[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; });
STATS.byHour = byHour;
speciesTotals = {};
(ll.species || []).forEach(function (s) { speciesTotals[s.sci] = +s.total; });
(ll.species || []).forEach(function (s) { speciesTotals[s.sci] = +s.n; });
}
// ---- Chart palette ----
// Monochromatic ink, matching the title text (--ink). Bars positioned
// toward the "recent" end of the gradient render in deeper ink; older
// bars fade to a warm light grey. Same hue family throughout.
function barColor(t) {
// t = 0 (outer / newest) -> 1 (inner / oldest).
// Monochromatic ink palette: same warm hue as the title text
// (--ink: #1a1612 ≈ HSL 25, 14%, 9%). Newest hours render in deep
// 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() {
// Editorial detection timeline. One evenly-spaced column per species,
// ordered oldest -> newest by last detection (x = time). Each species
// owns a cell, so the black squares never overlap and a square fills
// its column width - neighbours touch at the shared gridline. The
// square's height up the column encodes detection count; a small
// rotated label (common + scientific name) sits at the column's
// bottom, and each column carries its own timestamp on the x-axis.
function drawHistograms(animate) {
var tl = document.getElementById('statsTimeline');
if (!tl) return;
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) {
tl.innerHTML = '<div class="stats-tl-empty">no detections in this window</div>';
return;
}
// Cap species count so labels don't pile up. Same rule as before -
// ~28 px per visible mark - but applied to the count of marks, not
// the column layout (which is now time-positioned).
var plotW = Math.max(140, (tl.clientWidth || window.innerWidth || 800) - 40);
var cap = Math.max(4, Math.floor(plotW / 28));
// Discrete columns. On a phone the columns are fixed-width and wider
// (legible squares + labels for touch) and the plot grows past the
// viewport to scroll horizontally - so we show ALL species rather than
// trimming. On desktop, cap to whatever fits the available width.
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 species = all.slice();
if (trimmed) {
species.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); });
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 tier = C <= 5 ? 24 : C <= 12 ? 18 : C <= 24 ? 13 : 9;
var sq = Math.max(7, Math.min(tier, Math.round((plotW / C) * 0.62)));
var LABEL_GAP = 7;
var SPAN = 0.52; // bottom slice of plot for squares; rest is label headroom.
var maxN = species.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1);
// Mobile: fixed wide columns -> plot can exceed the viewport and scroll.
// Desktop: columns split the available width evenly.
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 = [];
if (maxN <= 8) {
for (var v = 0; v <= maxN; v++) ticks.push(v);
} else {
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;
}
var yaxis = ticks.map(function (v) {
var pct = (v / maxN) * SPAN * 100;
return '<span class="stats-tl-ytick" style="bottom:' + pct.toFixed(1) + '%">' + v + '</span>';
return '<span class="stats-tl-ytick" style="bottom:' + ((v / maxN) * SPAN * 100).toFixed(1) + '%">' + v + '</span>';
}).join('');
// Marks - each species placed by its last_seen time on the x-axis.
function parseTs(s) {
if (!s) return NaN;
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) {
// One timestamp under each column - format follows the window length.
function fmtTs(ms) {
if (isNaN(ms)) return '';
var d = new Date(ms);
var p2 = function (n) { return n < 10 ? '0' + n : '' + n; };
if (span <= 36 * 3600000) return p2(d.getHours()) + ':' + p2(d.getMinutes());
if (span <= 75 * 86400000) return (d.getMonth() + 1) + '/' + d.getDate();
if (currentHours <= 36) return p2(d.getHours()) + ':' + p2(d.getMinutes());
if (currentHours <= 75 * 24) return (d.getMonth() + 1) + '/' + d.getDate();
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
var stepMs = pickStepMs(windowSpan);
var firstTick = Math.ceil(windowStart / stepMs) * stepMs;
var xaxis = '', gridlines = '';
for (var t = firstTick; t <= now; t += stepMs) {
var pct = ((t - windowStart) / windowSpan) * 100;
xaxis += '<span class="stats-tl-xtick" style="left:' + pct.toFixed(2) + '%">' + fmtTick(t, windowSpan) + '</span>';
gridlines += '<i class="stats-tl-gridline" style="left:' + pct.toFixed(2) + '%"></i>';
// Faint gridlines at every column boundary. Start at gi=1: the gi=0
// line would sit on top of the y-axis rule (double line), so skip it.
var gridlines = '';
for (var gi = 1; gi <= C; gi++) {
gridlines += '<i class="stats-tl-gridline" style="left:' + (gi / C * 100).toFixed(3) + '%"></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
? '<div class="stats-tl-cap">' + C + ' most-heard of ' + all.length + '</div>'
: '';
tl.innerHTML =
'<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;
if (animate) playStatsEntrance();
}
// 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"
// affordance: atlas cards themselves, stats list rows (top species /
// first detections), and any future surface that wants to point at a
// bird. Action chips inside cards stop propagation themselves.
// first detections), stats timeline squares, and any future surface
// that wants to point at a bird. Action chips inside cards stop
// propagation themselves.
function jumpToSci(sci) {
if (!sci) return;
if (location.hash !== '#sci=' + encodeURIComponent(sci)) {
@@ -2814,13 +2768,15 @@
}
var row = ev.target.closest('li[data-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
// any active hash so the highlight survives a rebuild.
var _origRenderAtlas = renderAtlas;
renderAtlas = function () {
_origRenderAtlas();
renderAtlas = function (animate) {
_origRenderAtlas(animate);
var s = readHash();
if (s) highlightAtlas(s);
};
+43 -20
View File
@@ -574,40 +574,53 @@
on both short landscape phones and tall portrait ones. */
.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
square positioned vertically by count (y = quantity), columns
ordered by detection time (x = time). Rotated two-line label
sits just above its square. Y-axis count ticks on the left,
X-axis time labels on the bottom. Fits the viewport - never scrolls. */
/* Editorial detection timeline. One evenly-spaced column per species,
ordered by last-detection time (x = time). Black square fills its
column so neighbours touch at the gridline; its height up the column
encodes count. Small rotated label + per-column timestamp at the
bottom. Fits the viewport - never scrolls. */
.stats-timeline {
position: relative;
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 {
position: absolute; left: 0; top: 0; bottom: 30px; width: 36px;
border-right: 1px solid rgba(26,22,18,0.22);
position: absolute; left: 0; top: 0; bottom: 28px; width: 28px;
border-right: 1px solid var(--hairline);
}
.stats-tl-ytick {
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%);
}
.stats-tl-ytick::after {
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 {
position: relative; height: 100%;
}
/* Each species is absolutely positioned along the time axis by its
last_seen; columns aren't equal-width anymore, so gaps in the
timeline show as actual empty space. The width is a label/square
gutter, not a layout slot. */
/* Evenly-spaced cell, one per species. JS sets left (column centre)
and width (the column slot); the square and label centre within it. */
.stats-tl-col {
position: absolute; top: 0; bottom: 0;
width: 28px;
transform: translateX(-50%);
cursor: pointer;
}
@@ -616,13 +629,20 @@
.stats-tl-gridline {
position: absolute; top: 0; bottom: 0;
width: 1px;
background: rgba(26,22,18,0.10);
background: var(--hairline);
pointer-events: none;
}
.stats-tl-square {
position: absolute; left: 50%;
transform: translateX(-50%);
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;
}
.stats-tl-col { cursor: pointer; }
@@ -648,9 +668,12 @@
.stats-tl-label {
position: absolute; left: 50%;
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%);
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);
transition: transform 130ms ease;
}
@@ -660,16 +683,16 @@
}
.stats-tl-label .sci {
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;
}
/* Ticks live inside .stats-tl-plot so their left:% aligns with the
gridlines (both share the plot's content box). bottom is negative
to push the label into the x-axis gutter below the plot. */
.stats-tl-xtick {
position: absolute; bottom: -22px;
position: absolute; bottom: -19px;
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;
}
.stats-tl-empty {