viewof selectedYear = Inputs.range([2014, 2024], {step: 1, value: 2024, label: "Year", width: Math.min(360, width - 60)})
viewof isPlaying = Inputs.toggle({label: " Auto-Play", value: false})
// --- 4. COLOR LEGEND ---
// Local version of the Observable "@d3/color-legend" Legend (sequential scales only),
// so that nothing is fetched from observablehq.com at run time.
Legend = (color, {title, width = 320, height = 50, marginTop = 18, marginRight = 0,
marginBottom = 22, marginLeft = 0, ticks = width / 64, tickSize = 6} = {}) => {
const svg = d3.create("svg")
.attr("width", width).attr("height", height)
.attr("viewBox", [0, 0, width, height])
.style("overflow", "visible").style("display", "block");
const ramp = document.createElement("canvas");
ramp.width = 256; ramp.height = 1;
const context = ramp.getContext("2d");
for (let i = 0; i < 256; ++i) {
context.fillStyle = color.interpolator()(i / 255);
context.fillRect(i, 0, 1, 1);
}
svg.append("image")
.attr("x", marginLeft).attr("y", marginTop)
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom)
.attr("preserveAspectRatio", "none")
.attr("xlink:href", ramp.toDataURL());
const x = color.copy().interpolator(d3.interpolateRound(marginLeft, width - marginRight));
svg.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(d3.axisBottom(x).ticks(ticks).tickSize(tickSize))
.call(g => g.selectAll(".tick line").attr("y1", marginTop + marginBottom - height))
.call(g => g.select(".domain").remove())
.call(g => g.append("text")
.attr("x", marginLeft).attr("y", marginTop + marginBottom - height - 6)
.attr("fill", "currentColor").attr("text-anchor", "start")
.attr("font-weight", "bold").text(title));
return svg.node();
}
// --- 5. D3 CHART ---
chart = {
const width = 975;
const height = 600;
// Current data for the selected year
const currentYearData = dataByYear.get(selectedYear);
const zoom = d3.zoom()
.scaleExtent([1, 8])
.on("zoom", zoomed);
const root = d3.create("div")
.attr("class", "root")
.style("position", "relative")
.style("width", "100%")
.style("height", "auto");
const svg = root.append("svg")
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;")
.on("click", reset);
const g = svg.append("g");
// Fit projection to Switzerland
const projection = d3.geoMercator()
.fitSize([width, height], geoData);
const path = d3.geoPath().projection(projection);
// Color scale
const color = d3.scaleSequential(priceExtent, d3.interpolateReds);
// Legend
svg.append("g")
.attr("transform", "translate(20,20)")
.append(() => Legend(color, {
title: "Monthly Premium (CHF)",
width: 260
}));
// Tooltip Element
const tooltip = root
.append("div")
.style("position", "absolute")
.style("display", "none")
.style("background", "white")
.style("border", "1px solid #ccc")
.style("padding", "8px")
.style("border-radius", "4px")
.style("pointer-events", "none")
.style("box-shadow", "0 2px 4px rgba(0,0,0,0.1)")
.style("font-size", "0.9rem");
// Render Features
const features = g.append("g")
.attr("cursor", "pointer")
.selectAll("path")
.data(geoData.features)
.join("path")
.attr("fill", d => {
const val = currentYearData.get(d.properties.name);
return val ? color(val) : "#ccc";
})
.attr("stroke", "white")
.attr("stroke-width", 0.5)
.attr("d", path)
.on("click", clicked)
.on("mouseover", function(event, d) {
d3.select(this)
.attr("stroke", "black")
.attr("stroke-width", 2)
.raise();
tooltip.style("display", "block");
const val = currentYearData.get(d.properties.name);
tooltip.html(`
<strong>${d.properties.name}</strong><br>
${val ? Math.round(val) + " CHF" : "N/A"}
`);
})
.on("mousemove", function(event) {
// Position relative to the container
const [x, y] = d3.pointer(event, root.node());
tooltip
.style("top", (y - 10) + "px")
.style("left", (x + 15) + "px");
})
.on("mouseout", function() {
d3.select(this)
.attr("stroke", "white")
.attr("stroke-width", 0.5)
.lower(); // Lower back so borders don't overlap incorrectly
tooltip.style("display", "none");
});
svg.call(zoom);
function reset() {
svg.transition().duration(750).call(
zoom.transform,
d3.zoomIdentity,
d3.zoomTransform(svg.node()).invert([width / 2, height / 2])
);
}
function clicked(event, d) {
const [[x0, y0], [x1, y1]] = path.bounds(d);
event.stopPropagation();
svg.transition().duration(750).call(
zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(Math.min(8, 0.9 / Math.max((x1 - x0) / width, (y1 - y0) / height)))
.translate(-(x0 + x1) / 2, -(y0 + y1) / 2)
);
}
function zoomed(event) {
const {transform} = event;
g.attr("transform", transform);
g.attr("stroke-width", 1 / transform.k);
}
return root.node();
}