Public
Edited
May 15
Insert cell
Insert cell
oceanData.csv
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
processedData = ocean_data.map(d => ({
time: new Date(d.time),
temperature: d.sea_water_temperature,
qc: d.sea_water_temperature_qc_agg
})).filter(d => !isNaN(d.time.getTime()))
Insert cell
import {Plot} from "@observablehq/plot"
Insert cell
chartSetup = {
const width = 800
const height = 400
const margin = {top: 20, right: 30, bottom: 50, left: 50}

const x = d3.scaleUtc()
.domain(d3.extent(processedData, d => d.time))
.range([margin.left, width - margin.right])

const y = d3.scaleLinear()
.domain([d3.min(processedData, d => d.temperature) - 0.5,
d3.max(processedData, d => d.temperature) + 0.5])
.range([height - margin.bottom, margin.top])

const line = d3.line()
.x(d => x(d.time))
.y(d => y(d.temperature))

return {width, height, margin, x, y, line}
}
// things to check/improve
// check subset of data - does not change anything
// check left side zoom bounding
// select region of interest rather than point zoom
// slider for left and right inspection
Insert cell
viz = {
const {width, height, margin, x, y, line} = chartSetup

const container = d3.create("div")
.style("position", "relative")

const svg = container.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])

// Reset button
const resetBtn = svg.append("g")
.attr("transform", `translate(${width - margin.right - 70},${margin.top})`)
.style("cursor", "pointer")
resetBtn.append("rect")
.attr("width", 80)
.attr("height", 30)
.attr("rx", 4)
.attr("fill", "#f5f5f5")
.attr("stroke", "#ccc")
resetBtn.append("text")
.attr("x", 40)
.attr("y", 18)
.attr("text-anchor", "middle")
.attr("font-size", "12px")
.attr("fill", "#333")
.text("Reset View")

svg.append("defs").append("clipPath")
.attr("id", "chart-clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom)

const formatYear = d3.timeFormat("%Y")
const formatMonthYear = d3.timeFormat("%B %Y")
const formatDayYear = d3.timeFormat("%b %d %Y")

let currentZoomTransform = d3.zoomIdentity
let currentXScale = x.copy()
let isZoomed = false
let zoomLevel = 1

// determine time / date
function getTimeFormat(domain) {
const [start, end] = domain
const duration = end - start
const oneMonth = 1000 * 60 * 60 * 24 * 30
if (duration > oneMonth * 24) return formatYear
if (duration > oneMonth * 3) return formatMonthYear
return formatDayYear
}

// Add axes
const xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80).tickFormat(getTimeFormat(x.domain())))

svg.append("text")
.attr("class", "x-axis-label")
.attr("x", width / 2)
.attr("y", height - margin.bottom + 40)
.attr("text-anchor", "middle")
.text("Year");

const yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))

svg.append("text")
.attr("class", "y-axis-label")
.attr("x", -height / 2)
.attr("y", margin.left - 30)
.attr("transform", "rotate(-90)")
.attr("text-anchor", "middle")
.text("Degrees C");

const path = svg.append("path")
.datum(processedData)
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", line)
.attr("clip-path", "url(#chart-clip)")

// Tooltip
const tooltip = svg.append("g")
.style("pointer-events", "none")
.style("opacity", 0)

tooltip.append("circle")
.attr("r", 5)
.attr("fill", "red")

const tooltipText = tooltip.append("g")
.attr("text-anchor", "middle")

tooltipText.append("text")
.attr("class", "date-text")
.attr("y", -10)
.attr("font-family", "sans-serif")
.attr("font-size", 12)

tooltipText.append("text")
.attr("class", "temp-text")
.attr("y", 20)
.attr("font-family", "sans-serif")
.attr("font-size", 12)

// Horizontal Scrollbar
const scrollBar = container.append("input")
.attr("type", "range")
.attr("min", 0)
.attr("max", 1000)
.attr("value", 0)
.style("width", `${width}px`)
.style("margin-top", "10px")
.style("display", "none")
.style("opacity", 0)
.style("transition", "opacity 0.3s ease")

let userScrolling = false

scrollBar.on("input", function () {
if (!userScrolling) return
const t = currentZoomTransform
const scrollVal = +this.value
const scrollPercent = scrollVal / 1000
const fullWidth = width - margin.left - margin.right
const maxTranslate = fullWidth * (t.k - 1)
const tx = -scrollPercent * maxTranslate + margin.left
const newTransform = d3.zoomIdentity.translate(tx, 0).scale(t.k)
svg.call(zoom.transform, newTransform)
})

function updateScrollbarVisibility(isZoomed) {
scrollBar.style("display", isZoomed ? "block" : "none")
.style("opacity", isZoomed ? 1 : 0)
}

// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([1, 100])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("start", () => {
userScrolling = false
})
.on("zoom", (event) => {
currentZoomTransform = event.transform
currentXScale = currentZoomTransform.rescaleX(x)
isZoomed = currentZoomTransform.k > 1
zoomLevel = currentZoomTransform.k

svg.select(".x-axis")
.call(d3.axisBottom(currentXScale)
.ticks(width / 80)
.tickFormat(getTimeFormat(currentXScale.domain())))

path.attr("d", line.x(d => currentXScale(d.time)))

const t = currentZoomTransform
const fullWidth = width - margin.left - margin.right
const maxTranslate = fullWidth * (t.k - 1)
const scrollPos = -(t.x - margin.left) / maxTranslate
userScrolling = false
scrollBar.property("value", Math.max(0, Math.min(1000, scrollPos * 1000)))
userScrolling = true
updateScrollbarVisibility(isZoomed)
})
.on("end", () => {
userScrolling = true
})

svg.call(zoom)
userScrolling = true

// Box Zoom
let boxStart = null
let boxRect = null
svg.on("mousedown", function(event) {
if (event.target.tagName === 'button' || event.target.parentNode === resetBtn.node()) return
const [xm, ym] = d3.pointer(event)
boxStart = [xm, ym]
boxRect = svg.append("rect")
.attr("class", "zoom-box")
.attr("fill", "rgba(100, 149, 237, 0.2)")
.attr("stroke", "steelblue")
.attr("stroke-width", 1)
.attr("pointer-events", "none")
.attr("x", xm)
.attr("y", margin.top)
.attr("width", 0)
.attr("height", height - margin.top - margin.bottom)
svg.on("mousemove.boxzoom", function(event) {
const [x2] = d3.pointer(event)
const [x1] = boxStart
const left = Math.min(x1, x2)
const width = Math.abs(x2 - x1)
boxRect.attr("x", left).attr("width", width)
})
svg.on("mouseup.boxzoom", function(event) {
svg.on("mousemove.boxzoom", null).on("mouseup.boxzoom", null)
const [x1] = boxStart
const [x2] = d3.pointer(event)
if (Math.abs(x2 - x1) > 5) {
const xMin = x.invert(Math.min(x1, x2))
const xMax = x.invert(Math.max(x1, x2))
const scale = (width / (x(xMax) - x(xMin)))
const translate = margin.left - x(xMin) * scale
const zoomTransform = d3.zoomIdentity.translate(translate, 0).scale(scale)
svg.transition().duration(300).call(zoom.transform, zoomTransform)
}
boxRect.remove()
boxRect = null
})
})

// Hover behavior
svg.on("mousemove", function(event) {
if (boxStart) return
const [xm] = d3.pointer(event)
const time = currentXScale.invert(xm)
const [zoomStart, zoomEnd] = currentXScale.domain()
const visibleData = processedData.filter(d => d.time >= zoomStart && d.time <= zoomEnd)
const closest = d3.least(visibleData, d => Math.abs(d.time - time))
if (closest) {
const xPos = Math.max(margin.left, Math.min(width - margin.right, currentXScale(closest.time)))
tooltip.style("opacity", 1)
.attr("transform", `translate(${xPos},${y(closest.temperature)})`)
tooltip.select(".date-text").text(`${formatMonthYear(closest.time)}`)
tooltip.select(".temp-text").text(`${closest.temperature.toFixed(2)}°C`)
}
})

svg.on("mouseout", () => tooltip.style("opacity", 0))

// Click-to-zoom recursive
svg.on("click", function(event) {
if (boxStart || event.target.tagName === 'button' || event.target.parentNode === resetBtn.node()) return
const [xm] = d3.pointer(event)
const time = currentXScale.invert(xm)
const [zoomStart, zoomEnd] = currentXScale.domain()
const visibleData = processedData.filter(d => d.time >= zoomStart && d.time <= zoomEnd)
const closest = d3.least(visibleData, d => Math.abs(d.time - time))
if (closest) {
const baseZoomScale = 5
const zoomScale = baseZoomScale * (1 + (zoomLevel * 0.2))
const maxZoomScale = 100
const actualZoomScale = Math.min(zoomScale, maxZoomScale)
const svgCenter = (margin.left + (width - margin.right)) / 2
const zoomTransform = d3.zoomIdentity
.translate(svgCenter - x(closest.time) * actualZoomScale, 0)
.scale(actualZoomScale)
svg.transition()
.duration(500)
.call(zoom.transform, zoomTransform)
}
})

// Reset functionality
const resetView = () => {
zoomLevel = 1
svg.transition()
.duration(500)
.call(zoom.transform, d3.zoomIdentity)
}

resetBtn.on("click", resetView)
svg.on("dblclick", resetView)

const centeredDiv = d3.create("div")
.style("display", "flex")
.style("justify-content", "center")
.style("align-items", "center")
.style("width", "100%")
.style("padding", "20px 0");

centeredDiv.node().appendChild(container.node());
return centeredDiv.node();
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more