Files
edr-platform/apps/edr-passenger-web/backoffice/src/lib/chart-palette.ts
2026-08-02 23:08:15 +03:00

67 lines
2.3 KiB
TypeScript

/**
* Chart palette.
*
* Categorical slots are assigned in fixed order and never cycled — a series keeps its
* hue when a filter removes its neighbours. Both modes are separately stepped for their
* own surface, not an automatic flip of the light values.
*
* Validated against this app's card surfaces (light `#ffffff`, dark `#0f1729`) with the
* dataviz six-check validator, six slots, adjacent pairlist:
* light — CVD ΔE 9.1, normal-vision ΔE 19.6, contrast WARN on aqua/yellow/magenta
* dark — CVD ΔE 8.4, normal-vision ΔE 19.3, contrast all ≥ 3:1
* The light-mode contrast WARN obliges *relief*: every chart using these slots ships a
* legend with visible text labels and a table view of the same numbers.
*/
export interface ChartPalette {
/** Categorical slots, in fixed assignment order. */
categorical: readonly string[];
/** Single hue for magnitude — one colour for every bar in a one-series chart. */
sequential: string;
/** Recessive chrome. */
grid: string;
axis: string;
/** Text tokens — labels never wear the data colour. */
textMuted: string;
/** Surface, for the 2px gaps and rings that separate marks. */
surface: string;
tooltipBg: string;
tooltipBorder: string;
}
const LIGHT: ChartPalette = {
categorical: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'],
sequential: '#2a78d6',
grid: '#e1e0d9',
axis: '#c3c2b7',
textMuted: '#898781',
surface: '#ffffff',
tooltipBg: '#ffffff',
tooltipBorder: 'rgba(11,11,11,0.10)',
};
const DARK: ChartPalette = {
categorical: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300'],
sequential: '#3987e5',
grid: '#2c2c2a',
axis: '#383835',
textMuted: '#898781',
surface: '#0f1729',
tooltipBg: '#0f1729',
tooltipBorder: 'rgba(255,255,255,0.10)',
};
export function getChartPalette(isDark: boolean): ChartPalette {
return isDark ? DARK : LIGHT;
}
/**
* Colour for a categorical member, keyed by its position in a **stable** ordering of the
* whole domain — never by its rank in the current filtered view, so filtering does not
* repaint the survivors. Past the last slot everything folds into one neutral bucket
* rather than inventing a hue no CVD check would pass.
*/
export function categoricalColor(palette: ChartPalette, index: number): string {
return palette.categorical[index] ?? palette.textMuted;
}