| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- /**
- * Chart theme — the colours and the Chart.js defaults every panel inherits.
- *
- * The palette is not eyeballed. Both scales below were run through the data-viz
- * validator against this dashboard's actual chart surface (#ffffff, the panel
- * fill — not the page's paper), and both clear every hard gate:
- *
- * categorical #a8342a,#2a6f9e,#17916a,#c98500 (light, surface #ffffff, --pairs all)
- * lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair
- * ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) ·
- * contrast PASS (all ≥ 3:1, so no panel depends on the relief rule)
- *
- * ordinal #d99a90,#c26a5c,#a3423a,#7a201a (light, surface #ffffff, --ordinal)
- * monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1
- * PASS · single hue PASS (spread 3°)
- *
- * If you change a hex, re-run the validator rather than trusting your eye —
- * the red/green pair that "looks fine" is the one that collapses under
- * deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence,
- * never cycle, and fold a ninth series into "Other".
- */
- /** Panel fill — the surface every contrast number above was measured against. */
- export const SURFACE = '#ffffff';
- export const INK = '#16150f';
- export const SECONDARY = '#56534a';
- export const MUTED = '#807d74';
- export const GRID = '#e7e5de';
- export const AXIS = '#c9c6bc';
- /**
- * Categorical — identity. Slot 1 is the brand oxblood stepped up into the
- * lightness band (#7a201a itself is too dark to sit in a categorical scale).
- */
- export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500'];
- /**
- * Neutral, deliberately outside the categorical scale: "Other" is a leftover,
- * not a series, and should not read as one.
- */
- export const NEUTRAL = '#8d8a80';
- /**
- * Ordinal — order IS the meaning (run length, codebase size). One hue, light to
- * dark, so the reader sees the ordering in the colour instead of decoding a legend.
- */
- export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a'];
- /** Identity by position, never by rank — a filter must not repaint the survivors. */
- export function categorical(index) {
- return CATEGORICAL[index] ?? NEUTRAL;
- }
- /**
- * Colours for an ordered set of n marks. Four buckets map onto the ramp exactly;
- * a shorter set is spread across it so the light→dark reading survives. Anything
- * past the ramp (an unexpected bucket from an old client) goes neutral rather
- * than inventing a step that would misstate the order.
- */
- export function ordinal(n) {
- if (n <= 0) return [];
- if (n === 1) return [ORDINAL[2]];
- const out = [];
- for (let i = 0; i < n; i++) {
- out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL);
- }
- return out;
- }
- /** "Other" keeps the neutral wherever the API folded a tail into it. */
- export function paletteFor(labels, scale) {
- const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i));
- return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i]));
- }
- // ---------------------------------------------------------------------------
- // Formatting
- // ---------------------------------------------------------------------------
- const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
- const PLAIN = new Intl.NumberFormat('en-US');
- /** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */
- export function compact(n) {
- if (n === null || n === undefined || Number.isNaN(n)) return '—';
- return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n);
- }
- export function number(n) {
- if (n === null || n === undefined || Number.isNaN(n)) return '—';
- return PLAIN.format(n);
- }
- export function percent(fraction, digits = 1) {
- if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—';
- return `${(fraction * 100).toFixed(digits)}%`;
- }
- /** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */
- export function shortDay(day) {
- const parsed = Date.parse(`${day}T00:00:00Z`);
- if (!Number.isFinite(parsed)) return day;
- return new Date(parsed).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- timeZone: 'UTC',
- });
- }
- // ---------------------------------------------------------------------------
- // Chart.js defaults
- // ---------------------------------------------------------------------------
- /**
- * Applied once, before any chart is built. Everything here is the recessive
- * half of the design: hairline grid, muted axis text, no animation loud enough
- * to notice. Text never wears a series colour — identity comes from the mark
- * beside it, which is why the legend uses point-style swatches.
- */
- export function applyChartDefaults(Chart) {
- const { defaults } = Chart;
- defaults.font.family =
- "'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
- defaults.font.size = 12;
- defaults.color = MUTED;
- defaults.borderColor = GRID;
- defaults.maintainAspectRatio = false;
- defaults.animation.duration = 180;
- defaults.plugins.legend.position = 'bottom';
- defaults.plugins.legend.align = 'start';
- defaults.plugins.legend.labels.usePointStyle = true;
- defaults.plugins.legend.labels.pointStyle = 'circle';
- defaults.plugins.legend.labels.boxWidth = 8;
- defaults.plugins.legend.labels.boxHeight = 8;
- defaults.plugins.legend.labels.padding = 14;
- defaults.plugins.legend.labels.color = SECONDARY;
- defaults.plugins.tooltip.backgroundColor = INK;
- defaults.plugins.tooltip.padding = 10;
- defaults.plugins.tooltip.cornerRadius = 0;
- defaults.plugins.tooltip.displayColors = true;
- defaults.plugins.tooltip.usePointStyle = true;
- defaults.plugins.tooltip.boxWidth = 8;
- defaults.plugins.tooltip.boxHeight = 8;
- defaults.elements.line.borderWidth = 2;
- defaults.elements.line.borderJoinStyle = 'round';
- defaults.elements.line.borderCapStyle = 'round';
- defaults.elements.line.tension = 0;
- defaults.elements.point.hoverBorderWidth = 2;
- defaults.elements.bar.borderRadius = 4;
- defaults.elements.arc.borderColor = SURFACE;
- // The 2px surface gap between touching fills — white doing the separating,
- // rather than a stroke drawn around each mark.
- defaults.elements.arc.borderWidth = 2;
- }
- /**
- * `ticks` is merged rather than replaced: spreading an override on top would
- * silently drop the tick limit and hand back a y-axis labelled every 10%.
- */
- const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } });
- /** A value axis: hairline grid, clean ticks, always anchored at zero. */
- export function valueScale(extra = {}) {
- return scale(
- {
- beginAtZero: true,
- border: { color: AXIS },
- grid: { color: GRID, drawTicks: false },
- ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 },
- },
- extra,
- );
- }
- /** A category or time axis: no grid at all, so the marks carry the chart. */
- export function categoryScale(extra = {}) {
- return scale(
- {
- border: { color: AXIS },
- grid: { display: false },
- ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 },
- },
- extra,
- );
- }
- /**
- * Crosshair-style reading on anything plotted against days: hovering anywhere in
- * a column reports every series at that day, so a 2px line never has to be hit
- * dead-centre.
- */
- export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' };
|