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])
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
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
}
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)")
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)
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)
}
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
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
})
})
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))
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)
}
})
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();
}