From ddb7c557e9591fd5810a86876cee454574f4c1bf Mon Sep 17 00:00:00 2001 From: Twarner491 Date: Fri, 5 Jun 2026 16:34:46 -0700 Subject: [PATCH] [FEAT] stats: editorial detection timeline, a column per species --- avian/frontend/apt.js | 196 +++++++++++++++----------------------- avian/frontend/styles.css | 63 ++++++++---- 2 files changed, 119 insertions(+), 140 deletions(-) diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js index b874bbc..55642ae 100644 --- a/avian/frontend/apt.js +++ b/avian/frontend/apt.js @@ -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 = '
no detections in this window
'; 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 '' + v + ''; + return '' + v + ''; }).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 '' - + '
' - + '
' - + '
' - + '' + (s.com || s.sci) + '' - + '' + s.sci + '' - + '
' - + '
'; - }).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 += '' + fmtTick(t, windowSpan) + ''; - gridlines += ''; + + // 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 += ''; } + 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 += '' + + '
' + + '
' + + '
' + (s.com || s.sci) + '' + s.sci + '
' + + '
'; + var lab = fmtTs(parseTs(s.last_seen)); + if (lab) xaxis += '' + lab + ''; + }); + var note = trimmed ? '
' + C + ' most-heard of ' + all.length + '
' : ''; tl.innerHTML = '
' + yaxis + '
' - + '
' + gridlines + cols + xaxis + '
' + + '
' + + gridlines + cols + xaxis + + '
' + 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); }; diff --git a/avian/frontend/styles.css b/avian/frontend/styles.css index 8db083d..7f435c0 100644 --- a/avian/frontend/styles.css +++ b/avian/frontend/styles.css @@ -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 {