theme.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Chart theme — the colours and the Chart.js defaults every panel inherits.
  3. *
  4. * The palette is not eyeballed. Both scales below were run through the data-viz
  5. * validator against this dashboard's actual chart surface (#ffffff, the panel
  6. * fill — not the page's paper), and both clear every hard gate:
  7. *
  8. * categorical #a8342a,#2a6f9e,#17916a,#c98500 (light, surface #ffffff, --pairs all)
  9. * lightness band PASS · chroma floor PASS · CVD separation PASS (worst pair
  10. * ΔE 8.7 protan, all 6 pairs) · normal-vision floor PASS (worst 15.1) ·
  11. * contrast PASS (all ≥ 3:1, so no panel depends on the relief rule)
  12. *
  13. * ordinal #d99a90,#c26a5c,#a3423a,#7a201a (light, surface #ffffff, --ordinal)
  14. * monotone lightness PASS · adjacent ΔL PASS · light-end contrast 2.34:1
  15. * PASS · single hue PASS (spread 3°)
  16. *
  17. * If you change a hex, re-run the validator rather than trusting your eye —
  18. * the red/green pair that "looks fine" is the one that collapses under
  19. * deuteranopia. Slot order is the CVD-safety mechanism: assign in sequence,
  20. * never cycle, and fold a ninth series into "Other".
  21. */
  22. /** Panel fill — the surface every contrast number above was measured against. */
  23. export const SURFACE = '#ffffff';
  24. export const INK = '#16150f';
  25. export const SECONDARY = '#56534a';
  26. export const MUTED = '#807d74';
  27. export const GRID = '#e7e5de';
  28. export const AXIS = '#c9c6bc';
  29. /**
  30. * Categorical — identity. Slot 1 is the brand oxblood stepped up into the
  31. * lightness band (#7a201a itself is too dark to sit in a categorical scale).
  32. */
  33. export const CATEGORICAL = ['#a8342a', '#2a6f9e', '#17916a', '#c98500'];
  34. /**
  35. * Neutral, deliberately outside the categorical scale: "Other" is a leftover,
  36. * not a series, and should not read as one.
  37. */
  38. export const NEUTRAL = '#8d8a80';
  39. /**
  40. * Ordinal — order IS the meaning (run length, codebase size). One hue, light to
  41. * dark, so the reader sees the ordering in the colour instead of decoding a legend.
  42. */
  43. export const ORDINAL = ['#d99a90', '#c26a5c', '#a3423a', '#7a201a'];
  44. /** Identity by position, never by rank — a filter must not repaint the survivors. */
  45. export function categorical(index) {
  46. return CATEGORICAL[index] ?? NEUTRAL;
  47. }
  48. /**
  49. * Colours for an ordered set of n marks. Four buckets map onto the ramp exactly;
  50. * a shorter set is spread across it so the light→dark reading survives. Anything
  51. * past the ramp (an unexpected bucket from an old client) goes neutral rather
  52. * than inventing a step that would misstate the order.
  53. */
  54. export function ordinal(n) {
  55. if (n <= 0) return [];
  56. if (n === 1) return [ORDINAL[2]];
  57. const out = [];
  58. for (let i = 0; i < n; i++) {
  59. out.push(i < ORDINAL.length ? ORDINAL[Math.round((i * (ORDINAL.length - 1)) / (n - 1))] : NEUTRAL);
  60. }
  61. return out;
  62. }
  63. /** "Other" keeps the neutral wherever the API folded a tail into it. */
  64. export function paletteFor(labels, scale) {
  65. const hues = scale === 'ordinal' ? ordinal(labels.length) : labels.map((_, i) => categorical(i));
  66. return labels.map((label, i) => (label === 'Other' ? NEUTRAL : hues[i]));
  67. }
  68. // ---------------------------------------------------------------------------
  69. // Formatting
  70. // ---------------------------------------------------------------------------
  71. const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
  72. const PLAIN = new Intl.NumberFormat('en-US');
  73. /** Stat-tile values: 1,284 stays exact; 12,900 becomes 12.9K. */
  74. export function compact(n) {
  75. if (n === null || n === undefined || Number.isNaN(n)) return '—';
  76. return Math.abs(n) >= 10_000 ? COMPACT.format(n) : PLAIN.format(n);
  77. }
  78. export function number(n) {
  79. if (n === null || n === undefined || Number.isNaN(n)) return '—';
  80. return PLAIN.format(n);
  81. }
  82. export function percent(fraction, digits = 1) {
  83. if (fraction === null || fraction === undefined || Number.isNaN(fraction)) return '—';
  84. return `${(fraction * 100).toFixed(digits)}%`;
  85. }
  86. /** "2026-07-04" → "Jul 4". Axis ticks only; tables keep the full date. */
  87. export function shortDay(day) {
  88. const parsed = Date.parse(`${day}T00:00:00Z`);
  89. if (!Number.isFinite(parsed)) return day;
  90. return new Date(parsed).toLocaleDateString('en-US', {
  91. month: 'short',
  92. day: 'numeric',
  93. timeZone: 'UTC',
  94. });
  95. }
  96. // ---------------------------------------------------------------------------
  97. // Chart.js defaults
  98. // ---------------------------------------------------------------------------
  99. /**
  100. * Applied once, before any chart is built. Everything here is the recessive
  101. * half of the design: hairline grid, muted axis text, no animation loud enough
  102. * to notice. Text never wears a series colour — identity comes from the mark
  103. * beside it, which is why the legend uses point-style swatches.
  104. */
  105. export function applyChartDefaults(Chart) {
  106. const { defaults } = Chart;
  107. defaults.font.family =
  108. "'Archivo', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
  109. defaults.font.size = 12;
  110. defaults.color = MUTED;
  111. defaults.borderColor = GRID;
  112. defaults.maintainAspectRatio = false;
  113. defaults.animation.duration = 180;
  114. defaults.plugins.legend.position = 'bottom';
  115. defaults.plugins.legend.align = 'start';
  116. defaults.plugins.legend.labels.usePointStyle = true;
  117. defaults.plugins.legend.labels.pointStyle = 'circle';
  118. defaults.plugins.legend.labels.boxWidth = 8;
  119. defaults.plugins.legend.labels.boxHeight = 8;
  120. defaults.plugins.legend.labels.padding = 14;
  121. defaults.plugins.legend.labels.color = SECONDARY;
  122. defaults.plugins.tooltip.backgroundColor = INK;
  123. defaults.plugins.tooltip.padding = 10;
  124. defaults.plugins.tooltip.cornerRadius = 0;
  125. defaults.plugins.tooltip.displayColors = true;
  126. defaults.plugins.tooltip.usePointStyle = true;
  127. defaults.plugins.tooltip.boxWidth = 8;
  128. defaults.plugins.tooltip.boxHeight = 8;
  129. defaults.elements.line.borderWidth = 2;
  130. defaults.elements.line.borderJoinStyle = 'round';
  131. defaults.elements.line.borderCapStyle = 'round';
  132. defaults.elements.line.tension = 0;
  133. defaults.elements.point.hoverBorderWidth = 2;
  134. defaults.elements.bar.borderRadius = 4;
  135. defaults.elements.arc.borderColor = SURFACE;
  136. // The 2px surface gap between touching fills — white doing the separating,
  137. // rather than a stroke drawn around each mark.
  138. defaults.elements.arc.borderWidth = 2;
  139. }
  140. /**
  141. * `ticks` is merged rather than replaced: spreading an override on top would
  142. * silently drop the tick limit and hand back a y-axis labelled every 10%.
  143. */
  144. const scale = (base, extra) => ({ ...base, ...extra, ticks: { ...base.ticks, ...extra.ticks } });
  145. /** A value axis: hairline grid, clean ticks, always anchored at zero. */
  146. export function valueScale(extra = {}) {
  147. return scale(
  148. {
  149. beginAtZero: true,
  150. border: { color: AXIS },
  151. grid: { color: GRID, drawTicks: false },
  152. ticks: { color: MUTED, padding: 8, maxTicksLimit: 6, precision: 0 },
  153. },
  154. extra,
  155. );
  156. }
  157. /** A category or time axis: no grid at all, so the marks carry the chart. */
  158. export function categoryScale(extra = {}) {
  159. return scale(
  160. {
  161. border: { color: AXIS },
  162. grid: { display: false },
  163. ticks: { color: MUTED, padding: 6, autoSkipPadding: 12, maxRotation: 0 },
  164. },
  165. extra,
  166. );
  167. }
  168. /**
  169. * Crosshair-style reading on anything plotted against days: hovering anywhere in
  170. * a column reports every series at that day, so a 2px line never has to be hit
  171. * dead-centre.
  172. */
  173. export const INDEX_HOVER = { mode: 'index', intersect: false, axis: 'x' };