data = FileAttachment("data/corrected_counts.csv").csv({ typed: true })
diag = FileAttachment("data/diagnostics.csv").csv({ typed: true })
cams = FileAttachment("data/cameras.csv").csv({ typed: true })
dirData = FileAttachment("data/direction_counts.csv").csv({ typed: true })
dirLU = new Map(dirData.map(r => [r.camera_id + "|" + String(r.date) + "|" + r.minute, r])) // per-cell direction counts
titleToId = new Map(cams.map(c => [c.title, c.camera_id]))
flowState = ({ value: { dirs: [0, 1, 2, 3], cls: [0, 1, 2, 3, 4], dows: [1, 2, 3, 4, 5, 6, 7], win: [0, 1440], bg: -1 } }) // last-posted flow filter
scrubState = ({ center: 480, on: false, playing: false }) // scrub window centre + whether windowed + autoplay; persists across rebuilds
metric = "Per-day average" // counts are always shown as a per-day average
shortName = t => t.replace(/\s*\(.*\)\s*$/, "") // drop the "(Suburb)" suffix -> just the street
// wheel over the panel scrolls the panel (capture phase, so the Leaflet map can't swallow it)
panelScroll = {
const attach = () => {
const panel = document.querySelector(".panel");
if (!panel) { requestAnimationFrame(attach); return; }
if (panel.dataset.wheelBound) return;
panel.dataset.wheelBound = "1";
panel.addEventListener("wheel", e => {
const before = panel.scrollTop;
panel.scrollTop += e.deltaY;
if (panel.scrollTop !== before) e.preventDefault();
}, { passive: false, capture: true });
};
attach();
return html``;
}
// keep the control panel the same height as the chart card (panel scrolls internally if taller)
panelMatch = {
let ro;
const sync = () => {
const panel = document.querySelector(".panel"), card = document.querySelector(".chart-card");
if (!panel || !card) return;
const h = Math.min(card.offsetHeight, window.innerHeight - 24);
panel.style.maxHeight = h + "px"; // cap to the card (scroll if taller); don't stretch -> no trailing white space
};
const start = () => {
const card = document.querySelector(".chart-card");
if (!card) { requestAnimationFrame(start); return; }
ro = new ResizeObserver(sync); ro.observe(card);
window.addEventListener("resize", sync);
sync();
};
start();
return 0;
}
// focus toggle: expand one column (chart or image) to fill the card, hiding the panel + other column.
// clicking the active button again restores the balanced 3-way view.
dashFocus = {
window.dashFocus = (mode) => {
const d = document.querySelector(".dashboard");
if (!d) return;
const already = d.classList.contains(mode);
d.classList.remove("focus-chart", "focus-image");
if (!already) d.classList.add(mode);
// let the grid transition settle, then nudge the ResizeObservers so charts + flow refit to the new width
requestAnimationFrame(() => requestAnimationFrame(() => window.dispatchEvent(new Event("resize"))));
};
return 0;
}Sydney Cyclists
Sydney cyclist composition from TfNSW traffic camera data.
Location
Llib = {
for (let i = 0; i < 100 && !window.L; i++) await new Promise(r => setTimeout(r, 50));
if (!window.L) throw new Error("Leaflet failed to load");
return window.L;
}
viewof cam = {
const L = await Llib;
const UNSEL = { radius: 3.6, color: "#2f7a5f", weight: 1.25, fillColor: "#cfe0d6", fillOpacity: 0.9 };
const SEL = { radius: 4.2, color: "#123128", weight: 2, fillColor: "#274c48", fillOpacity: 1 };
const el = html`<div class="cam-map"></div>`;
const wrap = html`<div class="cam-picker">${el}</div>`;
wrap.value = "King St & Sussex St (Sydney)"; // default location on load
const map = L.map(el, { attributionControl: false, scrollWheelZoom: false, zoomSnap: 0 }); // fractional zoom -> tight fit
map.setView([-33.872, 151.206], 12); // initial view BEFORE adding layers (else Leaflet throws)
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
{ subdomains: "abcd", maxZoom: 19 }).addTo(map);
const pts = cams.filter(c => c.lat && c.lon).map(c => [+c.lat, +c.lon]);
const markers = {}, nodes = [];
const paint = () => {
for (const [t, m] of Object.entries(markers)) m.setStyle(t === wrap.value ? SEL : UNSEL);
};
const pick = t => { wrap.value = t; paint(); wrap.dispatchEvent(new Event("input", { bubbles: true })); };
for (const c of cams) {
if (!c.lat || !c.lon) continue;
const m = L.circleMarker([+c.lat, +c.lon], UNSEL).addTo(map).bindTooltip(shortName(c.title), { direction: "top" });
m.on("click", () => pick(c.title));
markers[c.title] = m;
nodes.push({ m, lat: +c.lat, lon: +c.lon });
}
paint();
// gentle repulsion in screen space: overlapping markers spread just enough to separate; recomputed
// from the TRUE positions on every zoom, so as you zoom in and there's room they settle onto their
// real coordinates (no push when nothing overlaps).
const relax = () => {
const R = 11; // min pixel distance between marker centres (~2×radius + a little)
const P = nodes.map(n => ({ m: n.m, p: map.latLngToLayerPoint([n.lat, n.lon]) }));
for (let it = 0; it < 120; it++) {
let moved = false;
for (let i = 0; i < P.length; i++) for (let j = i + 1; j < P.length; j++) {
let dx = P[j].p.x - P[i].p.x, dy = P[j].p.y - P[i].p.y, d = Math.hypot(dx, dy) || 0.01;
if (d < R) { const s = (R - d) / 2 / d; dx *= s; dy *= s; P[i].p.x -= dx; P[i].p.y -= dy; P[j].p.x += dx; P[j].p.y += dy; moved = true; }
}
if (!moved) break;
}
for (const { m, p } of P) m.setLatLng(map.layerPointToLatLng(p));
};
map.on("zoomend", relax);
// the element isn't in the DOM yet when this cell runs; fix map size + framing once it lands
const fit = () => {
if (!el.isConnected || el.clientHeight === 0) { requestAnimationFrame(fit); return; }
map.invalidateSize();
if (pts.length) map.fitBounds(pts, { padding: [16, 16] }); // tightest zoom that still shows every point
relax();
};
requestAnimationFrame(fit);
return wrap;
}Rider classes
canSplit = ["King St & Sussex St (Sydney)", "Kent Street (Sydney)"].includes(cam)
selStore = ({ value: ["All cyclists", "Lime", "Private", "Delivery"] }) // default classes + persists selection across location-driven rebuilds
viewof shown = {
canSplit; // depend on split-availability so the option set updates
const opts = canSplit
? ["All cyclists", "Lime", "Private", "Delivery", "Unclassified"]
: ["All cyclists"];
const keep = selStore.value.filter(c => opts.includes(c));
const el = Inputs.checkbox(opts, { value: keep.length ? keep : ["All cyclists"], label: "classes" });
el.addEventListener("input", () => { selStore.value = el.value; }); // plain object write -> no rebuild loop
return el;
}Days & direction
viewof dirs = {
// radial compass: four 90° wedges, each toggles its own direction; all four selected = all directions
const P = "#274c48";
const DIRS = [
{ i: 3, label: "Up", arrow: "↑", c: 270 },
{ i: 0, label: "Right", arrow: "→", c: 0 },
{ i: 1, label: "Down", arrow: "↓", c: 90 },
{ i: 2, label: "Left", arrow: "←", c: 180 }
];
const sel = new Set([0, 1, 2, 3]);
const S = 74, cx = S / 2, cy = S / 2, Ro = 34, Ri = 4, gap = 4, Rlab = (Ri + Ro) / 2;
const rad = a => a * Math.PI / 180, pt = (r, a) => [cx + r * Math.cos(rad(a)), cy + r * Math.sin(rad(a))];
const sector = c => {
const a0 = c - 45 + gap, a1 = c + 45 - gap;
const [x0i, y0i] = pt(Ri, a0), [x0o, y0o] = pt(Ro, a0), [x1o, y1o] = pt(Ro, a1), [x1i, y1i] = pt(Ri, a1);
return `M${x0i.toFixed(1)},${y0i.toFixed(1)} L${x0o.toFixed(1)},${y0o.toFixed(1)} A${Ro},${Ro} 0 0 1 ${x1o.toFixed(1)},${y1o.toFixed(1)} L${x1i.toFixed(1)},${y1i.toFixed(1)} A${Ri},${Ri} 0 0 0 ${x0i.toFixed(1)},${y0i.toFixed(1)} Z`;
};
const wrap = html`<div class="dir-compass"></div>`;
const render = () => {
let s = "";
for (const d of DIRS) {
const on = sel.has(d.i), fg = on ? "#fff" : "#9aa0a6", [lx, ly] = pt(Rlab, d.c);
s += `<path d="${sector(d.c)}" fill="${on ? P : "#f2f2f0"}" stroke="#fff" stroke-width="2" data-i="${d.i}" style="cursor:pointer"/>`;
s += `<text x="${lx.toFixed(1)}" y="${(ly + 4.5).toFixed(1)}" text-anchor="middle" fill="${fg}" font-size="14" style="pointer-events:none">${d.arrow}</text>`;
}
wrap.innerHTML = `<svg width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${s}</svg>`;
wrap.querySelectorAll("path[data-i]").forEach(p => p.onclick = () => {
const i = +p.dataset.i;
if (sel.has(i)) sel.delete(i); else sel.add(i); // plain per-direction toggle
commit();
});
};
const commit = () => { render(); wrap.value = new Set(sel); wrap.dispatchEvent(new Event("input", { bubbles: true })); };
wrap.value = new Set(sel);
render();
return wrap;
}Time bin
viewof binMin = { // bare slider — the bin-readout below shows the number
const el = html`<input type="range" min="5" max="120" step="5" value="60" style="width:100%;display:block">`;
const view = html`<div>${el}</div>`;
view.value = +el.value;
el.oninput = () => { view.value = +el.value; view.dispatchEvent(new Event("input", { bubbles: true })); };
return view;
}When cyclists ridedetected riders per hour, by rider type
classdef = [
["All cyclists", "__all__", "#3c4043"],
["Lime", "share", "#2e9e4f"],
["Private", "private", "#d95f02"],
["Delivery", "delivery", "#95c5c8"],
["Unclassified", "unclassified", "#9aa0a6"] // too small + too blurry + abstained
]
ALLKEYS = ["share", "private", "delivery", "untypeable", "too_small"] // mutually exclusive; sum = all cyclists
// shared chart legend (same size/colour/position across all three charts)
mkLegend = sel => html`<div class="chart-legend">${sel.map(([label,, color]) => html`<span><span class="chart-sw" style="background:${color}"></span>${label}</span>`)}</div>`
// value getter for a data row + class key; composite keys expand to their member columns
valOf = (d, key) =>
key === "__all__" ? d.share + d.private + d.delivery + d.untypeable + d.too_small
: key === "unclassified" ? d.untypeable + d.too_small
: d[key]
// fraction of a cell's riders heading in the selected 90° blocks (1 = no filter); scales the line/ridge/week
dirFrac = d => {
const r = dirLU.get(d.camera_id + "|" + String(d.date) + "|" + d.minute);
if (!r) return 1;
const cols = [r.dir_r, r.dir_d, r.dir_l, r.dir_u], tot = cols[0] + cols[1] + cols[2] + cols[3];
if (!tot) return 1;
let s = 0; for (const i of dirs) s += cols[i];
return s / tot;
}
// the current flow filter from all three controls: direction + rider class + day-of-week
flowMsg = {
const CLS = { "Lime": [0], "Private": [1], "Delivery": [2], "Unclassified": [3, 4], "All cyclists": [0, 1, 2, 3, 4] };
const cls = new Set(); for (const c of shown) (CLS[c] || []).forEach(i => cls.add(i));
return { dirs: [...dirs], cls: [...cls], dows: dowKeep ? [...dowKeep] : [1, 2, 3, 4, 5, 6, 7], win: scrubWin, bg: scrub.on ? Math.round(scrub.c) : -1 };
}
// the scrubber window in minutes: whole day when off; otherwise binMin-wide, centred on the scrub
// position and wrapping across midnight (so autoplay loops smoothly)
scrubWin = {
if (!scrub.on) return [0, 1440];
const half = binMin / 2;
return [((scrub.c - half) % 1440 + 1440) % 1440, ((scrub.c + half) % 1440 + 1440) % 1440];
}
// push it to the flow iframe live (no reload) whenever a control changes
flowPost = {
flowState.value = flowMsg;
const f = document.querySelector(".flow-frame iframe");
if (f && f.contentWindow) { try { f.contentWindow.postMessage(flowMsg, "*"); } catch (e) {} }
return 0;
}
fmtHour = h => { const x = ((h % 24) + 24) % 24, ap = x < 12 ? "am" : "pm"; const hh = x % 12 || 12; return `${hh}${ap}`; }
fmtTime = h => { let m = Math.max(0, Math.min(1439, Math.round(h * 60))); const H = Math.floor(m / 60), M = m % 60, ap = H < 12 ? "am" : "pm", hh = H % 12 || 12; return `${hh}:${String(M).padStart(2, "0")}${ap}`; }
XTICKS = d3.range(0, 25, plotWidth < 340 ? 6 : 3) // 3-hourly labels (fall back to 6-hourly only if very narrow)
XMINOR = d3.range(0, 25).filter(h => !XTICKS.includes(h)) // every other hour: tick mark only, no label
filteredLoc = data.filter(d => d.camera_id === titleToId.get(cam))
dowKeep = dayType === "Weekdays" ? new Set([2,3,4,5,6]) : dayType === "Weekends" ? new Set([1,7]) : null // dow: 1=Sun..7=Sat
filtered = dowKeep ? filteredLoc.filter(d => dowKeep.has(d.dow)) : filteredLoc // day view + strip respect the day toggle
ndays = new Set(filtered.map(d => String(d.date))).size || 1
// ---- animated "Through the day" line chart ----
// A persistent SVG: every line is resampled onto a fixed x-grid (so the point count never
// changes with the bin), then each point's y tweens to its new value when the bin slider moves.
// The line glides between shapes instead of the whole plot teleporting.
GX = d3.range(0, 24.0001, 0.2) // fixed hour grid — constant point count
// per-class minute-of-day prefix sums (built once per structural change) -> O(1) rolling-window counts
minuteSums = {
const sel = classdef.filter(c => shown.includes(c[0]));
const out = new Map();
for (const [label, key] of sel) {
const P = new Float64Array(1441); // P[i] = Σ valOf over minutes [0, i)
for (const d of filtered) P[Math.min(1439, d.minute) + 1] += valOf(d, key) * dirFrac(d);
for (let i = 0; i < 1440; i++) P[i + 1] += P[i];
out.set(label, P);
}
return out;
}
lineChart = {
const sel = classdef.filter(c => shown.includes(c[0]));
const W = plotWidth, H = mainH, m = { top: 20, right: 16, bottom: 40, left: 52 };
const iw = W - m.left - m.right, ih = H - m.top - m.bottom;
const x = d3.scaleLinear([0, 24], [0, iw]);
const y = d3.scaleLinear([0, 1], [ih, 0]);
const line = d3.line().x((v, i) => x(GX[i])).y(v => y(v)).curve(d3.curveMonotoneX);
const legend = mkLegend(sel);
const svg = d3.create("svg").attr("width", W).attr("height", H)
.attr("style", "max-width:100%;height:auto;display:block").attr("font-family", "Inter, sans-serif").attr("font-size", 11);
const g = svg.append("g").attr("transform", `translate(${m.left},${m.top})`);
const gGrid = g.append("g"), gY = g.append("g");
g.append("g").attr("transform", `translate(0,${ih})`) // minor hour ticks: half height, no labels
.call(d3.axisBottom(x).tickValues(XMINOR).tickSize(3).tickFormat("")).call(s => { s.select(".domain").remove(); s.selectAll("line").attr("stroke", "#c9ccd2"); });
g.append("g").attr("transform", `translate(0,${ih})`) // labelled ticks: full height, grey text to match the y-axis
.call(d3.axisBottom(x).tickValues(XTICKS).tickSize(6).tickFormat(fmtHour))
.call(s => { s.select(".domain").remove(); s.selectAll("line").attr("stroke", "#c9ccd2"); s.selectAll("text").attr("fill", "#8a8f98"); });
g.append("text").attr("x", -m.left + 2).attr("y", -6).attr("fill", "#6b7178").attr("font-size", 11).text("riders / hour · typical day");
g.append("text").attr("x", iw / 2).attr("y", ih + 34).attr("text-anchor", "middle").attr("fill", "#6b7178").attr("font-size", 11).text("Hour of day");
const paths = new Map(sel.map(([label,, color]) =>
[label, g.append("path").attr("fill", "none").attr("stroke", color).attr("stroke-width", 2.5).attr("stroke-linejoin", "round")]));
// hover: dashed vertical guide + time-of-day readout (nearest minute) in a small pill
const hov = g.append("g").style("display", "none");
const hovL = hov.append("line").attr("y1", 20).attr("y2", ih).attr("stroke", "#8a8f98").attr("stroke-width", 1).attr("stroke-dasharray", "4 3");
const hovBg = hov.append("rect").attr("y", 2).attr("height", 16).attr("width", 54).attr("rx", 4).attr("fill", "#fff").attr("stroke", "#e0ddd4");
const hovT = hov.append("text").attr("y", 13).attr("fill", "#3a4045").attr("font-size", 11).attr("font-weight", 600).attr("text-anchor", "middle");
g.append("rect").attr("width", iw).attr("height", ih).attr("fill", "none").attr("pointer-events", "all")
.on("mousemove", ev => {
const px = Math.max(0, Math.min(iw, d3.pointer(ev)[0])), cx = Math.max(28, Math.min(iw - 28, px));
hov.style("display", null); hovL.attr("x1", px).attr("x2", px);
hovBg.attr("x", cx - 27); hovT.attr("x", cx).text(fmtTime(x.invert(px)));
})
.on("mouseleave", () => hov.style("display", "none"));
// scrub-window band(s) just under the x-axis (matches the flow scrubber); two rects handle midnight wrap
const bandR = [0, 1].map(() => g.append("rect").attr("y", ih + 1).attr("height", 6).attr("rx", 2).attr("fill", "rgba(39,76,72,0.16)").attr("stroke", "#274c48").attr("stroke-width", 1.2).style("display", "none"));
const setBand = (on, t0h, t1h) => {
bandR.forEach(r => r.style("display", "none"));
if (!on) return;
const seg = (r, a, b) => r.attr("x", x(a)).attr("width", Math.max(2, x(b) - x(a))).style("display", null);
if (t0h <= t1h) seg(bandR[0], t0h, t1h); else { seg(bandR[0], t0h, 24); seg(bandR[1], 0, t1h); }
};
let curY = new Map(sel.map(([label]) => [label, GX.map(() => 0)])), curMax = 1, raf = null, primed = false, gridVals = [0];
const gfmt = d3.format("~g"); // plain numbers, no SI prefix (avoids e.g. 0.2 -> "200m")
const setGrid = (a, b) => { // stable tick values for a tween (covers both ends)
const top = Math.max(a, b, 1), step = d3.tickStep(0, top, 4) || 1, vals = [];
for (let v = 0; v <= top + step * 0.5; v += step) vals.push(+v.toFixed(6));
gridVals = vals;
};
const paint = () => {
y.domain([0, curMax || 1]); // exact domain (no .nice) so the scale tweens smoothly
const fade = yy => Math.max(0, Math.min(1, yy / 14)); // a line entering from the top edge glides in, not pops
const gl = gGrid.selectAll("line").data(gridVals, d => d);
gl.enter().append("line").attr("x1", 0).attr("x2", iw).attr("stroke", "#e6e3da").attr("shape-rendering", "crispEdges")
.merge(gl).attr("y1", d => y(d)).attr("y2", d => y(d)).attr("opacity", d => fade(y(d)));
gl.exit().remove();
const tl = gY.selectAll("text").data(gridVals, d => d);
tl.enter().append("text").attr("x", -8).attr("text-anchor", "end").attr("dy", "0.32em").attr("fill", "#8a8f98").attr("font-size", 11)
.merge(tl).attr("y", d => y(d)).attr("opacity", d => fade(y(d))).text(d => gfmt(d));
tl.exit().remove();
for (const [label, ys] of curY) paths.get(label).attr("d", line(ys));
};
paint();
const target = binMin => {
// rolling window of width binMin (minutes), centred on each grid point, wrapping at midnight;
// value = riders in that window on a typical day, expressed per hour
const w = Math.round(binMin), half = Math.round(binMin / 2), perHour = (60 / binMin) / ndays;
const out = new Map(); let tmax = 1e-6;
for (const [label] of sel) {
const P = minuteSums.get(label), total = P[1440];
const yg = GX.map(gx => {
const a = (((Math.round(gx * 60) - half) % 1440) + 1440) % 1440, b = a + w; // circular window [a, b)
const cnt = b <= 1440 ? P[b] - P[a] : total - P[a] + P[b - 1440];
return cnt * perHour;
});
out.set(label, yg); tmax = Math.max(tmax, d3.max(yg) || 0);
}
return { out, tmax };
};
const node = html`<div>${legend}${svg.node()}</div>`;
const update = binMin => {
const { out, tmax } = target(binMin);
if (raf) cancelAnimationFrame(raf);
if (!primed) { // first paint after a (re)build (e.g. resize) -> snap, don't animate from flat
primed = true; curY = out; curMax = tmax; setGrid(tmax, tmax); paint(); return;
}
const fromY = new Map([...curY].map(([l, a]) => [l, a.slice()])), fromMax = curMax, dur = 260;
setGrid(fromMax, tmax); // hold these tick values for the whole tween so the lines slide, not re-tick
let t0 = null;
const step = ts => {
if (t0 == null) t0 = ts;
const k = Math.min(1, (ts - t0) / dur), e = d3.easeCubicOut(k);
for (const [l, tg] of out) { const f = fromY.get(l) || tg.map(() => 0); curY.set(l, tg.map((tv, i) => f[i] + (tv - f[i]) * e)); }
curMax = fromMax + (tmax - fromMax) * e;
paint();
raf = k < 1 ? requestAnimationFrame(step) : null;
};
raf = requestAnimationFrame(step);
};
return { node, update, band: setBand };
}
lineChartTick = { lineChart.update(binMin); return binMin; } // side-effect cell: tween on every bin change
// ridgeline data: total riders (mutually-exclusive shown classes) by hour, one ridge per weekday
DOWLABEL = ({ 1: "Sun", 2: "Mon", 3: "Tue", 4: "Wed", 5: "Thu", 6: "Fri", 7: "Sat" })
DOWORDER = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// ---- animated "By day of week" ridgeline: same rolling-window smoothing + tween as the line chart ----
ridgeSums = {
const sel = classdef.filter(c => shown.includes(c[0]));
const out = new Map(); // `${dow}|${label}` -> { P: minute-of-day prefix sums, days }
for (let dow = 1; dow <= 7; dow++) {
const rs = filteredLoc.filter(d => d.dow === dow); // ridge is per-weekday; ignores the day toggle
const days = new Set(rs.map(d => String(d.date))).size || 1;
for (const [label, key] of sel) {
const P = new Float64Array(1441);
for (const d of rs) P[Math.min(1439, d.minute) + 1] += valOf(d, key) * dirFrac(d);
for (let i = 0; i < 1440; i++) P[i + 1] += P[i];
out.set(dow + "|" + label, { P, days });
}
}
return out;
}
ridgeChart = {
const sel = classdef.filter(c => shown.includes(c[0]));
const W = plotWidth, H = mainH, mL = 54, mR = 14, mb = 40;
const iw = W - mL - mR;
const order = DOWORDER.map(lab => +Object.keys(DOWLABEL).find(k => DOWLABEL[k] === lab)); // Mon..Sun (top->bottom)
const dows = order.filter(d => !dowKeep || dowKeep.has(d)); // respect the Days toggle (All / Weekdays / Weekends)
const rowStep = (H - mb) / (dows.length + 1.5), headTop = rowStep * 1.7, exagg = rowStep * 1.5; // rows + headroom; peaks overlap
const baseOf = dow => headTop + dows.indexOf(dow) * rowStep;
const x = d3.scaleLinear([0, 24], [0, iw]);
const area = d3.area().x((d, i) => x(GX[i])).curve(d3.curveBasis);
const legend = sel.length > 1 ? mkLegend(sel) : html`<span></span>`;
const svg = d3.create("svg").attr("width", W).attr("height", H)
.attr("style", "max-width:100%;height:auto;display:block").attr("font-family", "Inter, sans-serif").attr("font-size", 11);
const g = svg.append("g").attr("transform", `translate(${mL},0)`);
g.append("g").attr("transform", `translate(0,${H - mb})`) // minor hour ticks: half height, no labels
.call(d3.axisBottom(x).tickValues(XMINOR).tickSize(3).tickFormat("")).call(s => { s.select(".domain").remove(); s.selectAll("line").attr("stroke", "#c9ccd2"); });
g.append("g").attr("transform", `translate(0,${H - mb})`) // labelled ticks: full height, grey text to match the y-axis
.call(d3.axisBottom(x).tickValues(XTICKS).tickSize(6).tickSizeOuter(0).tickFormat(fmtHour))
.call(s => { s.select(".domain").attr("stroke", "#e6e3da"); s.selectAll("line").attr("stroke", "#c9ccd2"); s.selectAll("text").attr("fill", "#8a8f98"); }); // keep the baseline (matches the other two charts' x-axis line)
g.append("text").attr("x", iw / 2).attr("y", H - mb + 34).attr("text-anchor", "middle").attr("fill", "#6b7178").attr("font-size", 11).text("Hour of day");
const paths = new Map(); // rows top->bottom so lower ridges overlap the ones above
for (const dow of dows) for (const [label,, color] of sel)
paths.set(dow + "|" + label, g.append("path").attr("fill", color).attr("fill-opacity", 0.72));
for (const dow of dows)
g.append("text").attr("x", -10).attr("y", baseOf(dow)).attr("text-anchor", "end").attr("dy", "0.32em")
.attr("fill", "#8a8f98").attr("font-size", 11).text(DOWLABEL[dow]);
// hover: dashed vertical guide + time-of-day readout (nearest minute) in a small pill
const H2 = H - mb;
const hov = g.append("g").style("display", "none");
const hovL = hov.append("line").attr("y1", 20).attr("y2", H2).attr("stroke", "#8a8f98").attr("stroke-width", 1).attr("stroke-dasharray", "4 3");
const hovBg = hov.append("rect").attr("y", 2).attr("height", 16).attr("width", 54).attr("rx", 4).attr("fill", "#fff").attr("stroke", "#e0ddd4");
const hovT = hov.append("text").attr("y", 13).attr("fill", "#3a4045").attr("font-size", 11).attr("font-weight", 600).attr("text-anchor", "middle");
g.append("rect").attr("width", iw).attr("height", H2).attr("fill", "none").attr("pointer-events", "all")
.on("mousemove", ev => {
const px = Math.max(0, Math.min(iw, d3.pointer(ev)[0])), cx = Math.max(28, Math.min(iw - 28, px));
hov.style("display", null); hovL.attr("x1", px).attr("x2", px);
hovBg.attr("x", cx - 27); hovT.attr("x", cx).text(fmtTime(x.invert(px)));
})
.on("mouseleave", () => hov.style("display", "none"));
// scrub-window band(s) just under the x-axis (matches the flow scrubber); two rects handle midnight wrap
const bandR = [0, 1].map(() => g.append("rect").attr("y", H2 + 1).attr("height", 6).attr("rx", 2).attr("fill", "rgba(39,76,72,0.16)").attr("stroke", "#274c48").attr("stroke-width", 1.2).style("display", "none"));
const setBand = (on, t0h, t1h) => {
bandR.forEach(r => r.style("display", "none"));
if (!on) return;
const seg = (r, a, b) => r.attr("x", x(a)).attr("width", Math.max(2, x(b) - x(a))).style("display", null);
if (t0h <= t1h) seg(bandR[0], t0h, t1h); else { seg(bandR[0], t0h, 24); seg(bandR[1], 0, t1h); }
};
let curShape = new Map([...paths.keys()].map(k => [k, GX.map(() => 0)])), raf = null, primed = false;
const paint = () => {
for (const dow of dows) {
const base = baseOf(dow);
area.y0(base).y1(s => base - s * exagg);
for (const [label] of sel) paths.get(dow + "|" + label).attr("d", area(curShape.get(dow + "|" + label)));
}
};
paint();
const target = binMin => { // rolling window per (weekday, class); normalise each class by its weekly peak
const w = Math.round(binMin), half = Math.round(binMin / 2), raw = new Map(), classMax = {};
for (const dow of dows) for (const [label] of sel) {
const { P, days } = ridgeSums.get(dow + "|" + label), total = P[1440];
const arr = GX.map(gx => {
const a = (((Math.round(gx * 60) - half) % 1440) + 1440) % 1440, b = a + w;
const cnt = b <= 1440 ? P[b] - P[a] : total - P[a] + P[b - 1440];
return cnt / days; // riders in window on a typical <weekday>
});
raw.set(dow + "|" + label, arr);
classMax[label] = Math.max(classMax[label] || 1e-6, d3.max(arr) || 0);
}
const out = new Map();
for (const [k, arr] of raw) out.set(k, arr.map(v => v / classMax[k.split("|")[1]]));
return out;
};
const update = binMin => {
const out = target(binMin);
if (raf) cancelAnimationFrame(raf);
if (!primed) { primed = true; curShape = out; paint(); return; }
const from = new Map([...curShape].map(([k, a]) => [k, a.slice()])), dur = 260;
let t0 = null;
const step = ts => {
if (t0 == null) t0 = ts;
const k = Math.min(1, (ts - t0) / dur), e = d3.easeCubicOut(k);
for (const [key, tg] of out) { const f = from.get(key) || tg.map(() => 0); curShape.set(key, tg.map((tv, i) => f[i] + (tv - f[i]) * e)); }
paint();
raf = k < 1 ? requestAnimationFrame(step) : null;
};
raf = requestAnimationFrame(step);
};
return { node: html`<div>${legend}${svg.node()}</div>`, update, band: setBand };
}
ridgeTick = { ridgeChart.update(binMin); return binMin; } // side-effect cell: tween the ridge on bin change
// keep the chart scrub-band in sync with the flow scrubber window (hidden in whole-day mode)
scrubBandSync = { const [t0, t1] = scrubWin; lineChart.band(scrub.on, t0 / 60, t1 / 60); ridgeChart.band(scrub.on, t0 / 60, t1 / 60); return 0; }
// charts size to the LEFT half of the card (.chart-main); resizes when the panel collapses
cardWidth = Generators.observe(notify => {
const el = document.querySelector(".chart-main");
let t;
const push = () => { clearTimeout(t); t = setTimeout(() => notify(el.clientWidth), 130); };
const ro = new ResizeObserver(push);
ro.observe(el);
notify(el.clientWidth); // immediate first render
return () => { ro.disconnect(); clearTimeout(t); };
})
plotWidth = Math.max(300, cardWidth - 8)
// width of the (fixed-size) image column; the image is 4:3, so its height is width*0.75
flowFrameW = Generators.observe(notify => {
let ro, last;
const measure = () => {
const el = document.querySelector(".flow-frame");
if (!el) return;
const v = el.clientWidth;
if (v && v !== last) { last = v; notify(v); }
};
const start = () => {
const el = document.querySelector(".flow-frame");
if (!el) { requestAnimationFrame(start); return; }
ro = new ResizeObserver(measure); ro.observe(el);
window.addEventListener("resize", measure);
measure();
};
start();
return () => { if (ro) ro.disconnect(); window.removeEventListener("resize", measure); };
})
// size the chart so its x-axis lines up with the bottom of the 4:3 image (minus the chart's
// tabs+legend header, which the image column doesn't have)
stripH = 132 // callability strip block (chart + axis + heading)
mainH = Math.max(300, Math.round(flowFrameW * 0.75) + 18){
if (shown.length === 0)
return html`<div class="empty-chart" style="height:${mainH}px">Pick a rider class to show.</div>`;
if (chartTab === "Through the day") return lineChart.node; // persistent SVG; binMin tweens it (see lineChartTick)
if (chartTab === "By week") {
// weekly totals (Monday-anchored) for the selected location + day filter; one line per class
const wk = new Map();
for (const d of filtered) {
const k = +d3.utcMonday(new Date(d.date));
let o = wk.get(k); if (!o) { o = { share: 0, private: 0, delivery: 0, untypeable: 0, too_small: 0 }; wk.set(k, o); }
const f = dirFrac(d);
o.share += d.share * f; o.private += d.private * f; o.delivery += d.delivery * f; o.untypeable += d.untypeable * f; o.too_small += d.too_small * f;
}
const weekKeys = [...wk.keys()].sort((a, b) => a - b);
const sel = classdef.filter(c => shown.includes(c[0]));
const s = sel.flatMap(([label, key]) => weekKeys.map(k => ({
week: new Date(k), class: label,
value: valOf(wk.get(k), key)
})));
return html`<div>${mkLegend(sel)}${Plot.plot({
width: plotWidth, height: mainH, marginLeft: 52, marginBottom: 40,
style: { fontFamily: "Inter, sans-serif", fontSize: "11px", color: "#8a8f98" },
x: { type: "utc", label: null, ticks: weekKeys.map(k => new Date(k)), tickFormat: d => d3.utcFormat("%-d %b")(d) },
y: { label: "riders detected / week", grid: true, ticks: 4, tickSize: 0 },
color: { domain: sel.map(c => c[0]), range: sel.map(c => c[2]) },
marks: [
Plot.ruleY([0], { stroke: "#e0ddd4" }),
Plot.line(s, { x: "week", y: "value", stroke: "class", strokeWidth: 2.5, curve: "linear" }),
Plot.dot(s, { x: "week", y: "value", fill: "class", stroke: "white", strokeWidth: 1, r: 3.5 })
]
})}</div>`;
}
return ridgeChart.node; // persistent SVG; binMin tweens it (see ridgeTick)
}Computer vision pipeline diagnostics by time of day — how each stage behaves over the day at the selected location
Riders we can classify — share of detected riders large & sharp enough to identify a type
{
// classifiability by time of day for the selected location: what share of detected
// riders is large + sharp enough to type (callable) vs only countable (too_small).
// Fixed HOURLY bins (independent of the main time-bin slider) for stable denominators —
// avoids on/off flicker at the min-count threshold on sparse night bins.
const HB = 24;
const cal = new Float64Array(HB), tot = new Float64Array(HB);
for (const d of filtered) {
const b = Math.min(HB - 1, Math.floor(d.minute / 60));
const c = d.share + d.private + d.delivery + d.untypeable; // callable = typed (incl. untypeable abstain)
cal[b] += c; tot[b] += c + d.too_small; // total detected riders
}
// require >=15 detected riders in an hour before drawing a %, else leave a gap:
// low-volume cameras (Alison, Wentworth) otherwise show 0/100% noise on 1-2 riders.
const cd = Array.from(cal, (v, b) => ({ x: b + 0.5, pct: tot[b] >= 15 ? v / tot[b] : null }));
return Plot.plot({
width: plotWidth, height: 104,
marginLeft: 52, marginRight: 14, marginTop: 6, marginBottom: 26,
style: { fontFamily: "Inter, sans-serif", fontSize: "12px" },
x: { domain: [0, 24], ticks: XTICKS, tickFormat: fmtHour, label: null },
y: { domain: [0, 1], ticks: [0, 0.5, 1], tickFormat: d => `${Math.round(d * 100)}%`, label: null, grid: true },
marks: [
Plot.ruleY([0], { stroke: "#e0ddd4" }),
Plot.areaY(cd, { x: "x", y: "pct", fill: "#274c48", fillOpacity: 0.13, curve: "monotone-x" }),
Plot.lineY(cd, { x: "x", y: "pct", stroke: "#274c48", strokeWidth: 2, curve: "monotone-x" })
]
});
}{
const dfil0 = diag.filter(d => d.camera_id === titleToId.get(cam));
const dfil = dowKeep ? dfil0.filter(d => dowKeep.has(d.dow)) : dfil0; // respect the day toggle
const FIELDS = ["n_all","n_fp","n_rider","n_big","n_sharp","n_callable","sum_riderh","sum_sharp","n_grey","sum_frames","sum_dwell","n_deliv","n_untypeable"];
const agg = Array.from({ length: 24 }, () => Object.fromEntries(FIELDS.map(f => [f, 0])));
for (const d of dfil) { const h = +d.hr; if (h >= 0 && h < 24) for (const f of FIELDS) agg[h][f] += (+d[f] || 0); }
// spec: guard = which count must be >=15 for a bin to draw (null = always show)
const specs = [
{ label: "Total cyclists detected since July 2026", desc: "by hour of day, summed over the whole collection period", guard: null, fn: a => a.n_rider },
{ label: "Mean cyclist height in pixels (≥80 = classifiable)", desc: "how large riders appear in frame", guard: "n_rider", fn: a => a.sum_riderh / a.n_rider, cutoff: 80 },
{ label: "Mean sharpness (≥600 = classifiable)", desc: "image clarity — drops at night on dim cameras", guard: "n_rider", fn: a => a.sum_sharp / a.n_rider, cutoff: 600 }
];
const mk = spec => {
const cd = agg.map((a, h) => ({
x: h + 0.5,
v: (spec.guard === null || a[spec.guard] >= 15) ? spec.fn(a) : null
}));
return html`<div class="mini">
<div class="mini-label">${spec.label} <span>— ${spec.desc}</span></div>
${Plot.plot({
width: plotWidth, height: 82, marginLeft: 52, marginRight: 14, marginTop: 4, marginBottom: 20,
style: { fontFamily: "Inter, sans-serif", fontSize: "11px" },
x: { domain: [0, 24], ticks: XTICKS, tickFormat: fmtHour, label: null },
y: { ticks: 3, label: null, grid: true },
marks: [
Plot.ruleY([0], { stroke: "#e0ddd4" }),
Plot.areaY(cd, { x: "x", y: "v", fill: "#274c48", fillOpacity: 0.1, curve: "monotone-x" }),
Plot.lineY(cd, { x: "x", y: "v", stroke: "#274c48", strokeWidth: 1.75, curve: "monotone-x" }),
spec.cutoff != null ? Plot.ruleY([spec.cutoff], { stroke: "#6b7178", strokeDasharray: "4 3", strokeWidth: 1.5 }) : null
].filter(Boolean)
})}
</div>`;
};
return html`<div class="mini-grid">${specs.map(mk)}</div>`;
}{
// image title + a direction-of-travel colour wheel (same hsl(heading) mapping as the flow arrows;
// screen convention: 0°=right, 90°=down, 180°=left, 270°=up)
const N = 8, S = 22, cx = S / 2, cy = S / 2, r0 = 3, r1 = 9, hl = 2.6;
let g = "";
for (let i = 0; i < N; i++) {
const deg = i / N * 360, a = deg * Math.PI / 180, ca = Math.cos(a), sa = Math.sin(a);
const x2 = cx + ca * r1, y2 = cy + sa * r1, col = `hsl(${deg},90%,58%)`;
g += `<g stroke="${col}" stroke-width="1.3" stroke-linecap="round" fill="none">
<line x1="${(cx + ca * r0).toFixed(1)}" y1="${(cy + sa * r0).toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}"/>
<line x1="${x2.toFixed(1)}" y1="${y2.toFixed(1)}" x2="${(x2 - hl * Math.cos(a - 0.5)).toFixed(1)}" y2="${(y2 - hl * Math.sin(a - 0.5)).toFixed(1)}"/>
<line x1="${x2.toFixed(1)}" y1="${y2.toFixed(1)}" x2="${(x2 - hl * Math.cos(a + 0.5)).toFixed(1)}" y2="${(y2 - hl * Math.sin(a + 0.5)).toFixed(1)}"/>
</g>`;
}
const el = document.createElement("div");
el.className = "col-title";
el.innerHTML = `<b>Which way they ride</b><span>each arrow is a detected cyclist; colour = direction of travel ` +
`<svg class="dir-wheel" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}" role="img" aria-label="colour shows direction of travel">${g}</svg></span>`;
return el;
}{
// live arrow-flow for the selected camera (standalone canvas page generated by cv/heading_flow.py)
const slug = cam.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
const f = html`<iframe src="flow/flow_${slug}.html" loading="lazy"></iframe>`;
f.addEventListener("load", () => { try { f.contentWindow.postMessage(flowState.value, "*"); } catch (e) {} }); // apply current filters after (re)load
return f;
}viewof scrub = {
// whole day by default; drag the track to scrub a binMin-wide window, or press play to loop it
const W = Math.max(240, flowFrameW), mL = 8, mR = 8, iw = W - mL - mR, Ht = 28, ay = 8;
const x = d3.scaleLinear([0, 24], [0, iw]);
const half = binMin / 2;
const btnPlay = html`<button class="scrub-btn" title="Play / pause">▶</button>`;
const btnAll = html`<button class="scrub-btn all">All day</button>`;
const wrap = html`<div class="scrubber"><div class="scrub-ctrls">${btnPlay}${btnAll}</div></div>`;
const svg = d3.create("svg").attr("width", W).attr("height", Ht).attr("style", "display:block;touch-action:none;cursor:ew-resize").attr("font-family", "Inter, sans-serif").attr("font-size", 11);
const g = svg.append("g").attr("transform", `translate(${mL},0)`);
g.append("g").attr("transform", `translate(0,${ay})`).call(d3.axisBottom(x).tickValues(XMINOR).tickSize(3).tickFormat("")).call(s => { s.select(".domain").remove(); s.selectAll("line").attr("stroke", "#c9ccd2"); });
g.append("g").attr("transform", `translate(0,${ay})`).call(d3.axisBottom(x).tickValues(XTICKS).tickSize(6).tickFormat(fmtHour)).call(s => { s.select(".domain").remove(); s.selectAll("line").attr("stroke", "#c9ccd2"); s.selectAll("text").attr("fill", "#8a8f98"); });
g.append("line").attr("x1", 0).attr("x2", iw).attr("y1", ay).attr("y2", ay).attr("stroke", "#e0ddd4");
const winR = [0, 1].map(() => g.append("rect").attr("y", 2).attr("height", 6).attr("rx", 3).attr("fill", "rgba(39,76,72,0.16)").attr("stroke", "#274c48").attr("stroke-width", 1.4).style("display", "none"));
const drawWin = () => {
winR.forEach(r => r.style("display", "none"));
if (!scrubState.on) return;
const c = scrubState.center, t0 = ((c - half) % 1440 + 1440) % 1440, t1 = ((c + half) % 1440 + 1440) % 1440;
const seg = (r, a, b) => r.attr("x", x(a / 60)).attr("width", Math.max(3, x(b / 60) - x(a / 60))).style("display", null);
if (t0 <= t1) seg(winR[0], t0, t1); else { seg(winR[0], t0, 1440); seg(winR[1], 0, t1); }
};
const draw = () => { drawWin(); btnPlay.textContent = scrubState.playing ? "⏸" : "▶"; btnAll.classList.toggle("on", !scrubState.on); };
const emit = () => { wrap.value = { c: scrubState.center, on: scrubState.on }; wrap.dispatchEvent(new Event("input", { bubbles: true })); };
const setPx = px => { scrubState.on = true; scrubState.center = ((x.invert(Math.max(0, Math.min(iw, px - mL))) * 60) % 1440 + 1440) % 1440; draw(); emit(); };
let raf = null, last = null, lastEmit = 0;
const step = ts => {
if (last == null) last = ts;
scrubState.center = (scrubState.center + (ts - last) / 1000 * (1440 / 20) * 3) % 1440; // full day ~6.7s (3× speed)
last = ts; drawWin();
if (ts - lastEmit > 45) { lastEmit = ts; emit(); } // throttle the reactive flow/band update
raf = requestAnimationFrame(step);
};
const play = () => { if (raf) return; scrubState.on = true; scrubState.playing = true; last = null; draw(); raf = requestAnimationFrame(step); };
const stop = () => { if (raf) cancelAnimationFrame(raf); raf = null; scrubState.playing = false; draw(); emit(); };
let drag = false;
svg.on("pointerdown", e => { drag = true; stop(); svg.node().setPointerCapture(e.pointerId); setPx(d3.pointer(e, svg.node())[0]); });
svg.on("pointermove", e => { if (drag) setPx(d3.pointer(e, svg.node())[0]); });
svg.on("pointerup pointercancel", () => { drag = false; });
btnPlay.onclick = () => { scrubState.playing ? stop() : play(); };
btnAll.onclick = () => { stop(); scrubState.on = false; draw(); emit(); };
wrap.appendChild(svg.node());
wrap.value = { c: scrubState.center, on: scrubState.on };
draw();
invalidation.then(() => { if (raf) cancelAnimationFrame(raf); }); // stop autoplay on rebuild
if (scrubState.playing) play(); // resume across bin/resize rebuilds
return wrap;
}