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 {