From 99ae3d769b6c6bcf8244ac1ca41f6bf51aa19bcf Mon Sep 17 00:00:00 2001 From: Alessio C Date: Sat, 19 Sep 2026 14:36:12 +0100 Subject: [PATCH 1/5] Redesign the Election Trends chart and restyle the election pane tables The Trends tab drew a marker-heavy chart on an ordinal axis with rotated date labels. It now draws a newsroom-style line chart: a real time axis with upright year labels, thin lines in the existing party colours, a faint horizontal grid, percentages on the y-axis capped at 100, and direct labels at the end of each line carrying the latest share, with shorter forms as the pane narrows. Gaps stay real: the line stops where a party has no comparable result, a faint dotted bridge crosses the missing elections, and a lone result is a dot. Hover or touch shows a crosshair at the nearest election with a tooltip of every charted party's share in descending order; a line, its label or its key entry focuses that party and fades the rest; arrow keys move the crosshair. The chart renders at the container's real width and follows the pane height. The drawing lives in src/election-trend-chart.mjs, whose builders have no DOM dependency; the manager keeps the data path and only supplies points, captions and footnotes. Series ranking puts parties standing at the most recent election first, then others by peak, so the chart reads as a tracker rather than leading with 1918 single points. loadTrendSummary falls back to the static bundle when the elections API is unavailable, as loadBundle already did, so a static server shows the full history. The By Party, By Candidate and By Local Party tables are restyled through a layer scoped to the election pane so they sit with the chart: white surface, hairline row rules, no vertical grid, quiet bold headers, the site font, only the share change coloured, one underlined link per row, a dot for Elected. The shared table rules in assets/css/main.css are untouched, so tables elsewhere on the site are unchanged. The timeline play and stop buttons animate boundary layers and were always disabled while an election was open; they now hide instead. Existing Trends, election, catalogue and Books Playwright specs pass. --- app/src/app.js | 4 + app/src/election-manager.js | 207 ++++------ app/src/test2.css | 625 ++++++++++++++++++++++++---- src/election-trend-chart.mjs | 778 +++++++++++++++++++++++++++++++++++ 4 files changed, 1400 insertions(+), 214 deletions(-) create mode 100644 src/election-trend-chart.mjs diff --git a/app/src/app.js b/app/src/app.js index 649ea9a6ea..dcb056c426 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -2642,6 +2642,10 @@ class Test2App { const play = document.getElementById('timelinePlay'); const stop = document.getElementById('timelineStop'); if (!play || !stop) return; + // The playback buttons animate boundary layers through time. An election has nothing to + // animate, so canAnimateTimeline() always refuses while one is open; rather than sit + // there greyed out, the pair is hidden until the election is closed. + play.parentElement?.classList.toggle('timeline-playback-group--hidden', Boolean(this.elections?.activeEntry)); const canAnimate = this.canAnimateTimeline(); const { playing, paused, atEnd, originalLayerId } = this.timelineAnimation; play.disabled = !canAnimate; diff --git a/app/src/election-manager.js b/app/src/election-manager.js index 43da200cb6..cfa567caf8 100644 --- a/app/src/election-manager.js +++ b/app/src/election-manager.js @@ -18,7 +18,8 @@ import { partyColour as electionPartyColour, seatPositions } from '../../src/election-domain.mjs'; -import { partyLabelHtml } from '../../src/party-names.mjs'; +import { partyAbbreviation, partyLabelHtml } from '../../src/party-names.mjs'; +import { buildTrendChartModel, mountTrendChart } from '../../src/election-trend-chart.mjs'; const ELECTION_MANIFEST_URL = '/render/metadata/elections-test2.json?v=test-023'; @@ -240,6 +241,7 @@ export class Test2ElectionManager { this.trendSummaryCache = new Map(); this.trendSummaryPromiseCache = new Map(); this.trendRenderCache = new Map(); + this.trendChart = null; this.featureIndexCache = new Map(); this.resultsByLayer = new Map(); this.seatCircleClickBound = false; @@ -557,7 +559,12 @@ export class Test2ElectionManager { } else if (this.bundleCache.has(entry.key)) { bundle = this.bundleCache.get(entry.key); } else { - const response = await fetch(electionBundleUrl(entry), { cache: 'force-cache' }); + let response = await fetch(electionBundleUrl(entry), { cache: 'force-cache' }); + // Same fallback as loadBundle: the static bundles are always deployed, so an API + // miss (or a local static server with no API at all) still yields a full history. + if (!response.ok && useElectionsApi()) { + response = await fetch(`${entry.resultUrl}?v=test-023`, { cache: 'force-cache' }); + } if (!response.ok) throw new Error(`Failed to load trend data for ${entry.body} ${entry.date}: ${response.status}`); bundle = await response.json(); } @@ -1011,6 +1018,10 @@ export class Test2ElectionManager { if (selectedResult && nextView === 'animation' && this.resultHasAnimation(selectedResult)) { window.requestAnimationFrame(() => this.runAnimation(selectedResult)); } + if (nextView !== 'trends' && this.trendChart) { + this.trendChart.destroy(); + this.trendChart = null; + } if (nextView === 'trends') { const scopeToggle = pane.querySelector('#test2ElectionTrendsScope'); const renderTrends = () => { @@ -2553,12 +2564,13 @@ export class Test2ElectionManager { renderTrendsPanel(selectedResult = null) { const area = selectedResult?.constituency || selectedResult?.localBody || ''; - const title = area ? `Trend: ${area}` : 'Election trends'; + const title = area ? `Party vote share in ${area}` : 'Party vote share'; const family = electionTrendFamily(this.activeEntry || this.activeBundle || {}); const jurisdiction = electionTrendJurisdiction(this.activeEntry || this.activeBundle || {}); - const scopeText = family - ? `Showing comparable ${family} contests${jurisdiction ? ` in ${jurisdiction}` : ''} by default.` - : `Showing comparable contests${jurisdiction ? ` in ${jurisdiction}` : ''} by default.`; + const familyText = family || 'comparable elections'; + const place = jurisdiction === 'Republic of Ireland' ? 'the Republic of Ireland' : jurisdiction; + const placeText = place && !familyText.toLowerCase().includes(String(jurisdiction).toLowerCase()) ? ` in ${place}` : ''; + const scopeText = `Share of the vote at ${familyText}${placeText}.`; return ` `; @@ -2593,11 +2605,13 @@ export class Test2ElectionManager { selectedKeyLabel ].join('::'); if (this.trendRenderCache.has(renderCacheKey)) { - chart.innerHTML = this.trendRenderCache.get(renderCacheKey); + const cached = this.trendRenderCache.get(renderCacheKey); + this.renderTrendChart(chart, cached.points, selectedResult, includeAllTypes, cached.comparableCount); return; } chart.dataset.trendRequestKey = renderCacheKey; - chart.textContent = 'Loading trend data...'; + chart.setAttribute('aria-busy', 'true'); + chart.innerHTML = ''; const entries = (this.catalogue?.elections || []) .filter((entry) => (entry?.loadable || entry?.resultsOnly) && entry.resultUrl && normalizeName(entry.contestType || 'election') === 'election') .filter((entry) => { @@ -2646,36 +2660,29 @@ export class Test2ElectionManager { } for (const row of rows) points.push(row); } - const markup = this.renderTrendChart(points, selectedResult, includeAllTypes, entries.length); - rememberLimitedCache(this.trendRenderCache, renderCacheKey, markup, ELECTION_TREND_RENDER_CACHE_LIMIT); + rememberLimitedCache(this.trendRenderCache, renderCacheKey, { points, comparableCount: entries.length }, ELECTION_TREND_RENDER_CACHE_LIMIT); if (chart.dataset.trendRequestKey === renderCacheKey) { - chart.innerHTML = markup; + this.renderTrendChart(chart, points, selectedResult, includeAllTypes, entries.length); } } - renderTrendChart(points = [], selectedResult = null, includeAllTypes = false, comparableCount = 0) { + /** + * Mount the trends chart into its container. The drawing itself lives in + * src/election-trend-chart.mjs; this method only decides the caption text and the notes + * that explain the data, then hands over the points. + */ + renderTrendChart(chart, points = [], selectedResult = null, includeAllTypes = false, comparableCount = 0) { + this.trendChart?.destroy(); + this.trendChart = null; + chart.removeAttribute('aria-busy'); if (!points.length) { - return '

No trend data is available for this selection.

'; - } - const byParty = new Map(); - const electionOrder = []; - const electionByKey = new Map(); - for (const point of points) { - const entryKey = point.entry?.key || `${point.entry?.body}|${point.entry?.date}`; - if (!electionByKey.has(entryKey)) { - electionByKey.set(entryKey, point.entry); - electionOrder.push(entryKey); - } - const partyKey = normalizeName(point.party); - if (!byParty.has(partyKey)) byParty.set(partyKey, { party: point.party, colour: point.colour, points: new Map(), maxShare: 0, latestShare: 0 }); - const series = byParty.get(partyKey); - series.points.set(entryKey, point); - series.maxShare = Math.max(series.maxShare, numberOrZero(point.share)); - series.latestShare = numberOrZero(point.share); + chart.innerHTML = '

No trend data is available for this selection.

'; + return; } - const series = [...byParty.values()] - .sort((a, b) => numberOrZero(b.latestShare) - numberOrZero(a.latestShare) || numberOrZero(b.maxShare) - numberOrZero(a.maxShare)) - .slice(0, 8); + const model = buildTrendChartModel(points, { + abbreviate: (party) => partyAbbreviation(party), + bodyLabel: (entry) => shortElectionBody(entry.body || entry.displayProvider || entry.displayTitle || '') + }); // Why a constituency series can be short, said out loud. // // A point is matched to a constituency by NAME, and constituencies are renamed and @@ -2687,60 +2694,37 @@ export class Test2ElectionManager { // registry, and that does not exist yet. Until it does, a chart that explains its own // gap is far better than one that appears broken. const selectionName = selectedResult ? (selectedResult.constituency || selectedResult.featureName || '') : ''; - const shortSeriesNote = (selectionName && comparableCount > electionOrder.length + 1) - ? `' - : ''; - - const width = 860; - const height = 330; - const pad = { left: 46, right: 24, top: 22, bottom: 70 }; - const plotWidth = width - pad.left - pad.right; - const plotHeight = height - pad.top - pad.bottom; - const maxShare = Math.max(40, Math.ceil(Math.max(...series.flatMap((item) => [...item.points.values()].map((point) => numberOrZero(point.share)))) / 10) * 10); - const xFor = (index) => pad.left + (electionOrder.length <= 1 ? plotWidth / 2 : (index / (electionOrder.length - 1)) * plotWidth); - const yFor = (share) => pad.top + plotHeight - (numberOrZero(share) / maxShare) * plotHeight; - const grid = [0, 10, 20, 30, 40, 50, 60].filter((value) => value <= maxShare).map((value) => { - const y = yFor(value); - return `${value}%`; - }).join(''); - const xLabels = electionOrder.map((key, index) => { - if (electionOrder.length > 14 && index % Math.ceil(electionOrder.length / 10) !== 0 && index !== electionOrder.length - 1) return ''; - const entry = electionByKey.get(key); - return `${escapeHtml(shortTrendLabel(entry))}`; - }).join(''); - const seriesMarkup = series.map((item) => { - const orderedPoints = electionOrder - .map((key, index) => ({ key, index, point: item.points.get(key) })) - .filter((row) => row.point); - const linePoints = orderedPoints.map((row) => `${xFor(row.index).toFixed(1)},${yFor(row.point.share).toFixed(1)}`).join(' '); - const markers = orderedPoints.map((row) => trendMarkerSvg( - trendMarkerKind(row.point.entry), - xFor(row.index), - yFor(row.point.share), - safeCssColour(item.colour), - `${item.party}: ${formatFixedPercent(row.point.share)} at ${shortTrendLabel(row.point.entry)}` - )).join(''); - return `${markers}`; - }).join(''); - const legend = series.map((item) => ` - - ${escapeHtml(item.party)} - - `).join(''); + const notes = []; + notes.push(includeAllTypes + ? 'All election types available for this geography. A line is dotted across elections where the party has no comparable result.' + : 'Current election family only; by-elections are excluded. A line is dotted across elections where the party has no comparable result.'); + if (model.totalSeries > model.series.length) { + notes.push(`The ${model.series.length} largest parties at the most recent election are shown; ${model.totalSeries - model.series.length} others are not drawn.`); + } + if (selectionName && comparableCount > model.elections.length + 1) { + notes.push(`Showing ${model.elections.length} of ${comparableCount} comparable elections. ` + + `${selectionName} appears under this name in those elections only; constituencies are ` + + 'renamed and redrawn, and a predecessor under a different name is not yet linked to it.'); + } + const footnotes = ``; const geography = selectedResult?.constituency || selectedResult?.localBody || 'overall results'; - return ` - - - ${grid} - - ${seriesMarkup} - ${xLabels} - - - ${shortSeriesNote} - `; + // Fit the drawing to the pane. The lower pane is short by default and the reader can drag + // it taller; the chart follows, between a floor that keeps the grid legible and a ceiling + // that stops it sprawling. + const paneContent = chart.closest('.election-pane__content'); + const chartHeight = (width) => { + const chrome = 150; // Title, caption, key and footnotes above and below the drawing. + const available = paneContent ? paneContent.clientHeight - chrome : 0; + const ceiling = width < 560 ? 216 : width >= 900 ? 320 : 256; + return Math.max(200, Math.min(ceiling, available || ceiling)); + }; + this.trendChart = mountTrendChart(chart, model, { + ariaLabel: `Party vote share over time for ${geography}`, + formatDate: formatElectionDate, + footnotes, + height: chartHeight, + fitTo: paneContent + }); } formatNumberForPane(value) { @@ -5381,17 +5365,19 @@ function formatMainDelta(value) { return `${number > 0 ? '+' : ''}${number.toLocaleString('en-GB')}`; } +// Share changes carry `election-delta--share` so the pane can colour them alone: a table where +// every count change is also green or red says nothing about which change matters. function formatMainPercentDelta(value) { const number = Number(value); if (!Number.isFinite(number)) return ''; - const className = number > 0 ? 'election-delta election-delta--pos' : number < 0 ? 'election-delta election-delta--neg' : 'election-delta'; + const className = number > 0 ? 'election-delta election-delta--share election-delta--pos' : number < 0 ? 'election-delta election-delta--share election-delta--neg' : 'election-delta election-delta--share'; return `${number > 0 ? '+' : ''}${number.toFixed(2)}%`; } function formatMainSelectedPercentDelta(value) { const number = Number(value); if (!Number.isFinite(number)) return ''; - const className = number > 0 ? 'election-delta election-delta--pos' : number < 0 ? 'election-delta election-delta--neg' : 'election-delta'; + const className = number > 0 ? 'election-delta election-delta--share election-delta--pos' : number < 0 ? 'election-delta election-delta--share election-delta--neg' : 'election-delta election-delta--share'; return `${number > 0 ? '+' : ''}${number.toFixed(2)}`; } @@ -5476,47 +5462,6 @@ function electionTrendJurisdiction(entry = {}) { return null; } -function shortTrendLabel(entry = {}) { - const date = String(entry.date || ''); - const year = date.slice(0, 4) || ''; - const body = shortElectionBody(entry.body || entry.displayProvider || entry.displayTitle || ''); - return [year, body].filter(Boolean).join(' '); -} - -function trendMarkerKind(entry = {}) { - const family = electionTrendFamily(entry); - if (/westminster|uk general/.test(family)) return 'triangle'; - if (/local/.test(family)) return 'square'; - if (/european/.test(family)) return 'diamond'; - if (/devolved/.test(family)) return 'circle'; - if (/irish general/.test(family)) return 'pentagon'; - return 'circle'; -} - -function trendMarkerSvg(kind, x, y, colour, label) { - const safeColour = escapeHtml(safeCssColour(colour)); - const safeLabel = escapeHtml(label || ''); - const cx = Number(x).toFixed(1); - const cy = Number(y).toFixed(1); - if (kind === 'triangle') { - return `${safeLabel}`; - } - if (kind === 'square') { - return `${safeLabel}`; - } - if (kind === 'diamond') { - return `${safeLabel}`; - } - if (kind === 'pentagon') { - const points = [0, 1, 2, 3, 4].map((index) => { - const angle = -Math.PI / 2 + index * 2 * Math.PI / 5; - return `${(Number(x) + Math.cos(angle) * 7).toFixed(1)},${(Number(y) + Math.sin(angle) * 7).toFixed(1)}`; - }).join(' '); - return `${safeLabel}`; - } - return `${safeLabel}`; -} - function inferCountEvents(candidates = [], countNumbers = []) { const events = []; for (const count of countNumbers) { diff --git a/app/src/test2.css b/app/src/test2.css index 91afda42fc..c4e43aeb87 100644 --- a/app/src/test2.css +++ b/app/src/test2.css @@ -121,6 +121,11 @@ body.app-shell .app-main { flex: 0 0 auto; } +/* Hidden while an election is open: there is no boundary sequence to play. */ +.pane--map > #timelineSlider.timeline-slider .timeline-playback-group--hidden { + display: none; +} + .pane--map > #timelineSlider.timeline-slider .timeline-btn--play.is-playing, .pane--map > #timelineSlider.timeline-slider .timeline-btn--play.is-replay { color: #fff; @@ -1297,109 +1302,358 @@ body.test2-election-pane-resizing * { } .test2-election-trends { + --trend-surface: var(--color-surface, #fff); + --trend-text: var(--color-text, #111827); + --trend-muted: var(--color-text-muted, #6b7280); + --trend-grid: rgba(15, 23, 42, .09); + --trend-baseline: rgba(15, 23, 42, .3); + --trend-crosshair: rgba(15, 23, 42, .4); + --trend-hover: rgba(15, 23, 42, .06); display: grid; - gap: 10px; - padding: 10px; - border: 1px solid var(--color-border, #d8e0ea); + gap: 6px; + padding: 10px 12px 6px; border-radius: 6px; - background: var(--color-surface, #fff); + background: var(--trend-surface); + color: var(--trend-text); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif); } .test2-election-trends__header { display: flex; - align-items: start; + align-items: flex-start; justify-content: space-between; - gap: 14px; + gap: 12px 18px; flex-wrap: wrap; } .test2-election-trends__header h3 { margin: 0; - color: var(--color-text, #111827); + color: var(--trend-text); font-size: 15px; - line-height: 1.2; + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.01em; } -.test2-election-trends__header p, -.test2-election-trends__note { - margin: 3px 0 0; - color: var(--color-text-muted, #64748b); - font-size: 12px; +.test2-election-trends__header p { + margin: 2px 0 0; + color: var(--trend-muted); + font-size: 12.5px; + line-height: 1.4; } .test2-election-trends__scope { display: inline-flex; align-items: center; - gap: 7px; - color: var(--color-text, #111827); + gap: 6px; + color: var(--trend-muted); font-size: 12px; - font-weight: 700; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} + +.test2-election-trends__scope input { + margin: 0; + accent-color: var(--color-accent, #2563eb); } .test2-election-trends__chart { + min-width: 0; max-width: 100%; - overflow-x: auto; } +.test2-election-trends__loading, +.test2-election-trends__chart > .election-no-data { + margin: 0; + padding: 18px 0; + color: var(--trend-muted); + font-size: 12.5px; +} + +.trend-chart { + position: relative; + display: grid; + gap: 4px; +} + +/* Compact key. Doubles as the touch-friendly party focus control. */ .test2-election-trends__legend { display: flex; align-items: center; - gap: 10px; flex-wrap: wrap; - margin-bottom: 4px; - color: var(--color-text, #111827); - font-size: 12px; - font-weight: 700; + gap: 0 6px; + margin: 0 0 0 -4px; + font-size: 11.5px; + line-height: 1.25; } .test2-election-trends__legend-item { display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + margin: 0; + padding: 2px 4px; + border: 0; + border-radius: 4px; + background: none; + color: var(--trend-text); + font: inherit; + font-size: 11.5px; + cursor: pointer; + transition: opacity .15s ease, background-color .15s ease; } -.test2-election-trends__legend-item span { - width: 18px; - height: 4px; - border: 1px solid rgba(15, 23, 42, .35); - border-radius: 999px; +.test2-election-trends__legend-item:hover { + background: var(--trend-hover); +} + +.test2-election-trends__legend-item:focus-visible { + outline: 2px solid var(--color-accent, #2563eb); + outline-offset: 1px; +} + +.test2-election-trends__legend-item[aria-pressed="true"] { + font-weight: 700; +} + +.test2-election-trends__legend-swatch { + flex: 0 0 auto; + width: 14px; + height: 3px; + border-radius: 2px; +} + +.trend-chart__stage { + position: relative; + min-width: 0; } .test2-election-trends__svg { + display: block; width: 100%; - min-width: 680px; height: auto; - display: block; + overflow: visible; + font-family: inherit; + touch-action: pan-y; + outline: none; + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +.test2-election-trends__svg:focus-visible { + outline: 2px solid var(--color-accent, #2563eb); + outline-offset: 4px; + border-radius: 2px; } .trend-grid { - stroke: var(--color-border, #d8e0ea); + stroke: var(--trend-grid); stroke-width: 1; + shape-rendering: crispEdges; } -.trend-axis, -.trend-axis-line { - fill: var(--color-text-muted, #64748b); - stroke: var(--color-text-muted, #64748b); - font-size: 11px; +.trend-grid--baseline { + stroke: var(--trend-baseline); } -.trend-axis-line { - stroke-width: 1.2; +.trend-election-tick { + stroke: var(--trend-baseline); + stroke-width: 1; + shape-rendering: crispEdges; +} + +.trend-axis { + fill: var(--trend-muted); + font-size: 11.5px; + font-variant-numeric: tabular-nums; +} + +.trend-series { + transition: opacity .15s ease; } .trend-line { fill: none; stroke: var(--trend-colour, #64748b); - stroke-width: 3.5; + stroke-width: 2.25; stroke-linecap: round; stroke-linejoin: round; + transition: stroke-width .15s ease; +} + +.trend-hit { + fill: none; + stroke: transparent; + stroke-width: 14; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: stroke; + cursor: pointer; +} + +.trend-dot { + fill: var(--trend-colour, #64748b); +} + +.trend-gap { + fill: none; + stroke: var(--trend-colour, #64748b); + stroke-width: 1.25; + stroke-dasharray: 2 4; + stroke-linecap: round; + opacity: .5; } .trend-marker { fill: var(--trend-colour, #64748b); - stroke: #111827; - stroke-width: 2.3; + stroke: var(--trend-surface); + stroke-width: 1.5; + opacity: 0; + pointer-events: none; + transition: opacity .12s ease; +} + +.trend-marker.is-active, +.trend-series.is-focused .trend-marker { + opacity: 1; +} + +.trend-series.is-focused .trend-line { + stroke-width: 3; +} + +.trend-chart[data-trend-focus] .trend-series:not(.is-focused) { + opacity: .16; +} + +.trend-chart[data-trend-focus] .trend-label:not(.is-focused) { + opacity: .28; +} + +.trend-chart[data-trend-focus] .test2-election-trends__legend-item:not(.is-focused) { + opacity: .45; +} + +.trend-label { + cursor: pointer; + transition: opacity .15s ease; +} + +.trend-label text { + fill: var(--trend-colour, #64748b); + font-size: 12px; + font-variant-numeric: tabular-nums; + paint-order: stroke; + stroke: var(--trend-surface); + stroke-width: 3px; + stroke-linejoin: round; +} + +.trend-label__value { + font-weight: 600; +} + +.trend-label-leader { + stroke: var(--trend-colour, #64748b); + stroke-width: 1; + opacity: .55; +} + +.trend-crosshair { + display: none; + stroke: var(--trend-crosshair); + stroke-width: 1; + stroke-dasharray: 3 3; + pointer-events: none; + shape-rendering: crispEdges; +} + +.trend-crosshair.is-active { + display: block; +} + +.trend-tooltip { + position: absolute; + z-index: 5; + min-width: 150px; + max-width: 250px; + padding: 7px 10px 8px; + border: 1px solid var(--color-border, #d8e0ea); + border-radius: 6px; + background: var(--trend-surface); + color: var(--trend-text); + box-shadow: 0 4px 14px rgba(15, 23, 42, .14); + font-size: 12px; + line-height: 1.35; + pointer-events: none; +} + +.trend-tooltip__title { + font-weight: 700; +} + +.trend-tooltip__subtitle { + color: var(--trend-muted); + font-size: 11px; +} + +.trend-tooltip__table { + width: 100%; + margin-top: 4px; + border-collapse: collapse; +} + +.trend-tooltip__table td { + padding: 1px 0; + white-space: nowrap; +} + +.trend-tooltip__table tr.is-focused td { + font-weight: 700; +} + +.trend-tooltip__value { + padding-left: 14px !important; + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.trend-tooltip__swatch { + display: inline-block; + width: 10px; + height: 3px; + margin: 0 6px 3px 0; + border-radius: 2px; + vertical-align: middle; +} + +.test2-election-trends__notes { + display: grid; + gap: 2px; +} + +.test2-election-trends__note { + margin: 0; + color: var(--trend-muted); + font-size: 11px; + line-height: 1.4; +} + +@media (max-width: 640px) { + .test2-election-trends { + padding: 10px 8px 6px; + } + + .test2-election-trends__header h3 { + font-size: 14px; + } + + .trend-tooltip { + max-width: 200px; + } } @media (max-width: 1100px) { @@ -1435,35 +1689,22 @@ body.test2-election-pane-resizing * { [data-theme="dark"] .test2-source-panel__group, [data-theme="dark"] .test2-election-table th, [data-theme="dark"] .test2-election-table__summary, -[data-theme="dark"] .test2-election-trends__legend, [data-theme="dark"] .test2-fptp-vote-graphic__title { background: var(--color-surface-elevated, #1c2128); border-color: var(--color-border, #3d444d); color: var(--color-text, #f7fafc); } -[data-theme="dark"] .test2-election-trends__header h3, -[data-theme="dark"] .test2-election-trends__scope { - color: var(--color-text, #f7fafc); +[data-theme="dark"] .test2-election-trends { + --trend-muted: var(--color-text-muted, #a8b3c5); + --trend-grid: rgba(255, 255, 255, .1); + --trend-baseline: rgba(255, 255, 255, .32); + --trend-crosshair: rgba(255, 255, 255, .45); + --trend-hover: rgba(255, 255, 255, .08); } -[data-theme="dark"] .test2-election-trends__header p, -[data-theme="dark"] .test2-election-trends__note { - color: var(--color-text-muted, #a8b3c5); -} - -[data-theme="dark"] .trend-grid { - stroke: #3d444d; -} - -[data-theme="dark"] .trend-axis, -[data-theme="dark"] .trend-axis-line { - fill: #a8b3c5; - stroke: #a8b3c5; -} - -[data-theme="dark"] .trend-marker { - stroke: #f7fafc; +[data-theme="dark"] .trend-tooltip { + box-shadow: 0 4px 16px rgba(0, 0, 0, .5); } [data-theme="dark"] .test2-election-table td, @@ -1513,35 +1754,22 @@ body.test2-election-pane-resizing * { :root:not([data-theme="light"]) .test2-source-panel__group, :root:not([data-theme="light"]) .test2-election-table th, :root:not([data-theme="light"]) .test2-election-table__summary, - :root:not([data-theme="light"]) .test2-election-trends__legend, :root:not([data-theme="light"]) .test2-fptp-vote-graphic__title { background: var(--color-surface-elevated, #1c2128); border-color: var(--color-border, #3d444d); color: var(--color-text, #f7fafc); } - :root:not([data-theme="light"]) .test2-election-trends__header h3, - :root:not([data-theme="light"]) .test2-election-trends__scope { - color: var(--color-text, #f7fafc); + :root:not([data-theme="light"]) .test2-election-trends { + --trend-muted: var(--color-text-muted, #a8b3c5); + --trend-grid: rgba(255, 255, 255, .1); + --trend-baseline: rgba(255, 255, 255, .32); + --trend-crosshair: rgba(255, 255, 255, .45); + --trend-hover: rgba(255, 255, 255, .08); } - :root:not([data-theme="light"]) .test2-election-trends__header p, - :root:not([data-theme="light"]) .test2-election-trends__note { - color: var(--color-text-muted, #a8b3c5); - } - - :root:not([data-theme="light"]) .trend-grid { - stroke: #3d444d; - } - - :root:not([data-theme="light"]) .trend-axis, - :root:not([data-theme="light"]) .trend-axis-line { - fill: #a8b3c5; - stroke: #a8b3c5; - } - - :root:not([data-theme="light"]) .trend-marker { - stroke: #f7fafc; + :root:not([data-theme="light"]) .trend-tooltip { + box-shadow: 0 4px 16px rgba(0, 0, 0, .5); } :root:not([data-theme="light"]) .test2-election-table td, @@ -1931,3 +2159,234 @@ body.test2-election-pane-resizing * { color: var(--color-text-muted, #a8b3c5); } } + +/* ------------------------------------------------------------------------------------------ + Election pane tables (By Party, By Candidate, By Local Party), restyled to sit with the + Trends chart: white surface, hairline row rules, no vertical grid, quiet bold headers, + plain dark links, softer change colours. Scoped to the map app's election pane so the + shared table rules in assets/css/main.css keep serving the rest of the site unchanged. + ------------------------------------------------------------------------------------------ */ +.election-pane__content { + --table-surface: var(--color-surface, #fff); + --table-text: var(--color-text, #111827); + --table-muted: var(--color-text-muted, #6b7280); + --table-rule: rgba(15, 23, 42, .1); + --table-header-rule: rgba(15, 23, 42, .35); + --table-hover: rgba(15, 23, 42, .045); + --table-pos: #1f7a46; + --table-neg: #b83a2c; + --table-link: var(--color-primary, #1a4b8c); + --table-link-rule: rgba(26, 75, 140, .35); +} + +.election-pane__content .election-party-wrapper, +.election-pane__content .election-count-wrapper { + border: 0; + border-radius: 0; + background: var(--table-surface); +} + +.election-pane__content .election-party-table, +.election-pane__content .election-count-table { + background: var(--table-surface); + color: var(--table-text); + font-family: var(--font-sans, 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); + font-size: 0.76rem; + line-height: 1.3; +} + +.election-pane__content .election-party-table th, +.election-pane__content .election-party-table td, +.election-pane__content .election-count-table th, +.election-pane__content .election-count-table td { + border: 0; + border-bottom: 1px solid var(--table-rule); + padding: 6px 8px; + background: var(--table-surface); + color: var(--table-text); +} + +/* Header rows: no fill, bold labels, one firmer rule under the last header row. */ +.election-pane__content .election-party-table th, +.election-pane__content .election-count-table th, +.election-pane__content .election-party-table th:first-child, +.election-pane__content .election-count-table th:first-child, +.election-pane__content .election-party-table--grouped thead tr:nth-child(1) th, +.election-pane__content .election-count-table--grouped thead tr:nth-child(1) th, +.election-pane__content .election-party-table--grouped thead tr:nth-child(2) th, +.election-pane__content .election-count-table--grouped thead tr:nth-child(2) th, +.election-pane__content .election-party-table--grouped thead tr:nth-child(3) th, +.election-pane__content .election-count-table--grouped thead tr:nth-child(3) th { + background: var(--table-surface); + color: var(--table-text); + font-weight: 700; + border-bottom: 0; + box-shadow: 0 1px 0 var(--table-rule); +} + +.election-pane__content .election-party-table--grouped thead tr:nth-child(1) th:not([rowspan]), +.election-pane__content .election-count-table--grouped thead tr:nth-child(1) th:not([rowspan]), +.election-pane__content .election-count-table--grouped thead tr:nth-child(2) th:not([rowspan]) { + color: var(--table-muted); + font-weight: 600; + font-size: 0.72rem; + letter-spacing: .02em; + text-transform: uppercase; +} + +.election-pane__content .election-party-table thead tr:last-child th, +.election-pane__content .election-count-table thead tr:last-child th, +.election-pane__content .election-party-table thead th[rowspan], +.election-pane__content .election-count-table thead th[rowspan] { + box-shadow: 0 1.5px 0 var(--table-header-rule); +} + +.election-pane__content .election-th-indicator { + color: var(--color-accent, #2563eb); +} + +/* Cell fills and rules. The shared sheet pins frozen columns with their own grey fills and + shadows through many specific selectors (and dark-theme copies of each), so this layer + states the fill once, with !important, for every body cell except the party colour tab. */ +.election-pane__content .election-party-table tbody td:not(.election-colour-col), +.election-pane__content .election-count-table tbody td:not(.election-colour-col) { + background: var(--table-surface) !important; + border: 0 !important; + box-shadow: inset 0 -1px 0 var(--table-rule) !important; + color: var(--table-text); +} + +.election-pane__content .election-party-table thead th, +.election-pane__content .election-count-table thead th { + border: 0 !important; + box-shadow: inset 0 -1px 0 var(--table-rule) !important; +} + +.election-pane__content .election-party-table thead tr:last-child th, +.election-pane__content .election-count-table thead tr:last-child th, +.election-pane__content .election-party-table thead th[rowspan], +.election-pane__content .election-count-table thead th[rowspan] { + box-shadow: inset 0 -1.5px 0 var(--table-header-rule) !important; +} + +/* No zebra striping; a light wash on hover only. */ +.election-pane__content .election-party-table tbody tr:hover td:not(.election-colour-col), +.election-pane__content .election-count-table tbody tr:hover td:not(.election-colour-col) { + background: var(--table-hover) !important; +} + +/* Totals row: bold, upright, closed with a firmer rule above. */ +.election-pane__content .election-table-summary-row td:not(.election-colour-col) { + color: var(--table-text); + font-style: normal; + font-weight: 600; + box-shadow: inset 0 1.5px 0 var(--table-header-rule), inset 0 -1px 0 var(--table-rule) !important; +} + +/* Text reads from the left; numbers stay on the right. */ +.election-pane__content .election-party-table tbody td:has(.election-entity-link), +.election-pane__content .election-count-table tbody td:has(.election-entity-link), +.election-pane__content td.election-party-cell { + text-align: left; +} + +/* One link per row: the row's own name (candidate, party or constituency) in dark text with a + quiet underline. The other linked cells in the row are plain until hovered. */ +.election-pane__content .election-entity-link { + color: var(--table-text); + text-decoration: underline; + text-decoration-color: var(--table-link-rule); + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +.election-pane__content .election-count-table .election-entity-link:not([data-election-entity-kind="candidate"]), +.election-pane__content .election-results-table--district .election-entity-link[data-election-entity-kind="party"] { + text-decoration-color: transparent; +} + +.election-pane__content .election-entity-link:hover, +.election-pane__content .election-entity-link:focus-visible { + color: var(--table-link); + text-decoration-color: currentColor; +} + +.election-pane__content .election-party-table .election-rank-col, +.election-pane__content .election-count-table .election-rank-col { + color: var(--table-muted); + font-variant-numeric: tabular-nums; +} + +.election-pane__content td.election-party-cell::before { + width: 3px; + top: 5px; + bottom: 5px; + border-radius: 2px; +} + +/* Changes: counts and votes in muted grey with their sign; only the share change is coloured. */ +.election-pane__content .election-delta, +.election-pane__content .count-transfer--pos, +.election-pane__content .count-transfer--neg, +.election-pane__content .election-delta--neutral, +.election-pane__content .election-na { + color: var(--table-muted); +} + +.election-pane__content .election-delta--share.election-delta--pos { + color: var(--table-pos); +} + +.election-pane__content .election-delta--share.election-delta--neg { + color: var(--table-neg); +} + +.election-pane__content .election-na em { + font-style: normal; +} + +/* Elected: a filled dot rather than a green tick; the label stays for screen readers. */ +.election-pane__content .election-elected-mark { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + overflow: hidden; + color: transparent; + font-size: 0; + line-height: 0; + vertical-align: middle; +} + +.election-pane__content .election-elected-mark--yes { + background: var(--table-text); +} + +.election-pane__content .election-elected-mark--no { + background: transparent; + box-shadow: inset 0 0 0 1px var(--table-rule); +} + +[data-theme="dark"] .election-pane__content { + --table-link: #8fb4ff; + --table-link-rule: rgba(143, 180, 255, .4); + --table-muted: var(--color-text-muted, #a8b3c5); + --table-rule: rgba(255, 255, 255, .1); + --table-header-rule: rgba(255, 255, 255, .38); + --table-hover: rgba(255, 255, 255, .06); + --table-pos: #4ade80; + --table-neg: #f87171; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .election-pane__content { + --table-link: #8fb4ff; + --table-link-rule: rgba(143, 180, 255, .4); + --table-muted: var(--color-text-muted, #a8b3c5); + --table-rule: rgba(255, 255, 255, .1); + --table-header-rule: rgba(255, 255, 255, .38); + --table-hover: rgba(255, 255, 255, .06); + --table-pos: #4ade80; + --table-neg: #f87171; + } +} diff --git a/src/election-trend-chart.mjs b/src/election-trend-chart.mjs new file mode 100644 index 0000000000..3db516f29e --- /dev/null +++ b/src/election-trend-chart.mjs @@ -0,0 +1,778 @@ +/** + * Election trends chart: party vote share over time, drawn the way a newsroom would. + * + * The election manager owns the DATA -- which elections are comparable, which rows belong to + * the selected constituency, what colour a party is -- and hands this module a flat list of + * points. Everything from there is presentation: a multi-series line chart on a real time axis + * with thin lines, a faint horizontal grid, year labels that are never rotated, direct labels + * at the end of each line carrying the latest share, a crosshair with a tooltip at the nearest + * election, and one-tap party focus that fades everything else. + * + * The pure builders (`buildTrendChartModel`, `layoutTrendChart`, `renderTrendChartSvg`) have no + * DOM dependency so they can be exercised in Node. `mountTrendChart` is the browser half. + * + * Gaps are real. A party with no comparable result at an election gets a break in its line, + * not a segment drawn across the hole; the data model is the manager's and is not merged here. + */ + +const SERIES_LIMIT = 8; +const LINE_WIDTH = 2.25; +const LABEL_FONT_SIZE = 12; +const AXIS_FONT_SIZE = 11.5; +const LABEL_GAP = 14; // Minimum vertical distance between two direct labels. +const MIN_COLUMN_GAP = 7; // Minimum horizontal distance between two election columns. +const MIN_TICK_SPACING = 58; // Minimum horizontal distance between two year labels. +const YEAR_STEPS = [1, 2, 5, 10, 20, 25, 50, 100]; + +/* ------------------------------------------------------------------------------------------ */ +/* Model */ +/* ------------------------------------------------------------------------------------------ */ + +/** + * Turn the manager's flat point list into ordered elections and ranked series. + * + * @param {Array<{party:string, share:number, colour:string, entry:object}>} points + * @param {{ seriesLimit?: number, abbreviate?: (party:string)=>string, bodyLabel?: (entry:object)=>string }} [options] + */ +export function buildTrendChartModel(points = [], options = {}) { + const seriesLimit = Math.max(1, Number(options.seriesLimit) || SERIES_LIMIT); + const abbreviate = typeof options.abbreviate === 'function' ? options.abbreviate : (party) => party; + const bodyLabel = typeof options.bodyLabel === 'function' ? options.bodyLabel : (entry) => String(entry?.body || ''); + + const electionByKey = new Map(); + for (const point of points) { + const entry = point?.entry || {}; + const key = entry.key || `${entry.body}|${entry.date}`; + if (!electionByKey.has(key)) { + electionByKey.set(key, { + key, + entry, + date: String(entry.date || ''), + time: parseDateUtc(entry.date), + year: String(entry.date || '').slice(0, 4), + body: bodyLabel(entry) + }); + } + } + const elections = [...electionByKey.values()] + .sort((a, b) => a.date.localeCompare(b.date) || a.body.localeCompare(b.body)); + const indexByKey = new Map(elections.map((election, index) => [election.key, index])); + + const byParty = new Map(); + for (const point of points) { + const entry = point?.entry || {}; + const electionKey = entry.key || `${entry.body}|${entry.date}`; + const index = indexByKey.get(electionKey); + if (index === undefined) continue; + const partyKey = normalizeName(point.party); + if (!partyKey) continue; + if (!byParty.has(partyKey)) { + byParty.set(partyKey, { + key: partyKey, + party: String(point.party || ''), + abbreviation: abbreviate(point.party) || String(point.party || ''), + colour: point.colour, + values: new Array(elections.length).fill(null), + maxShare: 0, + latestShare: 0, + latestIndex: -1 + }); + } + const series = byParty.get(partyKey); + const share = finiteOrZero(point.share); + series.values[index] = { share, votes: finiteOrZero(point.votes), seats: finiteOrZero(point.seats) }; + series.maxShare = Math.max(series.maxShare, share); + if (index >= series.latestIndex) { + series.latestIndex = index; + series.latestShare = share; + } + } + + // Ranking: parties standing at the most recent election first, by their share there, so + // the chart reads as a tracker of the parties a reader knows; then everything else by its + // peak, so a party that mattered once still earns a line when there is room. + const lastIndex = elections.length - 1; + const currentShare = (item) => (lastIndex >= 0 && item.values[lastIndex] ? item.values[lastIndex].share : -1); + const series = [...byParty.values()] + .sort((a, b) => currentShare(b) - currentShare(a) || b.maxShare - a.maxShare || a.party.localeCompare(b.party)) + .slice(0, seriesLimit); + + // Two parties that abbreviate the same way ("Independent" and "Non party/Independent" are + // both "Ind") keep their full names, otherwise a short label would point at the wrong line. + const abbreviationCounts = new Map(); + for (const item of series) abbreviationCounts.set(item.abbreviation, (abbreviationCounts.get(item.abbreviation) || 0) + 1); + for (const item of series) { + if (abbreviationCounts.get(item.abbreviation) > 1) item.abbreviation = item.party; + } + + return { elections, series, totalSeries: byParty.size }; +} + +/* ------------------------------------------------------------------------------------------ */ +/* Layout */ +/* ------------------------------------------------------------------------------------------ */ + +/** + * Compute every coordinate the renderer needs for a given pixel width. + * + * @param {ReturnType} model + * @param {{ width:number, height?:number, measureText?:(text:string, bold?:boolean)=>number, directLabels?:boolean }} options + */ +export function layoutTrendChart(model, options = {}) { + const width = Math.max(240, Math.round(Number(options.width) || 640)); + const narrow = width < 560; + const height = Math.round(Number(options.height) || (narrow ? 216 : 256)); + const measure = typeof options.measureText === 'function' ? options.measureText : estimateTextWidth; + const { elections, series } = model; + + // Y scale: share of the vote, from zero to a round number at or just above the highest point. + const maxShare = series.reduce((max, item) => item.values.reduce((inner, value) => (value ? Math.max(inner, value.share) : inner), max), 0); + const yStep = maxShare <= 25 ? 5 : maxShare <= 60 ? 10 : 20; + const yMax = Math.min(100, Math.max(yStep * 2, Math.ceil(maxShare / yStep) * yStep)); + const yTicks = []; + for (let value = 0; value <= yMax; value += yStep) yTicks.push(value); + const yLabelWidth = Math.max(...yTicks.map((value) => measure(`${value}%`, false, AXIS_FONT_SIZE))); + + // Direct labels: the fullest form that fits in a modest share of the width, else shorter. + const seriesByKey = new Map(series.map((item) => [item.key, item])); + const labelTextFor = (item, mode) => { + const value = formatShare(item.latestShare); + if (mode === 'full') return { name: item.party, value }; + if (mode === 'abbr') return { name: item.abbreviation, value }; + return { name: '', value }; + }; + const labelWidthFor = (item, mode) => { + const text = labelTextFor(item, mode); + return (text.name ? measure(`${text.name} `, false, LABEL_FONT_SIZE) : 0) + measure(text.value, true, LABEL_FONT_SIZE); + }; + let labelMode = 'none'; + let labelWidth = 0; + if (options.directLabels !== false && !narrow && series.length) { + const modes = width >= 760 ? ['full', 'abbr', 'value'] : ['abbr', 'value']; + for (const mode of modes) { + labelMode = mode; + labelWidth = Math.max(...series.map((item) => labelWidthFor(item, mode))); + if (labelWidth <= width * 0.28) break; + } + } + + const pad = { + top: 12, + right: labelMode === 'none' ? 14 : Math.ceil(labelWidth) + 14, + bottom: 26, + left: Math.ceil(yLabelWidth) + 10 + }; + const plot = { + left: pad.left, + right: width - pad.right, + top: pad.top, + bottom: height - pad.bottom + }; + plot.width = Math.max(40, plot.right - plot.left); + plot.height = Math.max(40, plot.bottom - plot.top); + + const yFor = (share) => plot.bottom - (finiteOrZero(share) / yMax) * plot.height; + + // X scale: real time, then nudged so no two elections share a pixel column. + const times = elections.map((election) => election.time); + const timed = times.length > 0 && times.every((time) => Number.isFinite(time)); + const minTime = timed ? Math.min(...times) : 0; + const maxTime = timed ? Math.max(...times) : 0; + const span = maxTime - minTime; + let columns; + if (elections.length <= 1) { + columns = elections.map(() => plot.left + plot.width / 2); + } else if (timed && span > 0) { + columns = times.map((time) => plot.left + ((time - minTime) / span) * plot.width); + } else { + columns = elections.map((election, index) => plot.left + (index / (elections.length - 1)) * plot.width); + } + for (let index = 1; index < columns.length; index += 1) { + columns[index] = Math.max(columns[index], columns[index - 1] + MIN_COLUMN_GAP); + } + const overshoot = columns.length ? columns[columns.length - 1] - plot.right : 0; + if (overshoot > 0 && columns.length > 1) { + const scale = plot.width / (plot.width + overshoot); + for (let index = 0; index < columns.length; index += 1) { + columns[index] = plot.left + (columns[index] - plot.left) * scale; + } + } + + // Piecewise-linear map from a moment in time to a pixel column, through the election anchors. + const xForTime = (time) => { + if (!timed || !columns.length) return plot.left; + if (columns.length === 1) return columns[0]; + if (time <= times[0]) return columns[0] - ((times[0] - time) / span) * plot.width; + for (let index = 1; index < times.length; index += 1) { + if (time <= times[index]) { + const gap = times[index] - times[index - 1]; + const ratio = gap > 0 ? (time - times[index - 1]) / gap : 1; + return columns[index - 1] + (columns[index] - columns[index - 1]) * ratio; + } + } + return columns[columns.length - 1] + ((time - times[times.length - 1]) / span) * plot.width; + }; + + const xTicks = []; + if (timed && elections.length > 1 && span > 0) { + const minYear = new Date(minTime).getUTCFullYear(); + const maxYear = new Date(maxTime).getUTCFullYear(); + const spanYears = Math.max(1, maxYear - minYear); + const step = YEAR_STEPS.find((candidate) => (plot.width / (spanYears / candidate)) >= MIN_TICK_SPACING) || YEAR_STEPS[YEAR_STEPS.length - 1]; + for (let year = Math.ceil(minYear / step) * step; year <= maxYear; year += step) { + const x = xForTime(Date.UTC(year, 0, 1)); + if (x < plot.left - 0.5 || x > plot.right + 0.5) continue; + xTicks.push({ year, x }); + } + if (!xTicks.length) { + xTicks.push({ year: minYear, x: columns[0] }, { year: maxYear, x: columns[columns.length - 1] }); + } + } else { + elections.forEach((election, index) => { + if (election.year) xTicks.push({ year: Number(election.year), x: columns[index] }); + }); + } + + // Series geometry: segments broken at every missing election, isolated points, label anchors. + const seriesLayout = series.map((item) => { + const segments = []; + let current = null; + const isolated = []; + const markers = []; + item.values.forEach((value, index) => { + if (!value) { + current = null; + return; + } + const x = columns[index]; + const y = yFor(value.share); + markers.push({ index, x, y, share: value.share }); + if (!current) { + current = []; + segments.push(current); + } + current.push({ x, y }); + const previous = item.values[index - 1]; + const next = item.values[index + 1]; + if (!previous && !next) isolated.push({ index, x, y }); + }); + // A faint dotted bridge across each gap keeps the series legible without claiming a value + // for the elections the party has no comparable result at. + const bridges = []; + for (let index = 1; index < segments.length; index += 1) { + const from = segments[index - 1][segments[index - 1].length - 1]; + const to = segments[index][0]; + bridges.push({ from, to }); + } + const last = markers.length ? markers[markers.length - 1] : null; + return { + bridges, + key: item.key, + party: item.party, + abbreviation: item.abbreviation, + colour: item.colour, + latestShare: item.latestShare, + latestIndex: item.latestIndex, + segments, + isolated, + markers, + last + }; + }); + + // Direct labels sit at the end of each line, pushed apart so they never overlap, with a + // short leader whenever one had to move away from its point. + const labels = []; + if (labelMode !== 'none') { + const groups = new Map(); + for (const item of seriesLayout) { + if (!item.last) continue; + const anchorX = Math.round(item.last.x); + if (!groups.has(anchorX)) groups.set(anchorX, []); + const source = seriesByKey.get(item.key); + const text = labelTextFor(source, labelMode); + const textWidth = labelWidthFor(source, labelMode); + groups.get(anchorX).push({ + key: item.key, + colour: item.colour, + name: text.name, + value: text.value, + width: textWidth, + pointX: item.last.x, + pointY: item.last.y, + y: item.last.y + }); + } + for (const group of groups.values()) { + group.sort((a, b) => a.pointY - b.pointY); + const top = plot.top + LABEL_FONT_SIZE / 2; + const bottom = plot.bottom + 4; + for (let index = 0; index < group.length; index += 1) { + const floor = index === 0 ? top : group[index - 1].y + LABEL_GAP; + group[index].y = Math.max(group[index].y, floor); + } + for (let index = group.length - 1; index >= 0; index -= 1) { + const ceiling = index === group.length - 1 ? bottom : group[index + 1].y - LABEL_GAP; + group[index].y = Math.min(group[index].y, ceiling); + } + for (let index = 1; index < group.length; index += 1) { + group[index].y = Math.max(group[index].y, group[index - 1].y + LABEL_GAP); + } + for (const label of group) { + label.x = Math.min(label.pointX + 8, width - label.width - 2); + label.leader = Math.abs(label.y - label.pointY) > 5; + labels.push(label); + } + } + } + + return { + width, + height, + narrow, + pad, + plot, + yMax, + yTicks, + yFor, + columns, + xTicks, + labelMode, + series: seriesLayout, + labels + }; +} + +/* ------------------------------------------------------------------------------------------ */ +/* Markup */ +/* ------------------------------------------------------------------------------------------ */ + +/** + * SVG markup for a laid-out chart. Static: interaction state is applied by the mount code. + */ +export function renderTrendChartSvg(model, layout, options = {}) { + const { elections } = model; + const { width, height, plot } = layout; + const ariaLabel = escapeHtml(options.ariaLabel || 'Party vote share over time'); + + const grid = layout.yTicks.map((value) => { + const y = layout.yFor(value).toFixed(1); + const baseline = value === 0 ? ' trend-grid--baseline' : ''; + return `` + + `${value}%`; + }).join(''); + + const electionTicks = layout.columns.map((x) => ( + `` + )).join(''); + + const xLabels = layout.xTicks.map((tick) => { + const anchor = tick.x < plot.left + 16 ? 'start' : tick.x > plot.right - 16 ? 'end' : 'middle'; + const x = anchor === 'start' ? Math.max(tick.x - 2, 2) : anchor === 'end' ? Math.min(tick.x + 2, width - 2) : tick.x; + return `${tick.year}`; + }).join(''); + + const seriesMarkup = layout.series.map((item) => { + const colour = escapeHtml(item.colour); + const path = item.segments + .map((segment) => segment.map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join('')) + .join(''); + const dots = item.isolated.map((point) => ( + `` + )).join(''); + const gaps = item.bridges.length + ? `` + : ''; + const markers = item.markers.map((marker) => { + const election = elections[marker.index]; + const title = escapeHtml(`${item.party}: ${formatShare(marker.share)} at ${[election.year, election.body].filter(Boolean).join(' ')}`); + return `${title}`; + }).join(''); + return `` + + gaps + + (path ? `` : '') + + dots + + (path ? `` : '') + + markers + + ''; + }).join(''); + + const labels = layout.labels.map((label) => { + const colour = escapeHtml(label.colour); + const leader = label.leader + ? `` + : ''; + const name = label.name ? `${escapeHtml(label.name)} ` : ''; + return `${leader}` + + `${name}${escapeHtml(label.value)}` + + ''; + }).join(''); + + return `` + + `${grid}${electionTicks}` + + `` + + `${seriesMarkup}` + + `${labels}` + + `${xLabels}` + + ''; +} + +/* ------------------------------------------------------------------------------------------ */ +/* Browser mount */ +/* ------------------------------------------------------------------------------------------ */ + +/** + * Render the chart into `container` and wire up the interactions. Returns a controller with + * `destroy()`; mounting again into the same container destroys the previous instance. + * + * @param {HTMLElement} container + * @param {ReturnType} model + * @param {{ ariaLabel?:string, footnotes?:string, formatDate?:(date:string)=>string, height?:number }} [options] + */ +export function mountTrendChart(container, model, options = {}) { + if (!container) return null; + container.__trendChart?.destroy(); + + const doc = container.ownerDocument; + const root = doc.createElement('div'); + root.className = 'trend-chart'; + root.innerHTML = ` + +
+ + ${options.footnotes || ''} + `; + container.replaceChildren(root); + + const stage = root.querySelector('.trend-chart__stage'); + const tooltip = root.querySelector('.trend-tooltip'); + const legend = root.querySelector('.test2-election-trends__legend'); + const measureText = createTextMeasurer(root); + const formatDate = typeof options.formatDate === 'function' ? options.formatDate : (value) => value; + const seriesByKey = new Map(model.series.map((item) => [item.key, item])); + + const state = { + layout: null, + svg: null, + width: 0, + lockedParty: null, + hoverParty: null, + column: -1, + pinned: false, + destroyed: false + }; + + const effectiveFocus = () => state.hoverParty || state.lockedParty; + + const applyFocus = () => { + const focus = effectiveFocus(); + if (focus) root.setAttribute('data-trend-focus', focus); + else root.removeAttribute('data-trend-focus'); + root.querySelectorAll('[data-party]').forEach((node) => { + node.classList.toggle('is-focused', Boolean(focus) && node.getAttribute('data-party') === focus); + }); + legend.querySelectorAll('[data-party]').forEach((button) => { + button.setAttribute('aria-pressed', state.lockedParty === button.getAttribute('data-party') ? 'true' : 'false'); + }); + }; + + const setColumn = (index) => { + const layout = state.layout; + if (!layout || !state.svg) return; + const next = Number.isInteger(index) && index >= 0 && index < layout.columns.length ? index : -1; + state.column = next; + const crosshair = state.svg.querySelector('.trend-crosshair'); + state.svg.querySelectorAll('.trend-marker.is-active').forEach((node) => node.classList.remove('is-active')); + if (next < 0) { + crosshair.classList.remove('is-active'); + tooltip.hidden = true; + root.classList.remove('is-hovering'); + return; + } + const x = layout.columns[next].toFixed(1); + crosshair.setAttribute('x1', x); + crosshair.setAttribute('x2', x); + crosshair.classList.add('is-active'); + state.svg.querySelectorAll(`.trend-marker[data-election-index="${next}"]`).forEach((node) => node.classList.add('is-active')); + root.classList.add('is-hovering'); + renderTooltip(next); + }; + + const renderTooltip = (index) => { + const election = model.elections[index]; + if (!election) { + tooltip.hidden = true; + return; + } + const rows = model.series + .map((item) => ({ item, value: item.values[index] })) + .filter((row) => row.value) + .sort((a, b) => b.value.share - a.value.share); + const focus = effectiveFocus(); + tooltip.innerHTML = ` +
${escapeHtml(formatDate(election.date))}
+ ${election.body ? `
${escapeHtml(election.body)}
` : ''} + + ${rows.map((row) => ` + + + + `).join('')} +
${escapeHtml(row.item.party)}${formatShare(row.value.share)}
+ `; + tooltip.hidden = false; + positionTooltip(index); + }; + + const positionTooltip = (index) => { + const layout = state.layout; + if (!layout) return; + const rootRect = root.getBoundingClientRect(); + const svgRect = state.svg.getBoundingClientRect(); + const scale = svgRect.width / layout.width || 1; + const columnX = (svgRect.left - rootRect.left) + layout.columns[index] * scale; + const tipWidth = tooltip.offsetWidth; + const tipHeight = tooltip.offsetHeight; + let left = columnX + 12; + if (left + tipWidth > rootRect.width - 4) left = columnX - tipWidth - 12; + if (left < 4) left = 4; + let top = (svgRect.top - rootRect.top) + layout.plot.top * scale; + top = Math.max(0, Math.min(top, rootRect.height - tipHeight)); + tooltip.style.left = `${Math.round(left)}px`; + tooltip.style.top = `${Math.round(top)}px`; + }; + + const nearestColumn = (clientX) => { + const layout = state.layout; + if (!layout || !layout.columns.length) return -1; + const rect = state.svg.getBoundingClientRect(); + const scale = rect.width / layout.width || 1; + const x = (clientX - rect.left) / scale; + let best = -1; + let bestDistance = Infinity; + layout.columns.forEach((column, index) => { + const distance = Math.abs(column - x); + if (distance < bestDistance) { + bestDistance = distance; + best = index; + } + }); + return best; + }; + + const partyFromEvent = (event) => { + const target = event.target instanceof Element ? event.target.closest('[data-party]') : null; + return target ? target.getAttribute('data-party') : null; + }; + + const togglePartyLock = (party) => { + if (!party || !seriesByKey.has(party)) return; + state.lockedParty = state.lockedParty === party ? null : party; + state.hoverParty = null; + applyFocus(); + if (state.column >= 0) renderTooltip(state.column); + }; + + const onPointerMove = (event) => { + if (event.pointerType === 'touch') return; + state.hoverParty = partyFromEvent(event); + applyFocus(); + setColumn(nearestColumn(event.clientX)); + }; + + const onPointerLeave = () => { + state.hoverParty = null; + applyFocus(); + if (!state.pinned) setColumn(-1); + }; + + const onPointerDown = (event) => { + if (event.pointerType !== 'touch') return; + state.pinned = true; + setColumn(nearestColumn(event.clientX)); + }; + + const onClick = (event) => { + const party = partyFromEvent(event); + if (party) { + togglePartyLock(party); + } else if (state.lockedParty) { + // A click on empty chart space releases the focused party. + togglePartyLock(state.lockedParty); + } + }; + + // A touch outside the chart releases a pinned crosshair. + const onDocumentPointerDown = (event) => { + if (!state.pinned || event.pointerType !== 'touch') return; + if (event.target instanceof Node && root.contains(event.target)) return; + state.pinned = false; + setColumn(-1); + }; + + const onKeyDown = (event) => { + const count = state.layout?.columns.length || 0; + if (!count) return; + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + event.preventDefault(); + const delta = event.key === 'ArrowRight' ? 1 : -1; + const next = state.column < 0 ? (delta > 0 ? 0 : count - 1) : Math.min(count - 1, Math.max(0, state.column + delta)); + state.pinned = true; + setColumn(next); + } else if (event.key === 'Escape') { + state.pinned = false; + state.lockedParty = null; + applyFocus(); + setColumn(-1); + } + }; + + const onLegendClick = (event) => { + const button = event.target instanceof Element ? event.target.closest('button[data-party]') : null; + if (button) togglePartyLock(button.getAttribute('data-party')); + }; + const onLegendPointerOver = (event) => { + if (event.pointerType === 'touch') return; + const button = event.target instanceof Element ? event.target.closest('button[data-party]') : null; + state.hoverParty = button ? button.getAttribute('data-party') : null; + applyFocus(); + }; + const onLegendPointerOut = () => { + state.hoverParty = null; + applyFocus(); + }; + + const render = () => { + if (state.destroyed) return; + const width = Math.round(stage.clientWidth || container.clientWidth || 640); + const height = typeof options.height === 'function' ? options.height(width) : options.height; + if (width === state.width && height === state.height && state.svg) return; + state.width = width; + state.height = height; + state.layout = layoutTrendChart(model, { width, height, measureText }); + stage.innerHTML = renderTrendChartSvg(model, state.layout, { ariaLabel: options.ariaLabel }); + state.svg = stage.querySelector('svg'); + state.svg.addEventListener('pointermove', onPointerMove); + state.svg.addEventListener('pointerleave', onPointerLeave); + state.svg.addEventListener('pointerdown', onPointerDown); + state.svg.addEventListener('click', onClick); + state.svg.addEventListener('keydown', onKeyDown); + applyFocus(); + const column = state.column; + state.column = -1; + if (column >= 0 && state.pinned) setColumn(column); + else tooltip.hidden = true; + }; + + legend.addEventListener('click', onLegendClick); + legend.addEventListener('pointerover', onLegendPointerOver); + legend.addEventListener('pointerout', onLegendPointerOut); + doc.addEventListener('pointerdown', onDocumentPointerDown, true); + + let observer = null; + let frame = 0; + if (typeof ResizeObserver === 'function') { + observer = new ResizeObserver(() => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + render(); + }); + }); + observer.observe(stage); + if (options.fitTo instanceof Element) observer.observe(options.fitTo); + } + render(); + + const controller = { + render, + focus(party) { + state.lockedParty = party && seriesByKey.has(party) ? party : null; + applyFocus(); + }, + destroy() { + if (state.destroyed) return; + state.destroyed = true; + observer?.disconnect(); + if (frame) cancelAnimationFrame(frame); + legend.removeEventListener('click', onLegendClick); + legend.removeEventListener('pointerover', onLegendPointerOver); + legend.removeEventListener('pointerout', onLegendPointerOut); + doc.removeEventListener('pointerdown', onDocumentPointerDown, true); + if (container.__trendChart === controller) delete container.__trendChart; + } + }; + container.__trendChart = controller; + return controller; +} + +function legendMarkup(model) { + return model.series.map((item) => ( + `' + )).join(''); +} + +function createTextMeasurer(root) { + try { + const canvas = root.ownerDocument.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return estimateTextWidth; + const family = getComputedStyle(root).fontFamily || 'sans-serif'; + return (text, bold = false, size = LABEL_FONT_SIZE) => { + context.font = `${bold ? '600' : '400'} ${size}px ${family}`; + return context.measureText(String(text)).width; + }; + } catch { + return estimateTextWidth; + } +} + +/* ------------------------------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------------------------------ */ + +export function formatShare(value) { + const number = Number(value); + return Number.isFinite(number) ? `${number.toFixed(1)}%` : ''; +} + +function estimateTextWidth(text, bold = false, size = LABEL_FONT_SIZE) { + return String(text).length * size * (bold ? 0.6 : 0.55); +} + +function parseDateUtc(value) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/); + if (!match) return NaN; + return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); +} + +function finiteOrZero(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function normalizeName(value) { + return String(value ?? '') + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/\([^)]*\)/g, ' ') + .replace(/&/g, ' and ') + .replace(/['’`]/g, '') + .replace(/[-_/.,()]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[char])); +} From 8b88590a630a1870d9d751911262952bfe6577db Mon Sep 17 00:00:00 2001 From: Alessio C Date: Sat, 19 Sep 2026 14:36:12 +0100 Subject: [PATCH 2/5] Give the About page the same content width as Home About used the 860px wrapper and capped its prose at about 68 characters while Home's body text fills the 1120px wide wrapper. Both About wrappers are now wrap--wide and the caps on paragraphs, lists and boxes are gone. The About stylesheet is linked with a content-hash ?v= because it was linked bare and browsers kept the old copy. --- assets/pages/css/about.css | 12 ++++++------ pages/about.html | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assets/pages/css/about.css b/assets/pages/css/about.css index 53daf9f7c3..3e498df9d5 100644 --- a/assets/pages/css/about.css +++ b/assets/pages/css/about.css @@ -8,9 +8,10 @@ .hero__grid--single { grid-template-columns: 1fr; } .hero--interior .lede { max-width: 60ch; } -/* Text-led page: give the prose a comfortable measure and a little more air. */ +/* The prose runs the full width of the same wide wrapper the Home page uses, so the two + pages line up; only a little more air between sections. */ main > .wrap > section > p, -main > .wrap > section > ul { max-width: 68ch; } +main > .wrap > section > ul { max-width: none; } main > .wrap > section > h2 { margin-top: 2.6rem; } /* .shot-hero exists on the Home page to pull the lead screenshot UP into the gradient @@ -21,7 +22,7 @@ main > .wrap > section > h2 { margin-top: 2.6rem; } /* Numbered method steps. The review asked for the digitising process as a step by step example rather than narrative prose, so it is an
    and reads as a procedure. */ -.method-steps { counter-reset: step; list-style: none; padding-left: 0; max-width: 68ch; } +.method-steps { counter-reset: step; list-style: none; padding-left: 0; } .method-steps > li { counter-increment: step; position: relative; @@ -51,7 +52,6 @@ main > .wrap > section > h2 { margin-top: 2.6rem; } background: var(--color-gray-50); padding: 1.15rem 1.35rem; margin: 1.5rem 0 2rem; - max-width: 68ch; } .correction-box h3 { margin: 0 0 .5rem; @@ -65,7 +65,7 @@ main > .wrap > section > h2 { margin-top: 2.6rem; } /* Source categories. The review asked for the breadth of sources to be shown rather than asserted, as grouped categories with examples. Generated from the catalogue's provider field, so it stays true: 58 organisations across 75 provider strings. */ -.sources-list { margin: 1.1rem 0 1.4rem; padding: 0; list-style: none; max-width: 72ch; } +.sources-list { margin: 1.1rem 0 1.4rem; padding: 0; list-style: none; } .sources-list > li { padding: .55rem 0 .55rem 1rem; border-left: 2px solid var(--color-border); @@ -83,7 +83,7 @@ main > .wrap > section > h2 { margin-top: 2.6rem; } /* Named projects. Distinct from the broad "ways to contribute" list above it: that list answers "how can I help", this one answers "what specifically needs doing". */ -.project-list { list-style: none; padding: 0; margin: 1rem 0 1.3rem; max-width: 70ch; } +.project-list { list-style: none; padding: 0; margin: 1rem 0 1.3rem; } .project-list > li { border: 1px solid var(--color-border); border-radius: var(--radius-md); diff --git a/pages/about.html b/pages/about.html index 524bdb62d2..d8eb78bad8 100644 --- a/pages/about.html +++ b/pages/about.html @@ -12,7 +12,7 @@ - + @@ -71,10 +71,10 @@
    -

    About Civgraph

    Civgraph brings together the civic record of Ireland, tied to people, places and events.

    +

    About Civgraph

    Civgraph brings together the civic record of Ireland, tied to people, places and events.

    -
    +

    Civgraph aspires to bring together a wide range of information of all kinds: elections and the candidates; censuses and statistics; deprivation measures; opinion polls; geology and terrain; Parliamentary debates; how politicians voted; archives and scanned books; and political and administrative boundaries. Most of this already exists somewhere public, scattered across portals, archives and old reports, in formats that cannot be easily compared, with some of it not online at all before.

    Civgraph includes multiple layers, such as the original sources, kept whole and verbatim, as texts, maps and tables, scanned or digital; the data derived from them in cleaned, labelled and linked formats, encompassing people, places, geographies and statistics; and tools which take that data as an input and turn it into something usable. The tools are part of the project, not an extension of it.

    Some of those tools exist today. The longer aim is that the whole linked record can be queried and analysed by anyone, and that any practically possible tool can be built on it.

    From 1b0f105c5261aa86897e3e3d477a67a15ac5b74a Mon Sep 17 00:00:00 2001 From: Alessio C Date: Sat, 19 Sep 2026 14:36:12 +0100 Subject: [PATCH 3/5] Move the catalogue section links into a pinned segmented bar and fix the Books layout Elections, Maps, Books and Tables were a row of uppercase text links inside the contents box. They are now a segmented bar above it, the active section filled with the site gradient, each with a Lucide icon. The links keep their class and data attributes, so click handling and the Tables tab switch are unchanged. The active tab is remembered on the controller because the flat view is re-rendered when a section expands, which used to wipe aria-current. The bar is pinned under the title-and- search shell, with the shell's measured height published as a custom property, and the scroll-to-target offset now includes the bar. For the pin to hold, the flat view is sized to its content rather than clipped to the pane by flex sizing (the book viewer keeps its bounded height). Inside the box the Elections and Maps headings carry the same icons and each section after the first starts with a rule and a clear gap; the Tables pane gets a heading with the table icon. The Books section no longer reads the emoji from data/database/books.json: category headings and card badges use Lucide icons chosen by category id, all defined in one map in ui-controller.js. Three layout faults are fixed there: the category heading occupied the first grid cell so the first card jumped to the right column; the cover fallback badge was never hidden once a cover loaded, so both drew on top of each other; and cards stacked cover, title, author, date, buttons and a notice vertically. They are now compact rows with author and date on one line and the transcription notice in the Markdown button's tooltip. --- assets/css/main.css | 312 ++++++++++++++++++++++++++++++++++++++++++- maps/index.html | 17 +-- src/ui-controller.js | 122 ++++++++++++++--- 3 files changed, 418 insertions(+), 33 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index d767b23e4b..cee4d43ecf 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -677,6 +677,198 @@ button.catalogue-flat__toc-toplink { cursor: pointer; } +/* Section switcher above the contents box: one segmented bar, the active section filled + with the site gradient. The links inside keep .catalogue-flat__toc-toplink for the click + handling; everything visual comes from the tab class. */ +.catalogue-flat__sections { + position: sticky; + /* Just below the sticky title-and-search shell; the value is measured by the controller. */ + top: calc(var(--catalogue-sticky-shell-height, 110px) - var(--space-5, 20px)); + z-index: 6; + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: 4px; + margin: 8px 6px 2px; + padding: 4px; + background: var(--color-surface-elevated, #f7fafc); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 12px; + box-shadow: 0 2px 8px rgba(15, 23, 42, .06); +} + +.catalogue-flat__sections .catalogue-flat__section-tab { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 36px; + padding: 6px 10px; + border: 0; + border-radius: 9px; + background: var(--color-surface, #fff); + color: var(--color-text, #1a202c); + font-family: inherit; + font-size: 13px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + text-decoration: none; + line-height: 1; + cursor: pointer; + box-shadow: 0 1px 0 rgba(15, 23, 42, .04); + transition: background-color .15s ease, color .15s ease; +} + +.catalogue-flat__sections .catalogue-flat__section-tab:hover { + background: var(--color-gray-100, #edf2f7); + text-decoration: none; +} + +.catalogue-flat__sections .catalogue-flat__section-tab:focus-visible { + outline: 2px solid var(--color-accent, #2563eb); + outline-offset: 2px; +} + +.catalogue-flat__sections .catalogue-flat__section-tab[aria-current="true"] { + background: linear-gradient(135deg, #12a35f 0%, #0b8a6a 55%, #0a6f79 100%); + color: #fff; + box-shadow: 0 1px 2px rgba(10, 111, 121, .35); +} + +.catalogue-flat__section-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; +} + +/* Section and category headings inside the catalogue: icon beside the title. */ +.category-group-title, +.category-section__header { + display: flex; + align-items: center; + gap: 8px; +} + +.category-group-title { + margin: 20px 0 10px; + font-size: 1rem; + font-weight: 800; + color: var(--color-text, #1a202c); +} + +.category-group-title--pane { + margin: 4px 0 12px; +} + +.category-section__header { + margin: 16px 0 8px; +} + +.category-section__title { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--color-text, #1a202c); +} + +.category-section__icon { + display: inline-flex; + align-items: center; + color: var(--color-primary, #1a365d); +} + +.category-group-title .catalogue-flat__section-icon, +.category-section__icon .catalogue-flat__section-icon { + width: 18px; + height: 18px; + color: var(--color-primary, #1a365d); +} + +[data-theme="dark"] .category-group-title .catalogue-flat__section-icon, +[data-theme="dark"] .category-section__icon .catalogue-flat__section-icon { + color: #8fb4ff; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .category-group-title .catalogue-flat__section-icon, + :root:not([data-theme="light"]) .category-section__icon .catalogue-flat__section-icon { + color: #8fb4ff; + } +} + +.catalogue-flat__toc-toplinks--stats-only { + justify-content: flex-end; + margin-bottom: 4px; +} + +.catalogue-flat__toc-toplinks--stats-only:empty, +.catalogue-flat__toc-toplinks--stats-only:has(.catalogue-flat__toc-stats:empty) { + display: none; +} + +@media (max-width: 480px) { + .catalogue-flat__sections .catalogue-flat__section-tab { + gap: 6px; + padding: 6px 6px; + font-size: 12px; + } +} + +[data-theme="dark"] .catalogue-flat__sections { + background: var(--color-surface-elevated, #1c2128); + border-color: var(--color-border, #3d444d); +} + +[data-theme="dark"] .catalogue-flat__toc-heading .catalogue-flat__section-icon { + color: #8fb4ff; +} + +[data-theme="dark"] .catalogue-flat__sections .catalogue-flat__section-tab, +[data-theme="dark"] .catalogue-flat__sections button.catalogue-flat__section-tab { + background: var(--color-surface, #0f1419); + color: var(--color-text, #f7fafc); +} + +[data-theme="dark"] .catalogue-flat__sections .catalogue-flat__section-tab:hover, +[data-theme="dark"] .catalogue-flat__sections button.catalogue-flat__section-tab:hover { + background: #232a33; +} + +[data-theme="dark"] .catalogue-flat__sections .catalogue-flat__section-tab[aria-current="true"], +[data-theme="dark"] .catalogue-flat__sections button.catalogue-flat__section-tab[aria-current="true"] { + background: linear-gradient(135deg, #12a35f 0%, #0b8a6a 55%, #0a6f79 100%); + color: #fff; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .catalogue-flat__sections { + background: var(--color-surface-elevated, #1c2128); + border-color: var(--color-border, #3d444d); + } + + :root:not([data-theme="light"]) .catalogue-flat__toc-heading .catalogue-flat__section-icon { + color: #8fb4ff; + } + + :root:not([data-theme="light"]) .catalogue-flat__sections .catalogue-flat__section-tab, + :root:not([data-theme="light"]) .catalogue-flat__sections button.catalogue-flat__section-tab { + background: var(--color-surface, #0f1419); + color: var(--color-text, #f7fafc); + } + + :root:not([data-theme="light"]) .catalogue-flat__sections .catalogue-flat__section-tab:hover, + :root:not([data-theme="light"]) .catalogue-flat__sections button.catalogue-flat__section-tab:hover { + background: #232a33; + } + + :root:not([data-theme="light"]) .catalogue-flat__sections .catalogue-flat__section-tab[aria-current="true"], + :root:not([data-theme="light"]) .catalogue-flat__sections button.catalogue-flat__section-tab[aria-current="true"] { + background: linear-gradient(135deg, #12a35f 0%, #0b8a6a 55%, #0a6f79 100%); + color: #fff; + } +} + .catalogue-flat__toc-title { margin: 0 0 var(--space-3) 0; font-size: var(--text-base, 1rem); @@ -799,17 +991,36 @@ button.catalogue-flat__toc-toplink { } .catalogue-flat__toc-heading-row td { - padding-top: 12px; - padding-bottom: 4px; + padding-top: 10px; + padding-bottom: 6px; +} + +/* Each section after the first starts with a rule and a clear gap above it. */ +.catalogue-flat__toc-heading-row:not(:first-child) td { + padding-top: 20px; + border-top: 1px solid var(--color-border, #e2e8f0); +} + +.catalogue-flat__toc-heading-row + tr td, +.catalogue-flat__toc-decade-row td { + padding-bottom: 14px; } .catalogue-flat__toc-heading { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 8px; font-size: 0.95rem; font-weight: 800; color: var(--color-text, #1a202c); } +.catalogue-flat__toc-heading .catalogue-flat__section-icon { + width: 17px; + height: 17px; + color: var(--color-primary, #1a365d); +} + .catalogue-flat__toc-subheading-row td { padding-top: 10px; padding-bottom: 4px; @@ -3121,10 +3332,16 @@ button.catalogue-flat__toc-toplink { } .book-card__thumbnail-icon { + display: inline-flex; font-size: 14px; line-height: 1; } +.book-card__thumbnail-icon .catalogue-flat__section-icon { + width: 16px; + height: 16px; +} + .book-card__thumbnail-label { font-size: 9px; line-height: 1.1; @@ -3186,6 +3403,85 @@ button.catalogue-flat__toc-toplink { margin: 0; } +/* ------------------------------------------------------------------------------------------ + Books section layout. The category heading spans the card grid (it used to take the first + grid cell, pushing the first card to the right column); the thumbnail fallback really hides + once an image is present; and each card is a compact row -- cover at the left, title, + author and date and the actions at the right -- instead of a tall stack. + ------------------------------------------------------------------------------------------ */ +.category-section > .category-section__header { + grid-column: 1 / -1; + margin: 6px 0 2px; +} + +.category-section:has(> .book-card) { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.book-card__thumbnail-fallback[hidden] { + display: none !important; +} + +#catalogueFlatView .category-section > .book-card, +.category-section > .book-card { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-start; + gap: 12px; + padding: 12px; + min-height: 0; + border-radius: 10px; +} + +.category-section > .book-card .thumb-zone { + align-self: flex-start; + margin: 0; +} + +.category-section > .book-card .book-card__content { + flex: 1 1 auto; + min-width: 0; + gap: 4px; +} + +.category-section > .book-card .book-card__title { + font-size: 0.9rem; + line-height: 1.3; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +} + +.book-card__meta { + margin: 0; + font-size: var(--text-xs); + line-height: 1.4; + color: var(--color-text-muted); +} + +.book-card__meta .book-card__author { + color: var(--color-primary-light); +} + +.book-card__meta .book-card__author:empty { + display: none; +} + +.book-card__meta .book-card__author:not(:empty) + .book-card__date:not(:empty)::before { + content: '\00b7'; + margin: 0 6px; + color: var(--color-text-muted); +} + +.category-section > .book-card .book-card__actions { + margin-top: 6px; + gap: 6px; +} + #catalogueListView { display: flex; flex-direction: column; @@ -3198,6 +3494,16 @@ button.catalogue-flat__toc-toplink { min-height: 0; } +/* The catalogue list is as tall as its content, not the pane. With flex sizing the view was + clipped to the pane height and its content simply overflowed, which is invisible until + something inside is position: sticky: a sticky child is confined to its parent's box, so + the section bar scrolled away after the first screenful. The book viewer keeps the bounded + height because it scrolls internally. */ +.catalogue-flat-view:not(.catalogue-flat-view--book-viewer) { + flex: 0 0 auto; + min-height: auto; +} + .catalogue-flat-view--book-viewer { display: flex; min-height: 0; diff --git a/maps/index.html b/maps/index.html index d62237d249..506d9e82de 100644 --- a/maps/index.html +++ b/maps/index.html @@ -54,7 +54,7 @@ - + - - - + + + - + @@ -364,6 +364,7 @@

    Maps of Irish administrative geography and
    +

    Tables

    @@ -724,7 +725,7 @@

    Feature Details

    and animation code are loaded lazily by the /app module bundle. --> - +