Public
Edited
Apr 14, 2023
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function highlight(d) {
return /California/.test(d.state);
}
Insert cell
attempt = LineChart(rate, {
x: (d) => d.year,
y: (d) => d.rate,
z: (d) => d.state,
yLabel: "↑ Unemployment (%)",
width,
height: 500,
color: (d) => (d[0] == "California" ? "red" : "steelblue"), // THIS is where things seem to go unexpectedly wrong.
title: (d) => `${d.state} \n ${d.rate}`
})
Insert cell
// Copyright 2021 Observable, Inc.
// Released under the ISC license.
// https://observablehq.com/@d3/multi-line-chart
function LineChart(
data,
{
x = ([x]) => x, // given d in data, returns the (temporal) x-value
y = ([, y]) => y, // given d in data, returns the (quantitative) y-value
z = () => 1, // given d in data, returns the (categorical) z-value
title, // given d in data, returns the title text
defined, // for gaps in data
curve = d3.curveLinear, // method of interpolation between points
marginTop = 20, // top margin, in pixels
marginRight = 30, // right margin, in pixels
marginBottom = 30, // bottom margin, in pixels
marginLeft = 40, // left margin, in pixels
width = 640, // outer width, in pixels
height = 400, // outer height, in pixels
xType = d3.scaleLinear, // type of x-scale
xDomain, // [xmin, xmax]
xRange = [marginLeft, width - marginRight], // [left, right]
yType = d3.scaleLinear, // type of y-scale
yDomain, // [ymin, ymax]
yRange = [height - marginBottom, marginTop], // [bottom, top]
yFormat = d3.format("2s"),
percent_format,
yLabel, // a label for the y-axis
zDomain, // array of z-values
color, // = "currentColor", // stroke color of line, as a constant or a function of *z*
strokeLinecap, // stroke line cap of line
strokeLinejoin, // stroke line join of line
strokeWidth = 1.5, // stroke width of line
strokeOpacity, // stroke opacity of line
mixBlendMode = "multiply" // blend mode of lines
} = {}
) {
// Compute values.
const X = d3.map(data, x);
const Y = d3.map(data, y);
const Z = d3.map(data, z);
const O = d3.map(data, (d) => d);
if (defined === undefined) defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
const D = d3.map(data, defined);

// Compute default domains, and unique the z-domain.
if (xDomain === undefined) xDomain = d3.extent(X);
if (yDomain === undefined) yDomain = [d3.min(Y), d3.max(Y)];
if (zDomain === undefined) zDomain = Z;
// zDomain = new d3.InternSet(zDomain);

// Omit any data not present in the z-domain.
const I = d3.range(X.length); //filter((i) => zDomain.has(Z[i]));

// Construct scales and axes.
const xScale = xType(xDomain, xRange);
const yScale = yType(yDomain, yRange);
const xAxis = d3.axisBottom(xScale).ticks(width / 80, "0f");
const yAxis = d3.axisLeft(yScale).ticks(height / 60, yFormat);

// Compute titles.
const T =
title === undefined ? Z : title === null ? null : d3.map(data, title);

// Construct a line generator.
const line = d3
.line()
.defined((i) => D[i])
.curve(curve)
.x((i) => xScale(X[i]))
.y((i) => yScale(Y[i]));

const svg = d3
.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.style("-webkit-tap-highlight-color", "transparent")
.on("pointerenter", pointerentered)
.on("pointermove", pointermoved)
.on("pointerleave", pointerleft)
.on("touchstart", (event) => event.preventDefault());

svg
.append("g")
.attr("transform", `translate(0,${height - marginBottom})`)
.call(xAxis)
.attr("font-size", "12.5px")
.select(".domain")
.remove();

svg
.append("g")
.attr("transform", `translate(${marginLeft},0)`)
.call(yAxis)
.attr("font-size", "12.5px")
.call((g) => g.select(".domain").remove())
.call((g) =>
g
.append("text")
.attr("font-size", "12.5px")
.attr("x", -marginLeft)
.attr("y", 10)
// .attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(yLabel)
);
svg
.append("line")
.attr("x1", marginLeft)
.attr("y1", yScale(0))
.attr("x2", width - marginRight)
.attr("y2", yScale(0))
.attr("stroke-width", 1)
.attr("stroke", "black");

const serie = svg
.append("g")
.selectAll("g")
.data(d3.group(I, (i) => Z[i]))
.join("g");

const path = serie
.append("path")
.attr("fill", "none")
.attr("stroke", color)
.attr("stroke-width", strokeWidth)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-opacity", strokeOpacity)
.style("mix-blend-mode", "multiply")
.attr("d", ([, I]) => line(I));

serie
.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", 10)
.attr("text-anchor", "middle")
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.selectAll("text")
.data(([, I]) => I)
.join("text")
.attr("dy", "0.35em")
.attr("x", (i) => xScale(X[i]))
.attr("y", (i) => yScale(Y[i]))
.text((i) => yFormat(Y[i]))
.call((text) =>
text
.filter((_, j, I) => j === I.length - 1)
.append("tspan")
.attr("font-weight", "bold")
.text((i) => ` ${Z[i]}`)
)
.call((text) => text.clone(true))
.attr("fill", "none");

const dot = svg.append("g").attr("display", "none");

dot.append("circle").attr("r", 2.5);

function pointermoved(event) {
const [xm, ym] = d3.pointer(event);
const i = d3.least(I, (i) =>
Math.hypot(xScale(X[i]) - xm, yScale(Y[i]) - ym)
); // closest point
path
.style("stroke", ([z]) => (Z[i] === z ? null : "#ddd"))
.filter(([z]) => Z[i] === z)
.raise();
dot.attr("transform", `translate(${xScale(X[i])},${yScale(Y[i])})`);
if (T) {
const box = dot
.selectAll("path")
.data([,])
.join("path")
.attr("class", "tooltip");
const label = dot
.selectAll("text")
.data([,])
.join("text")
.call((text) =>
text
.selectAll("tspan")
.data(`${T[i]}`.split(/\n/))
.join("tspan")
.attr("x", 0)
.attr("y", (_, i) => `${i * 1.1}em`)
.attr("font-weight", (_, i) => (i ? null : "bold"))
.text((d) => d)
)
.attr("font-family", "sans-serif")
.attr("font-size", 12)
.attr("text-anchor", "middle")
.attr("y", -8);
const { x, y, width: w, height: h } = label.node().getBBox();
box.attr(
"d",
`M${-w / 2 - 10},5H-5l5,-5l5,5H${w / 2 + 10}v${h + 20}h-${w + 20}z`
);
const hbw = w / 2 + 10;
const hbh = h / 2 + 10;
const limitW = xScale(X[X.length - 1]);
const switchHW = limitW - hbw;
const tooltipOver = `M${-hbw},-5H-5l5,5l5,-5H${hbw}v${-hbh * 2}h-${
2 * hbw
}z`;
const tooltipUnder = `M${-hbw},5H-5l5,-5l5,5H${hbw}v${hbh * 2}h-${
2 * hbw
}z`;
const tooltipLeft = `M-5,${-hbh}V-5l5,5l-5,5V${hbh}H${-hbw * 2}V-${hbh}z`;
const tooltipRight = `M5,${-hbh}V-5l-5,5l5,5V${hbh}H${hbw * 2}V-${hbh}z`;
box.attr(
"d",
xm > switchHW ? tooltipLeft : xm < hbw ? tooltipRight : tooltipUnder
);
const shiftLabelX = xm > switchHW ? -hbw : xm < hbw ? hbw : 0;
const shiftLabelY = xm > switchHW || xm < hbw ? y - h / 2 + 15 : 15 - y;
label.attr("transform", `translate(${shiftLabelX},${shiftLabelY})`);
}
svg.property("value", O[i]).dispatch("input", { bubbles: true });
}

function pointerentered() {
path.style("mix-blend-mode", null).style("stroke", "#ddd");
dot.attr("display", null);
}

function pointerleft() {
path.style("mix-blend-mode", "multiply").style("stroke", null);
dot.attr("display", "none");
svg.node().value = null;
svg.dispatch("input", { bubbles: true });
}

return Object.assign(svg.node(), { value: null });
}
Insert cell
html`<style>
.tooltip {
fill: #FFFFFF;
opacity: 0.5;
}
</style>`
Insert cell
rate = FileAttachment("property_crime_rate_by_state.csv").csv({typed:true})
Insert cell
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