[FEAT] stats: editorial detection timeline, a column per species

This commit is contained in:
Twarner491
2026-06-05 16:34:46 -07:00
parent 1897a5848b
commit ddb7c557e9
2 changed files with 119 additions and 140 deletions
+76 -120
View File
@@ -681,163 +681,116 @@
(ts.by_hour || []).forEach(function (r) { byHour[+r.hour] = +r.detections; }); (ts.by_hour || []).forEach(function (r) { byHour[+r.hour] = +r.detections; });
STATS.byHour = byHour; STATS.byHour = byHour;
speciesTotals = {}; speciesTotals = {};
(ll.species || []).forEach(function (s) { speciesTotals[s.sci] = +s.total; }); (ll.species || []).forEach(function (s) { speciesTotals[s.sci] = +s.n; });
} }
// ---- Chart palette ---- // Editorial detection timeline. One evenly-spaced column per species,
// Monochromatic ink, matching the title text (--ink). Bars positioned // ordered oldest -> newest by last detection (x = time). Each species
// toward the "recent" end of the gradient render in deeper ink; older // owns a cell, so the black squares never overlap and a square fills
// bars fade to a warm light grey. Same hue family throughout. // its column width - neighbours touch at the shared gridline. The
function barColor(t) { // square's height up the column encodes detection count; a small
// t = 0 (outer / newest) -> 1 (inner / oldest). // rotated label (common + scientific name) sits at the column's
// Monochromatic ink palette: same warm hue as the title text // bottom, and each column carries its own timestamp on the x-axis.
// (--ink: #1a1612 ≈ HSL 25, 14%, 9%). Newest hours render in deep function drawHistograms(animate) {
// ink so the outer perimeter reads bold; older hours fade to a
// warm light grey, the chart looks like a hand-pulled engraving.
var hue = 25; // warm-grey hue, matches --ink family
var sat = 12 - t * 8; // 12% -> 4%
var light = 14 + t * 50; // 14% (near-black) -> 64% (light grey)
return 'hsl(' + hue + ', ' + sat.toFixed(0) + '%, ' + light.toFixed(0) + '%)';
}
// Editorial detection timeline. One column per species; the black
// square's height up the column encodes detection count (y axis),
// columns run left->right oldest->newest detection (x axis). A
// rotated species label sits just above each square. Y-axis count
// ticks on the left, X-axis time labels on the bottom. Always fits
// the viewport - column widths flex, square size steps down as the
// species count climbs.
function drawHistograms() {
var tl = document.getElementById('statsTimeline'); var tl = document.getElementById('statsTimeline');
if (!tl) return; if (!tl) return;
var all = ((DATA.recent && DATA.recent.species) || []).slice(); var all = ((DATA.recent && DATA.recent.species) || []).slice();
// X-axis = the FULL selected time window, so quiet stretches show
// as actual empty space. windowStart/now span everything; species
// squares get placed within by their last_seen timestamp.
var now = Date.now();
var isAllWindow = currentHours >= 1000000;
var windowStart;
if (isAllWindow) {
// ALL = since the earliest known first_seen. Fall back to 'now'
// if the firstseen list hasn't loaded yet, which collapses to an
// empty span - the empty-state branch below catches that.
var oldest = now;
var first = (DATA.firstseen && DATA.firstseen.species) || [];
first.forEach(function (s) {
var t = Date.parse((s.first_seen || '').replace(' ', 'T'));
if (!isNaN(t) && t < oldest) oldest = t;
});
((DATA.lifelist && DATA.lifelist.species) || []).forEach(function (s) {
var t = Date.parse((s.first_seen || '').replace(' ', 'T'));
if (!isNaN(t) && t < oldest) oldest = t;
});
windowStart = oldest;
} else {
windowStart = now - currentHours * 3600000;
}
var windowSpan = Math.max(1, now - windowStart);
if (!all.length) { if (!all.length) {
tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>'; tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>';
return; return;
} }
// Cap species count so labels don't pile up. Same rule as before - // Discrete columns. On a phone the columns are fixed-width and wider
// ~28 px per visible mark - but applied to the count of marks, not // (legible squares + labels for touch) and the plot grows past the
// the column layout (which is now time-positioned). // viewport to scroll horizontally - so we show ALL species rather than
var plotW = Math.max(140, (tl.clientWidth || window.innerWidth || 800) - 40); // trimming. On desktop, cap to whatever fits the available width.
var cap = Math.max(4, Math.floor(plotW / 28)); var isMobile = (window.innerWidth || 800) <= 700;
var containerW = Math.max(140, (tl.clientWidth || window.innerWidth || 800) - 34);
var MIN_COL = isMobile ? 52 : 22;
var cap = isMobile ? all.length : Math.max(3, Math.floor(containerW / MIN_COL));
var trimmed = all.length > cap; var trimmed = all.length > cap;
var species = all.slice(); var species = all.slice();
if (trimmed) { if (trimmed) {
species.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); }); species.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
species = species.slice(0, cap); species = species.slice(0, cap);
} }
// X-axis is time: order the chosen columns oldest -> newest.
function parseTs(s) { return s ? Date.parse(s.replace(' ', 'T')) : NaN; }
species.sort(function (a, b) {
var ta = parseTs(a.last_seen), tb = parseTs(b.last_seen);
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return ta - tb;
});
var maxN = species.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1);
var C = species.length; var C = species.length;
var tier = C <= 5 ? 24 : C <= 12 ? 18 : C <= 24 ? 13 : 9; var maxN = species.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1);
var sq = Math.max(7, Math.min(tier, Math.round((plotW / C) * 0.62))); // Mobile: fixed wide columns -> plot can exceed the viewport and scroll.
var LABEL_GAP = 7; // Desktop: columns split the available width evenly.
var SPAN = 0.52; // bottom slice of plot for squares; rest is label headroom. var colW = isMobile ? MIN_COL : (containerW / C);
var plotW = isMobile ? Math.max(containerW, C * colW) : containerW;
// Square fills its column so adjacent squares touch at the shared
// gridline; capped so a few species don't render as giant blocks.
var sq = Math.max(6, Math.min(colW, isMobile ? 60 : 48));
var LABEL_GAP = 6; // px between a square's top and its label
var SPAN = 0.55; // squares occupy the bottom this fraction of
// the plot by count (y = quantity); the
// rotated label floats just above each square.
// Y-axis: 0..maxN with maxN pinned on the top tick. Same as before. // Y-axis quantity ticks: 0..maxN, with maxN pinned on the top tick.
var ticks = []; var ticks = [];
if (maxN <= 8) { if (maxN <= 8) {
for (var v = 0; v <= maxN; v++) ticks.push(v); for (var v = 0; v <= maxN; v++) ticks.push(v);
} else { } else {
var divs = 4; var divs = 4;
for (var i = 0; i <= divs; i++) ticks.push(Math.round(maxN * i / divs)); for (var di = 0; di <= divs; di++) ticks.push(Math.round(maxN * di / divs));
ticks[ticks.length - 1] = maxN; ticks[ticks.length - 1] = maxN;
} }
var yaxis = ticks.map(function (v) { var yaxis = ticks.map(function (v) {
var pct = (v / maxN) * SPAN * 100; return '<span class="stats-tl-ytick" style="bottom:' + ((v / maxN) * SPAN * 100).toFixed(1) + '%">' + v + '</span>';
return '<span class="stats-tl-ytick" style="bottom:' + pct.toFixed(1) + '%">' + v + '</span>';
}).join(''); }).join('');
// Marks - each species placed by its last_seen time on the x-axis. // One timestamp under each column - format follows the window length.
function parseTs(s) { function fmtTs(ms) {
if (!s) return NaN; if (isNaN(ms)) return '';
return Date.parse(s.replace(' ', 'T'));
}
var cols = species.map(function (s) {
var ts = parseTs(s.last_seen);
var leftPct;
if (isNaN(ts)) {
leftPct = 50;
} else {
var clamped = Math.max(windowStart, Math.min(now, ts));
leftPct = ((clamped - windowStart) / windowSpan) * 100;
}
var n = +s.n || 0;
var bottomPct = (n / maxN) * SPAN * 100;
return ''
+ '<div class="stats-tl-col" data-sci="' + s.sci + '" style="left:' + leftPct.toFixed(2) + '%">'
+ '<div class="stats-tl-square" style="bottom:' + bottomPct.toFixed(1) + '%;width:' + sq + 'px;height:' + sq + 'px"></div>'
+ '<div class="stats-tl-label" style="bottom:calc(' + bottomPct.toFixed(1) + '% + ' + (sq + LABEL_GAP) + 'px)">'
+ '<span class="com">' + (s.com || s.sci) + '</span>'
+ '<span class="sci">' + s.sci + '</span>'
+ '</div>'
+ '</div>';
}).join('');
// X-axis ticks + gridlines at regular boundaries that span the
// window - every 15 min for 1H, every 4-6 h for 24H, every day for
// 7D, etc. Both are children of the plot so left:% aligns.
function pickStepMs(span) {
var h = span / 3600000;
if (h <= 1.2) return 15 * 60000;
if (h <= 6) return 60 * 60000;
if (h <= 14) return 2 * 3600000;
if (h <= 36) return 6 * 3600000;
if (h <= 9 * 24) return 24 * 3600000;
if (h <= 75 * 24) return 7 * 24 * 3600000;
return 30 * 24 * 3600000;
}
function fmtTick(ms, span) {
var d = new Date(ms); var d = new Date(ms);
var p2 = function (n) { return n < 10 ? '0' + n : '' + n; }; var p2 = function (n) { return n < 10 ? '0' + n : '' + n; };
if (span <= 36 * 3600000) return p2(d.getHours()) + ':' + p2(d.getMinutes()); if (currentHours <= 36) return p2(d.getHours()) + ':' + p2(d.getMinutes());
if (span <= 75 * 86400000) return (d.getMonth() + 1) + '/' + d.getDate(); if (currentHours <= 75 * 24) return (d.getMonth() + 1) + '/' + d.getDate();
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} }
var stepMs = pickStepMs(windowSpan);
var firstTick = Math.ceil(windowStart / stepMs) * stepMs; // Faint gridlines at every column boundary. Start at gi=1: the gi=0
var xaxis = '', gridlines = ''; // line would sit on top of the y-axis rule (double line), so skip it.
for (var t = firstTick; t <= now; t += stepMs) { var gridlines = '';
var pct = ((t - windowStart) / windowSpan) * 100; for (var gi = 1; gi <= C; gi++) {
xaxis += '<span class="stats-tl-xtick" style="left:' + pct.toFixed(2) + '%">' + fmtTick(t, windowSpan) + '</span>'; gridlines += '<i class="stats-tl-gridline" style="left:' + (gi / C * 100).toFixed(3) + '%"></i>';
gridlines += '<i class="stats-tl-gridline" style="left:' + pct.toFixed(2) + '%"></i>';
} }
var cols = '', xaxis = '';
species.forEach(function (s, i) {
var centerPct = (i + 0.5) / C * 100;
var n = +s.n || 0;
var bottomPct = (n / maxN) * SPAN * 100; // square height = quantity
cols += ''
+ '<div class="stats-tl-col" data-sci="' + s.sci + '" style="left:' + centerPct.toFixed(3) + '%;width:' + colW.toFixed(2) + 'px">'
+ '<div class="stats-tl-square" style="bottom:' + bottomPct.toFixed(1) + '%;width:' + sq.toFixed(1) + 'px;height:' + sq.toFixed(1) + 'px"></div>'
+ '<div class="stats-tl-label" style="bottom:calc(' + bottomPct.toFixed(1) + '% + ' + (sq + LABEL_GAP) + 'px)"><span class="com">' + (s.com || s.sci) + '</span><span class="sci">' + s.sci + '</span></div>'
+ '</div>';
var lab = fmtTs(parseTs(s.last_seen));
if (lab) xaxis += '<span class="stats-tl-xtick" style="left:' + centerPct.toFixed(3) + '%">' + lab + '</span>';
});
var note = trimmed var note = trimmed
? '<div class="stats-tl-cap">' + C + ' most-heard of ' + all.length + '</div>' ? '<div class="stats-tl-cap">' + C + ' most-heard of ' + all.length + '</div>'
: ''; : '';
tl.innerHTML = tl.innerHTML =
'<div class="stats-tl-yaxis">' + yaxis + '</div>' '<div class="stats-tl-yaxis">' + yaxis + '</div>'
+ '<div class="stats-tl-plot">' + gridlines + cols + xaxis + '</div>' + '<div class="stats-tl-plot"' + (isMobile ? ' style="width:' + Math.round(plotW) + 'px"' : '') + '>'
+ gridlines + cols + xaxis
+ '</div>'
+ note; + note;
if (animate) playStatsEntrance();
} }
// Cross-highlight between the timeline squares and the right-side // Cross-highlight between the timeline squares and the right-side
@@ -2794,8 +2747,9 @@
// Any element with data-sci is a "jump to that bird's atlas card" // Any element with data-sci is a "jump to that bird's atlas card"
// affordance: atlas cards themselves, stats list rows (top species / // affordance: atlas cards themselves, stats list rows (top species /
// first detections), and any future surface that wants to point at a // first detections), stats timeline squares, and any future surface
// bird. Action chips inside cards stop propagation themselves. // that wants to point at a bird. Action chips inside cards stop
// propagation themselves.
function jumpToSci(sci) { function jumpToSci(sci) {
if (!sci) return; if (!sci) return;
if (location.hash !== '#sci=' + encodeURIComponent(sci)) { if (location.hash !== '#sci=' + encodeURIComponent(sci)) {
@@ -2814,13 +2768,15 @@
} }
var row = ev.target.closest('li[data-sci]'); var row = ev.target.closest('li[data-sci]');
if (row) return jumpToSci(row.dataset.sci); if (row) return jumpToSci(row.dataset.sci);
var tlCol = ev.target.closest('.stats-tl-col[data-sci]');
if (tlCol) return jumpToSci(tlCol.dataset.sci);
}); });
// After the atlas re-renders (window change, fresh fetch), re-apply // After the atlas re-renders (window change, fresh fetch), re-apply
// any active hash so the highlight survives a rebuild. // any active hash so the highlight survives a rebuild.
var _origRenderAtlas = renderAtlas; var _origRenderAtlas = renderAtlas;
renderAtlas = function () { renderAtlas = function (animate) {
_origRenderAtlas(); _origRenderAtlas(animate);
var s = readHash(); var s = readHash();
if (s) highlightAtlas(s); if (s) highlightAtlas(s);
}; };
+43 -20
View File
@@ -574,40 +574,53 @@
on both short landscape phones and tall portrait ones. */ on both short landscape phones and tall portrait ones. */
.stats-timeline { height: clamp(260px, 44vh, 420px); } .stats-timeline { height: clamp(260px, 44vh, 420px); }
} }
/* Phones: the timeline keeps its editorial form but uses fixed wider
columns (set in drawHistograms) and scrolls horizontally for the rest,
with larger labels/ticks so it's legible + tappable. */
@media (max-width: 700px) {
.stats-timeline { overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; }
.stats-tl-label { font-size: 11px; }
.stats-tl-label .sci { font-size: 9px; }
.stats-tl-xtick { font-size: 8px; }
}
/* Desktop two-column: size the grid to the side panel's content height
(the timeline stretches to match it) and centre it vertically, so the
chart sits beside the text at the same height instead of stretching to
the full viewport. */
@media (min-width: 901px) {
.stats-grid { flex: 0 1 auto; margin: auto; }
}
/* Editorial detection timeline. One column per species. Black /* Editorial detection timeline. One evenly-spaced column per species,
square positioned vertically by count (y = quantity), columns ordered by last-detection time (x = time). Black square fills its
ordered by detection time (x = time). Rotated two-line label column so neighbours touch at the gridline; its height up the column
sits just above its square. Y-axis count ticks on the left, encodes count. Small rotated label + per-column timestamp at the
X-axis time labels on the bottom. Fits the viewport - never scrolls. */ bottom. Fits the viewport - never scrolls. */
.stats-timeline { .stats-timeline {
position: relative; position: relative;
flex: 1 1 auto; min-height: 0; flex: 1 1 auto; min-height: 0;
padding: 0 0 30px 40px; /* bottom = x-axis, left = y-axis */ padding: 0 0 28px 32px; /* left = y-axis (quantity), bottom = timestamps */
} }
.stats-tl-yaxis { .stats-tl-yaxis {
position: absolute; left: 0; top: 0; bottom: 30px; width: 36px; position: absolute; left: 0; top: 0; bottom: 28px; width: 28px;
border-right: 1px solid rgba(26,22,18,0.22); border-right: 1px solid var(--hairline);
} }
.stats-tl-ytick { .stats-tl-ytick {
position: absolute; right: 5px; position: absolute; right: 5px;
font: 9px/1 "SF Mono", Menlo, monospace; color: var(--ink-soft); font: 8px/1 "SF Mono", Menlo, monospace; color: var(--ink-soft);
transform: translateY(50%); transform: translateY(50%);
} }
.stats-tl-ytick::after { .stats-tl-ytick::after {
content: ''; position: absolute; right: -5px; top: 50%; content: ''; position: absolute; right: -5px; top: 50%;
width: 4px; height: 1px; background: rgba(26,22,18,0.3); width: 4px; height: 1px; background: var(--hairline);
} }
.stats-tl-plot { .stats-tl-plot {
position: relative; height: 100%; position: relative; height: 100%;
} }
/* Each species is absolutely positioned along the time axis by its /* Evenly-spaced cell, one per species. JS sets left (column centre)
last_seen; columns aren't equal-width anymore, so gaps in the and width (the column slot); the square and label centre within it. */
timeline show as actual empty space. The width is a label/square
gutter, not a layout slot. */
.stats-tl-col { .stats-tl-col {
position: absolute; top: 0; bottom: 0; position: absolute; top: 0; bottom: 0;
width: 28px;
transform: translateX(-50%); transform: translateX(-50%);
cursor: pointer; cursor: pointer;
} }
@@ -616,13 +629,20 @@
.stats-tl-gridline { .stats-tl-gridline {
position: absolute; top: 0; bottom: 0; position: absolute; top: 0; bottom: 0;
width: 1px; width: 1px;
background: rgba(26,22,18,0.10); background: var(--hairline);
pointer-events: none; pointer-events: none;
} }
.stats-tl-square { .stats-tl-square {
position: absolute; left: 50%; position: absolute; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--ink); background: var(--ink);
/* Paper-coloured side borders. box-sizing: border-box keeps the box
column-filling, so the fill just insets 1px each side. Where two
squares touch, the abutting borders read as a crisp white line
directly between them; on the cluster's outer edges they vanish
into the matching paper background. */
border-left: 1px solid var(--paper);
border-right: 1px solid var(--paper);
transition: background 140ms ease, transform 140ms ease; transition: background 140ms ease, transform 140ms ease;
} }
.stats-tl-col { cursor: pointer; } .stats-tl-col { cursor: pointer; }
@@ -648,9 +668,12 @@
.stats-tl-label { .stats-tl-label {
position: absolute; left: 50%; position: absolute; left: 50%;
transform-origin: 0% 100%; transform-origin: 0% 100%;
/* translateY(50%) becomes a horizontal shift after the -90deg turn
that re-centres the rotated label on the column (= on its square),
so the name reads up the middle of each block. */
transform: rotate(-90deg) translateY(50%); transform: rotate(-90deg) translateY(50%);
white-space: nowrap; white-space: nowrap;
font: 11px/1.2 ui-serif, "Iowan Old Style", Georgia, serif; font: 10px/1.15 ui-serif, "Iowan Old Style", Georgia, serif;
color: var(--ink); color: var(--ink);
transition: transform 130ms ease; transition: transform 130ms ease;
} }
@@ -660,16 +683,16 @@
} }
.stats-tl-label .sci { .stats-tl-label .sci {
display: block; display: block;
font-style: italic; color: var(--ink-soft); font-size: 9.5px; font-style: italic; color: var(--ink-soft); font-size: 8.5px;
transition: color 130ms ease; transition: color 130ms ease;
} }
/* Ticks live inside .stats-tl-plot so their left:% aligns with the /* Ticks live inside .stats-tl-plot so their left:% aligns with the
gridlines (both share the plot's content box). bottom is negative gridlines (both share the plot's content box). bottom is negative
to push the label into the x-axis gutter below the plot. */ to push the label into the x-axis gutter below the plot. */
.stats-tl-xtick { .stats-tl-xtick {
position: absolute; bottom: -22px; position: absolute; bottom: -19px;
transform: translateX(-50%); transform: translateX(-50%);
font: 8.5px/1 "SF Mono", Menlo, monospace; color: var(--ink-soft); font: 7px/1 "SF Mono", Menlo, monospace; color: var(--ink-soft);
white-space: nowrap; white-space: nowrap;
} }
.stats-tl-empty { .stats-tl-empty {