import { useCallback, useMemo, useSyncExternalStore } from 'react'; import type { ColorToken } from './color-token'; /** * Resolves CSS custom properties to literal colors for canvas and imperative APIs. * * @description * Lives in `shared` rather than beside the map because it is not about maps: any * surface that draws outside CSS — a canvas graph, a Google Maps overlay, an * exported image — needs a literal color and needs it to change when the theme * does. The map family re-exports these under their original `useMapTokenColor` * names, so its public API is unchanged. */ /** * Last-resort color when a token is undefined and no fallback was supplied. * A neutral gray reads as "unstyled" rather than impersonating a semantic state. */ export const TOKEN_COLOR_FALLBACK = '#71717A'; /* -------------------------------------------------------------------------- */ /* Theme-change store */ /* -------------------------------------------------------------------------- */ /** * A single module-level subscription serves every hook call on the page, so a * map with two hundred markers still installs exactly one MutationObserver. * * The snapshot is a version counter rather than the resolved colors themselves: * `useSyncExternalStore` requires a referentially stable snapshot, and a freshly * built object would fail that on every render. */ let version = 0; const listeners = new Set<() => void>(); /** Resolved-color memo, discarded wholesale whenever the theme moves. */ let cache = new Map(); let observer: MutationObserver | null = null; let colorSchemeQuery: MediaQueryList | null = null; function notify() { version += 1; cache = new Map(); listeners.forEach(listener => listener()); } function startWatching() { if (typeof window === 'undefined' || typeof document === 'undefined') return; const root = document.documentElement; // `class` catches the light/dark switch (ThemeContext toggles a class on // ), `style` catches brand-token changes (BrandColorsContext writes // custom properties with root.style.setProperty), and `data-theme` covers // consumers that drive theming through that attribute instead. observer = new MutationObserver(notify); observer.observe(root, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'], }); if (typeof window.matchMedia === 'function') { colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); colorSchemeQuery.addEventListener?.('change', notify); } } function stopWatching() { observer?.disconnect(); observer = null; colorSchemeQuery?.removeEventListener?.('change', notify); colorSchemeQuery = null; } function subscribe(listener: () => void) { if (listeners.size === 0) startWatching(); listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) stopWatching(); }; } function getSnapshot() { return version; } /** Server render has no computed styles; the version never advances there. */ function getServerSnapshot() { return 0; } /* -------------------------------------------------------------------------- */ /* Color normalization */ /* -------------------------------------------------------------------------- */ function toHexPair(value: number) { return Math.max(0, Math.min(255, Math.round(value))) .toString(16) .padStart(2, '0'); } /** * Normalizes a resolved CSS color into a form the Google Maps API accepts. * * Tokens in this library resolve to either `rgba(...)` (`styles/xertica/tokens.css`) * or `#hex` (`contexts/theme-data.ts`). The alpha channel is deliberately * dropped: Google Maps applies `fillOpacity`/`strokeOpacity` on top of the * color, so an alpha-bearing color would be composited twice and render * washed out. Opacity stays the exclusive job of the opacity props. * * Anything this function cannot parse is returned untouched — Google accepts * most CSS3 color syntaxes, and passing the original through is better than * guessing wrong. */ export function normalizeCanvasColor(input: string): string { const value = input.trim(); if (!value) return value; // #abc -> #aabbcc if (/^#[0-9a-f]{3}$/i.test(value)) { return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`; } // #aabbccdd -> #aabbcc (drop alpha) if (/^#[0-9a-f]{8}$/i.test(value)) return value.slice(0, 7); if (/^#[0-9a-f]{6}$/i.test(value)) return value; // rgb()/rgba(), both the legacy comma form and the modern slash form. const rgb = value.match(/^rgba?\(([^)]+)\)$/i); if (rgb) { const parts = rgb[1] .replace(/\//g, ' ') .split(/[\s,]+/) .filter(Boolean); if (parts.length >= 3) { const channels = parts.slice(0, 3).map(part => { const numeric = parseFloat(part); if (Number.isNaN(numeric)) return NaN; // Percentages are relative to 255. return part.trim().endsWith('%') ? (numeric / 100) * 255 : numeric; }); if (channels.every(Number.isFinite)) { return `#${channels.map(toHexPair).join('')}`; } } } return value; } /* -------------------------------------------------------------------------- */ /* Resolution */ /* -------------------------------------------------------------------------- */ function readToken(token: ColorToken, fallback: string): string { if (typeof window === 'undefined' || typeof document === 'undefined') return fallback; const key = `${token}|${fallback}`; const cached = cache.get(key); if (cached !== undefined) return cached; let resolved = fallback; try { const raw = getComputedStyle(document.documentElement).getPropertyValue(token).trim(); if (raw) resolved = normalizeCanvasColor(raw); } catch { // getComputedStyle can throw in exotic environments (detached documents, // some test runners). The fallback already covers that case. } cache.set(key, resolved); return resolved; } /** * Resolves a CSS custom property to a literal color the Google Maps API can use, * and re-resolves it whenever the theme changes. * * @description * The Maps API consumes literal colors, so `"var(--destructive)"` never resolves * in `fillColor`/`strokeColor`/`iconColor`. Four consumer applications shipped * their own copy of this resolver to work around that, and because each resolved * once at mount, none of them followed a light/dark switch while the rest of the * interface did. This hook is the design system's answer to both halves of that * problem. * * @param token - Custom property name, e.g. `'--chart-1'`. * @param fallback - Literal color used when the token is undefined or unreadable. * * @example * ```tsx * const fill = useTokenColor('--destructive'); * new google.maps.Circle({ fillColor: fill, fillOpacity: 0.2 }); * ``` */ export function useTokenColor(token: ColorToken, fallback: string = TOKEN_COLOR_FALLBACK): string { const revision = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // `revision` is not read inside the memo — it is the invalidation signal that // makes the token re-resolve after a theme change. // eslint-disable-next-line react-hooks/exhaustive-deps return useMemo(() => readToken(token, fallback), [revision, token, fallback]); } /** * Batch form of {@link useTokenColor}, for array-driven props. * * @description * `markers[]`, `polygons[]` and friends carry one token per entry, and a hook * cannot be called inside a loop. This resolves a whole set in one pass and * shares the same subscription and cache as the single-token hook. * * @returns A record keyed by the token names that were passed in. * * @example * ```tsx * const colors = useTokenColors(markers.map(m => m.colorToken ?? '--primary')); * // colors['--chart-1'] -> '#635bff' * ``` */ export function useTokenColors( tokens: ColorToken[], fallback: string = TOKEN_COLOR_FALLBACK ): Record { const revision = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); // Join into a primitive so a new-but-equal array does not rebuild the record. const key = tokens.join('|'); return useMemo(() => { const result: Record = {}; for (const token of tokens) { result[token] = readToken(token, fallback); } return result; // eslint-disable-next-line react-hooks/exhaustive-deps }, [revision, key, fallback]); } /** * Imperative escape hatch: a stable resolver that re-identifies on theme change. * * @description * Useful where tokens are discovered during render — a GeoJSON choropleth whose * class count is data-driven, for instance — and collecting them into an array * ahead of the hook call would be contrived. Because the returned function's * identity changes with the theme, it is safe to list in a `useEffect` * dependency array to re-apply colors after a theme switch. */ export function useTokenColorResolver(): (token: ColorToken, fallback?: string) => string { const revision = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return useCallback( (token: ColorToken, fallback: string = TOKEN_COLOR_FALLBACK) => readToken(token, fallback), // eslint-disable-next-line react-hooks/exhaustive-deps [revision] ); } /** * Applies an alpha channel to a resolved color, producing `#rrggbbaa`. * * @description * DOM overlays (proportional marker discs, legend swatches) need translucency * baked into the color itself, because unlike `google.maps.Circle` they have no * separate `fillOpacity` channel — and a CSS `opacity` would fade the border and * the label along with the fill. * * @param color - Any value `normalizeCanvasColor` can parse; others pass through. * @param alpha - 0–1. Values outside the range are clamped. */ export function withAlpha(color: string, alpha: number): string { const hex = normalizeCanvasColor(color); if (!/^#[0-9a-f]{6}$/i.test(hex)) return hex; const clamped = Math.max(0, Math.min(1, alpha)); return `${hex}${toHexPair(clamped * 255)}`; }