← Files DataARCHIVED FILE
templates/data-app/base/src/charting/chart-theme.js
20.6 KB · Sep 30, 2026 · 23:19 UTC
import { maximumValue } from "./chart-extrema.js";
import { chromeContrastRatio, parseChromeColor } from "../chrome-contrast.js";
export const chartTypes = [
"line",
"area",
"stackedArea",
"bar",
"horizontalBar",
"stackedBar",
"stackedBar100",
"horizontalStackedBar",
"horizontalStackedBar100",
"histogram",
"scatter",
"heatmap",
"pie",
"leaderboard",
"rankedList",
"sparkline",
"funnel",
"waterfall",
"boxPlot",
"sankey",
];
export const colors = Array.from({ length: 8 }, (_, index) => `var(--chart-${index + 1})`);
const categoricalPaletteOrder = [0, 12, 6, 18, 3, 15, 9, 21, 1, 13, 7, 19, 4, 16, 10, 22, 2, 14, 8, 20, 5, 17, 11, 23];
const categoryColors = Array.from(
{ length: categoricalPaletteOrder.length },
(_, index) => `oklch(from var(--chart-1) l max(c, 0.16) calc(h + ${categoricalPaletteOrder[index] * 15}))`,
);
function reservedConceptColor(value = "") {
const words = String(value)
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ")
.trim()
.toLocaleLowerCase();
if (/^active\s+(?:users?|accounts?)$/u.test(words)) return colors[0];
if (/\b(?:resurrected|reactivated|returning)\b/u.test(words)) return colors[2];
if (/\bretained\b/u.test(words)) return colors[0];
if (/\bnew(?:\s+users?)?\b/u.test(words)) return colors[4];
if (/\b(?:churned|churn)\b/u.test(words)) return "var(--negative)";
if (/^(?:dau|daily active users?)$/u.test(words)) return categoryColors[0];
if (/^(?:wau|weekly active users?)$/u.test(words)) return categoryColors[1];
if (/^(?:mau|monthly active users?)$/u.test(words)) return categoryColors[2];
if (/\bapproved\b/u.test(words)) return "var(--positive)";
if (/\b(?:denied|rejected)\b/u.test(words)) return "var(--negative)";
if (/\bfailed\b/u.test(words)) return "color-mix(in oklch, var(--negative) 72%, var(--chart-7))";
if (/\btimed?\s*out\b/u.test(words)) return "color-mix(in oklch, var(--chart-6) 72%, var(--chart-5))";
if (/\bdraft\b/u.test(words)) return "var(--secondary)";
if (/\bopen\b/u.test(words)) return colors[0];
if (/\bclosed\b/u.test(words)) return colors[2];
return undefined;
}
/** Keep a concept on the same palette token even when its local series order changes. */
export function semanticColor({ field, dimension, value, index = 0, explicitColor } = {}) {
if (typeof explicitColor === "string" && explicitColor.trim()) return explicitColor;
const identity =
value == null || value === "" ? String(field ?? "") : `${String(dimension ?? field ?? "")}:${String(value)}`;
if (!identity) return colors[Math.abs(Number(index) || 0) % colors.length];
let hash = 0;
for (const character of identity.toLocaleLowerCase()) {
hash = (Math.imul(31, hash) + character.codePointAt(0)) | 0;
}
return colors[(hash >>> 0) % colors.length];
}
/** Collect only distinct dimension values and measure fields, never reviewed rows. */
export function createSemanticColorMetadataAccumulator() {
const dimensions = new Map();
const dimensionGroups = [];
const measureGroups = [];
const addQuery = (rows, payloadColumns) => {
const payloadFields = Array.isArray(payloadColumns) ? new Set(payloadColumns) : null;
const queryDimensions = new Map();
const queryMeasures = new Set();
for (const row of rows) {
for (const [field, value] of Object.entries(row)) {
if (typeof value === "number" && Number.isFinite(value)) {
queryMeasures.add(field);
continue;
}
if (
typeof value !== "string" ||
payloadFields?.has(field) ||
!value ||
value.toLowerCase() === "all" ||
/^(?:\d{4}-\d{2}-\d{2}|https?:)/u.test(value)
)
continue;
if (!dimensions.has(field)) dimensions.set(field, new Set());
dimensions.get(field).add(value);
if (!queryDimensions.has(field)) queryDimensions.set(field, new Set());
queryDimensions.get(field).add(value);
}
}
dimensionGroups.push(queryDimensions);
measureGroups.push(queryMeasures);
};
const finish = () => {
const assignments = new Map();
for (const [dimension] of dimensions) {
const containsCountries = [...dimensions.get(dimension)].filter((value) => /^[A-Z]{2}$/u.test(value)).length >= 3;
const palette = semanticCategoryDimension(dimension) || containsCountries ? categoryColors : colors;
for (const group of dimensionGroups) {
const values = [...(group.get(dimension) ?? [])].sort((left, right) => left.localeCompare(right));
const used = new Set(
values.map((value) => palette.indexOf(assignments.get(`${dimension}:${value}`))).filter((slot) => slot >= 0),
);
for (const value of values) {
const identity = `${dimension}:${value}`;
if (assignments.has(identity)) continue;
let slot = colors.indexOf(semanticColor({ dimension, value }));
if (used.size < palette.length) {
while (used.has(slot)) slot = (slot + 1) % palette.length;
used.add(slot);
}
assignments.set(identity, palette[slot]);
}
}
}
const measureAssignments = new Map();
for (const group of measureGroups) {
const fields = [...group].sort((left, right) => left.localeCompare(right));
const used = new Set(
fields.map((field) => categoryColors.indexOf(measureAssignments.get(field))).filter((slot) => slot >= 0),
);
for (const field of fields) {
if (measureAssignments.has(field)) continue;
let slot = colors.indexOf(semanticColor({ field }));
if (used.size < categoryColors.length) {
while (used.has(slot)) slot = (slot + 1) % categoryColors.length;
used.add(slot);
}
measureAssignments.set(field, categoryColors[slot]);
}
}
const categoryFields = new Map();
for (const [dimension, values] of dimensions) {
for (const value of values) {
categoryFields.set(value, categoryFields.has(value) ? null : assignments.get(`${dimension}:${value}`));
}
}
return { version: 1, assignments: [...assignments], measureAssignments: [...measureAssignments],
categoryFields: [...categoryFields].filter(([, color]) => color !== null) };
};
return { addQuery, finish };
}
export function semanticColorMetadata(queries = {}) {
const accumulator = createSemanticColorMetadataAccumulator();
for (const query of Object.values(queries)) accumulator.addQuery(query?.rows ?? [], query?.payloadColumns);
return accumulator.finish();
}
/** Allocate the same reviewed palette before filtering, including deferred queries. */
export function semanticColorResolver(queries = {}, precomputedMetadata = null) {
const metadata = precomputedMetadata ?? semanticColorMetadata(queries);
if (metadata.version !== 1) throw new Error("Unsupported semantic color metadata.");
const assignments = new Map(metadata.assignments);
const measureAssignments = new Map(metadata.measureAssignments);
const categoryFields = new Map(metadata.categoryFields);
const dynamicAssignments = new Map();
const dynamicColor = (dimension, value) => {
const key = `${dimension}:${value}`;
if (assignments.has(key)) return assignments.get(key);
if (dynamicAssignments.has(key)) return dynamicAssignments.get(key);
const palette = semanticCategoryDimension(dimension) ? categoryColors : colors;
const used = new Set(
[...assignments, ...dynamicAssignments]
.filter(([identity]) => identity.startsWith(`${dimension}:`))
.map(([, color]) => palette.indexOf(color))
.filter((index) => index >= 0),
);
let slot = colors.indexOf(semanticColor({ dimension, value }));
while (used.has(slot) && used.size < palette.length) slot = (slot + 1) % palette.length;
const selected = palette[slot];
dynamicAssignments.set(key, selected);
return selected;
};
return (descriptor = {}) => {
if (descriptor.explicitColor) return descriptor.explicitColor;
const reserved = reservedConceptColor(descriptor.value ?? descriptor.field);
if (reserved) return reserved;
if (descriptor.value != null && descriptor.value !== "") {
return dynamicColor(descriptor.dimension ?? descriptor.field ?? "category", descriptor.value);
}
return descriptor.field
? categoryFields.get(descriptor.field) ??
measureAssignments.get(descriptor.field) ??
dynamicColor("category", descriptor.field)
: semanticColor(descriptor);
};
}
export function semanticCategoryDimension(field = "") {
const words = String(field)
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ");
return /\b(?:plans?|products?|segments?|features?|experiences?|cohorts?|channels?|categor(?:y|ies)|dimensions?|buckets?|status(?:es)?|states?|stages?|countr(?:y|ies)|geograph(?:y|ies)|actions?|surfaces?|lifecycles?|drivers?)\b/iu.test(
words,
);
}
export function ratioMetric(field = "", values = []) {
// Relative change can cross zero or exceed 100% without changing its unit.
const relativeChange = /(?:growth|change|delta|variance).*?(?:rate|ratio|percent|percentage)$/iu.test(String(field));
return (
/(?:ratio|rate|conversion|retention|percent|percentage|share|penetration|adoption)$/iu.test(String(field)) &&
values.some((value) => Number.isFinite(value)) &&
values.every((value) => !Number.isFinite(value) || relativeChange || (value >= 0 && value <= 1))
);
}
export function deltaDirection(field, value) {
const words = String(field)
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ");
if (!/\b(?:delta|change|variance|growth|difference|diff|movement)(?:\b|\d)/iu.test(words)) return "";
const normalized =
typeof value === "number"
? value
: Number(
String(value)
.trim()
.replace(/[,$€£%\s]/gu, "")
.replace(/^[−–]/u, "-")
.replace(/[KMBT]$/iu, ""),
);
return Number.isFinite(normalized) && normalized !== 0 ? (normalized < 0 ? "negative" : "positive") : "";
}
export const compact = (value) =>
new Intl.NumberFormat(undefined, {
notation: "compact",
maximumFractionDigits: 1,
}).format(value);
const numericTickValues = (values) =>
[...new Set(values.filter(Number.isFinite).map((value) => (value === 0 ? 0 : value)))].sort(
(left, right) => left - right,
);
/** Keep all observed tick values until the axis domain or formatting mode changes. */
export function mergeNumericAxisTicks(previous, key, values) {
const merged = numericTickValues([...(previous?.key === key ? previous.values : []), ...values]);
const signature = JSON.stringify(merged);
return previous?.key === key && previous.signature === signature ? previous : { key, values: merged, signature };
}
/** Axis-only precision; KPI, mark, and tooltip formatting remains independent. */
export function numericAxisFormatter(tickValues = [], { percent = false, percentDigits, locale, currency } = {}) {
const values = numericTickValues(tickValues);
const style = percent ? { style: "percent" } : /^[A-Z]{3}$/.test(currency ?? "") ? {style:"currency",currency,minimumFractionDigits:0} : {};
const zero = new Intl.NumberFormat(locale, { ...style, maximumFractionDigits: 0 }).format(0);
const formatter = (options) => {
const number = new Intl.NumberFormat(locale, { ...style, signDisplay: "exceptZero", ...options });
return (value) =>
!Number.isFinite(value)
? ""
: value === 0
? zero
: number
.formatToParts(value)
.filter((part) => part.type !== "plusSign")
.map((part) => part.value)
.join("");
};
if (percent && Number.isInteger(percentDigits)) {
return formatter({ minimumFractionDigits: percentDigits, maximumFractionDigits: percentDigits });
}
const distinct = (format) => new Set(values.map(format)).size === values.length;
const readable = (format) => values.every((value) => format(value).length <= 24);
const exact = formatter({ maximumSignificantDigits: 21 });
const scientific = formatter({ notation: "scientific", maximumSignificantDigits: 21 });
// Before Recharts reports its ticks, do not round unknown nearby values together.
if (values.length < 2)
return (value) => {
const label = exact(value);
return label.length <= 24 ? label : scientific(value);
};
const maxAbs = maximumValue(values.map(Math.abs));
const notation = percent || maxAbs < 10_000 ? "standard" : "compact";
const maximum = notation === "compact" ? 3 : 12;
for (let digits = 1; digits <= maximum; digits += 1) {
const format = formatter({ notation, maximumFractionDigits: digits });
if (distinct(format) && readable(format)) return format;
}
// Narrow large-value domains are clearer as grouped numbers than long compact decimals.
if (notation === "compact") {
for (let digits = 0; digits <= 12; digits += 1) {
const format = formatter({ maximumFractionDigits: digits });
if (distinct(format) && readable(format)) return format;
}
}
if (distinct(exact) && readable(exact)) return exact;
for (let digits = 3; digits <= 21; digits += 1) {
const format = formatter({ notation: "scientific", maximumSignificantDigits: digits });
if (distinct(format)) return format;
}
return scientific;
}
export function wholePercentTicks(domain, tickCount = 5) {
if (!Array.isArray(domain) || domain.length !== 2 || !domain.every(Number.isFinite)) return [];
const [minimum, maximum] = [...domain].sort((left, right) => left - right).map((value) => value * 100);
if (minimum === maximum) return [minimum / 100];
const target = Math.max(1, (maximum - minimum) / Math.max(1, tickCount - 1));
const magnitude = 10 ** Math.floor(Math.log10(target));
const normalized = target / magnitude;
const multiplier = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5
: normalized <= 5 ? 5 : 10;
const step = Math.max(1, multiplier * magnitude);
const start = Math.ceil(minimum / step) * step;
const end = Math.floor(maximum / step) * step;
const ticks = [];
for (let value = start; value <= end + step / 100; value += step) ticks.push(value / 100);
return ticks;
}
export function displayValue(value) {
if (value == null || value === "") return "—";
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T|$)/u.test(value)) {
const date = new Date(value.length === 10 ? `${value}T00:00:00Z` : value);
if (!Number.isNaN(date.valueOf()))
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
if (typeof value !== "number" || !Number.isFinite(value)) return String(value);
if (Math.abs(value) >= 10_000) return compact(value);
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 2,
minimumFractionDigits: Number.isInteger(value) ? 0 : Math.abs(value) < 1 ? 2 : 0,
}).format(value);
}
export const percentage = (value) =>
new Intl.NumberFormat(undefined, {
style: "percent",
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(value);
export function periodComparison(
current,
previous,
{ percentagePoints = false, lowerIsBetter = false, previousPeriod = "previous reporting period", includePeriod = true } = {},
) {
if (!Number.isFinite(current) || !Number.isFinite(previous) || (!percentagePoints && previous === 0)) return {};
const change = percentagePoints ? current - previous : (current - previous) / Math.abs(previous);
const formatted = percentagePoints
? `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
signDisplay: "exceptZero",
}).format(change * 100)} pp`
: percentage(change);
return {
comparison: includePeriod ? `${formatted} vs. ${dateValue(previousPeriod) ? shortDate(previousPeriod) : previousPeriod}` : formatted,
negative: lowerIsBetter ? change > 0 : change < 0,
};
}
function dateValue(value) {
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T|$)/u.test(value);
}
export const shortDate = (value) =>
new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
timeZone: "UTC",
}).format(new Date(value));
export const label = (field = "") =>
String(field)
.replace(/([a-z\d])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ")
.replace(/\b(?:arr|mau|wau|dau|api|sql|kpi|cli|ide|mcp|p50|p95|p99)\b/giu, (abbreviation) =>
abbreviation.toUpperCase(),
)
.replace(/^./u, (character) => character.toUpperCase());
export function categoryLabel(field = "", value = "") {
const raw = String(value);
const dimension = String(field)
.replace(/([a-z])([A-Z])/gu, "$1 $2")
.replaceAll("_", " ");
if (/\b(?:country|countries|geography)\b/iu.test(dimension) && /^[A-Z]{2}$/u.test(raw)) {
try {
return new Intl.DisplayNames(undefined, { type: "region" }).of(raw) ?? raw;
} catch {
return raw;
}
}
if (dateValue(raw)) return shortDate(raw);
// Humanize identifiers, but preserve display-ready category casing (iOS, ChatGPT, etc.).
const reviewed = (raw.includes("_") || raw === raw.toLowerCase() ? label(raw) : raw)
.replace(/\bSelf serve\b/iu, "Self-serve")
.replace(/\busage based\b/iu, "usage-based")
.replace(/\bEdu\b/iu, "Education")
.replace(/\bCbp\s+/iu, "");
return /\bskill\b/iu.test(dimension) ? reviewed.replace(/^Data-analytics:/iu, "") : reviewed;
}
export const tick = (value, { includeYear = false } = {}) => {
if (typeof value !== "string") return value;
const month = /^\d{4}-(?:0[1-9]|1[0-2])$/u.test(value);
if (!month && !/^\d{4}-\d{2}-\d{2}(?:T|$)/u.test(value)) return value;
const date = new Date(month ? `${value}-01T00:00:00Z` : value);
if (!Number.isFinite(date.getTime())) return value;
return date.toLocaleDateString("en-US", { month: "short", ...(month ? {} : { day: "numeric" }),
...(month || includeYear ? { year: "numeric" } : {}), timeZone: "UTC" });
};
// Anchor the first stage to the unmodified core hue; tint only later stages.
export function funnelStageColor(color, index, count, active = false) {
if (index === 0) return active ? `color-mix(in srgb, ${color} 92%, var(--text))` : color;
const strength = Math.min(100, Math.round(100 - index / Math.max(1, count - 1) * 76) + (active ? 8 : 0));
return `color-mix(in srgb, ${color} ${strength}%, var(--surface))`;
}
export function percentageAxisMode(fields, rows) {
if (fields.length && fields.every(field => /\(%\)$/.test(field))) return "points";
return fields.length > 0 && fields.every(field => ratioMetric(field, rows.map(row => row[field])));
}
export function formatChartValue(value, field, { ratio = false, compactValue = false, decimals = 1, currency } = {}) {
if (value == null || value === "" || !Number.isFinite(Number(value))) return "—";
const points = /\(%\)$/.test(field);
if (points || ratio) return new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: decimals })
.format(Number(value) / (points ? 100 : 1));
if (/^[A-Z]{3}$/.test(currency ?? "") || /(?:Usd|\(USD\))$/i.test(field)) return new Intl.NumberFormat(undefined, { style: "currency", currency: /^[A-Z]{3}$/.test(currency ?? "") ? currency : "USD",
notation: compactValue ? "compact" : "standard", maximumFractionDigits: compactValue ? 1 : currency ? decimals : 0 }).format(Number(value));
return compactValue ? compact(Number(value)) : displayValue(value);
}
export function comparisonSeriesBase(field, fields) {
const plain = String(field).replace(/^previous\s*/i, "").replace(/\s*[·-]?\s*previous$/i, "");
return fields.find(candidate => String(candidate).toLowerCase() === plain.toLowerCase()) ?? field;
}
export function heatmapTextColor(fill) {
const color = parseChromeColor(fill);
if (!color) return "var(--text)";
return chromeContrastRatio([255,255,255,1], color) > chromeContrastRatio([0,0,0,1], color) ? "white" : "black";
}
export function compactChartLabel(field) {
return label(String(field).replace(/^previous\s*/i, "").replace(/\s*[·-]?\s*previous$/i, ""))
.replace(/\s*\((?:USD|%)\)$/i, "");
}
export function heatmapFill(base, intensity) {
return `color-mix(in srgb, ${base} ${Math.max(12, intensity * 100)}%, var(--surface))`;
}
export function compactAxisCategory(value, capacity) {
if (value.length <= capacity) return value;
const words = value.split(/\s+/u);
if (words.length > 1) {
const prefix = words.slice(0, -1).map((word) => `${word[0]}.`).join(" ") + " ";
const suffix = words.at(-1);
const combined = prefix + suffix;
if (combined.length <= capacity) return combined;
if (capacity - prefix.length >= 3) return `${prefix}${suffix.slice(0, capacity - prefix.length - 1)}…`;
}
return `${value.slice(0, capacity - 1).trimEnd()}…`;
}
SHA-256: 21367c8f24bd4122e85efe4bac43e2e6fa8567a0d098cf8d03158dd183342cb9