Public
Edited
May 11
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
averages = FileAttachment("averages.csv").csv({typed: true})
Insert cell
polls = FileAttachment("polls@1.csv").csv({typed: true})
Insert cell
Insert cell
Insert cell
Insert cell
color = Plot.scale({color: { domain: ["DEM", "IND", "REP", "LIB", "GRE"] }})
Insert cell
Insert cell
r = Plot.scale({r: { domain: [0, 3], range: [0.2, 2] }})
Insert cell
Insert cell
Plot.plot({
r,
color,
marks: [
Plot.ruleY([0]),
Plot.dot(polls, {
x: "date",
y: "pct",
fill: "party",
r: "numeric_grade",
fillOpacity: 0.2
}),
Plot.lineY(averages, {
x: "date",
y: "pct",
stroke: "party",
strokeWidth: 2,
tip: { channels: { candidate: "candidate" } }
})
]
})
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
mutable peek = null
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
customOverlapRender = ({ minDistance = 26 } = {}) =>
(index, scales, values, dimensions, context, next) => {
const { channels } = values;
const Z =
channels.z?.value ?? channels.stroke?.value ?? channels.fill?.value;
if (index.length >= 2) {
const a = index.find((i) => Z[i] === "DEM");
const b = index.find((i) => Z[i] === "REP");
let d = values.y[a] - values.y[b];
if (Math.abs(d) < minDistance) {
d = ((minDistance - Math.abs(d)) * Math.sign(d)) / 2;
const Y = values.y.slice();
Y[a] += d;
Y[b] -= d;
values = { ...values, y: Y };
}
}
return next(index, scales, values, dimensions, context);
}
Insert cell
Insert cell
Insert cell
Insert cell
plot = {
const datefmt = d3.utcFormat("%b. %d");
const chart = Plot.plot({
width: 550,
height: 230,
marginBottom: 38,
r,
color,
// title: html`<div style="font-family: var(--sans-serif); font-weight: 600;">National polling average`,
// caption: "Data: FiveThirtyEight — inspiration: The New York Times",
style: "user-select: none; touch-action: none; max-width: 100%;", // beware of preventing vertical scrolling
x: { domain: [new Date("2024-06-01"), new Date("2024-10-15")], grid: true },
y: {
domain: [24, 54],
ticks: 3,
tickFormat: (d) => `${d}%`,
grid: true,
label: null
},
marks: [
Plot.dot(polls, {
x: "date",
y: "pct",
fill: "party",
r: "numeric_grade",
fillOpacity: 0.2
}),

Plot.lineY(averages, {
x: "date",
y: "pct",
stroke: "party",
strokeWidth: 1,
curve: "natural",
opacity: 0.25
}),

Plot.dot(
averages,
pointerMX({
symbol: "triangle",
x: "date",
frameAnchor: "top",
atrest: "maxX",
fill: "black",
rotate: 180
})
),

Plot.ruleX(averages, pointerMX({ x: "date", atrest: "maxX" })),

Plot.lineY(
averages,
pointerMX({
x: "date",
atrest: "maxX",
y: "pct",
selector: "before",
z: "party",
stroke: "party",
curve: "natural",
strokeWidth: 3,
markerStart: true
})
),

Plot.text(
averages,
pointerMX(
renderMultipleText({
x: "date",
atrest: "maxX",
y: "pct",
channels: { z: "party" },
fill: "party",
selector: "eq",
fontWeight: "bold",
fontSize: 14,
text: (d) => `${d.candidate} | ${Math.round(d.pct)}%`,
textAnchor: "end",
stroke: "white",
dx: 50,
render: customOverlapRender()
})
)
),

Plot.text(
averages,
pointerMX({
x: "date",
atrest: "maxX",
text: ({ date }) => datefmt(date),
fill: "currentColor",
frameAnchor: "top",
dy: -15
})
)
]
});

return html`<div style="border: none 1.5px #333; padding-left: 10px; width: 570px;">${chart}`;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
pointerMX = pointerMore(Plot.pointerX)
Insert cell
pointerM = pointerMore(Plot.pointer)
Insert cell
pointerMY = pointerMore(Plot.pointerY)
Insert cell
function pointerMore(pointer) {
return ({ atrest = null, selector = "point", render, ...options } = {}) => {
const I = new WeakMap(); // Memoize the at rest index for each facet (for performance).

return pointer({
...options,
render(index, scales, values, dimensions, context, next) {
const facet = context.getMarkState(this).facets[index.fi ?? 0]; // full index for the current facet
const { x, x1, x2, y, y1, y2, z, stroke, fill } = values.channels;
const X = x?.value ?? x2?.value ?? x1?.value;
const Y = y?.value ?? y2?.value ?? y1?.value;
const VX = values.x ?? values.x2 ?? values.x1;
const VY = values.y ?? values.y2 ?? values.y1;
const Z = z?.value ?? stroke?.value ?? fill?.value;

// at rest
if (!I.has(facet)) {
let select;
switch (String(atrest).toLowerCase()) {
case "null":
select = () => [];
break;
case "minx":
if (!X) throw new Error("missing channel x");
select = (index) => [d3.least(index, (i) => X[i])];
break;
case "maxx":
if (!X) throw new Error("missing channel x");
select = (index) => [d3.greatest(index, (i) => X[i])];
break;
case "miny":
if (!Y) throw new Error("missing channel y");
select = (index) => [d3.least(index, (i) => Y[i])];
break;
case "maxy":
if (!Y) throw new Error("missing channel y");
select = (index) => [d3.greatest(index, (i) => Y[i])];
break;
case "first":
select = (index) => index.slice(0, 1);
break;
case "last":
select = (index) => index.slice(-1);
break;
// TODO top, bottom, left, right… ?
}
if (!select) throw new Error(`unsupported atrest method ${atrest}`);
I.set(facet, select(facet));
}

if (index.length === 0) index = I.get(facet);

// selector
switch (String(selector).toLowerCase()) {
case "point":
break;
case "before":
case "lte":
if (!VX) throw new Error("missing channel x");
index = facet.filter((i) => VX[i] <= VX[index[0]]);
break;
case "lt":
if (!VX) throw new Error("missing channel x");
index = facet.filter((i) => VX[i] < VX[index[0]]);
break;
case "after":
case "gte":
if (!VX) throw new Error("missing channel x");
index = facet.filter((i) => VX[i] >= VX[index[0]]);
break;
case "gt":
if (!VX) throw new Error("missing channel x");
index = facet.filter((i) => VX[i] > VX[index[0]]);
break;
case "eq":
case "x":
if (!VX) throw new Error("missing channel x");
index = facet.filter((i) => VX[i] == VX[index[0]]);
break;
case "y":
if (!VY) throw new Error("missing channel y");
index = facet.filter((i) => VY[i] == VY[index[0]]);
break;
case "z":
if (!Z) throw new Error("missing channel z");
index = facet.filter((i) => Z[i] == Z[index[0]]);
break;
default:
throw new Error(`unsupported selector ${selector}`);
}

return render
? render(index, scales, values, dimensions, context, next)
: next(index, scales, values, dimensions, context);
}
});
};
}
Insert cell
Insert cell
party = (PAR) => htl.html`<span ${{style: `border-bottom: solid 2px ${color.apply(PAR)};`}}>${PAR}</span>`
Insert cell
function renderMultipleText({render: prerender, ...options} = {}) {
const multiple = (index, scales, values, dimensions, context, next) => {
const g = next(index, scales, values, dimensions, context);
const text = g.querySelectorAll("text");
const white = g.getAttribute("stroke");

index.forEach((i, j) => {
text[j].setAttribute("stroke", "none");
text[j].setAttribute("fill", white);
const [candidate, pct] = text[j].textContent.split(" | ");
text[j].textContent = pct;
g.append(
svg`<rect x=${values.x[i]-38} y=${values.y[i]-10} width=43 rx=4 height=20 fill="${values.fill[i]}"></rect>${text[j]}<text dx=10 x=${values.x[i]} y=${values.y[i]} text-anchor="start" fill="${values.fill[i]}" dy="0.38em" stroke="${white}" stroke-width=3 paint-order="stroke">${candidate}</text>`
);
})

return g;
};

// combine the renderers
const render = prerender ? (i, s, v, d, c, next) => prerender(i, s, v, d, c, (i, s, v, d, c) => multiple(i, s, v, d, c, next))
: multiple;
return {...options, render}
}
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