From 577db6965f8d16456c1bfc56e0a72176135a92a1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:38:25 +0100 Subject: [PATCH 1/3] feat(log-viewer): give each namespace its own stable colour Colours came from the log's namespace order and wrapped at eight, so a ninth namespace re-used the first colour and a package changed colour between logs. A namespace's name now picks its colour, and once the colour-blind-safe eight are taken the rest are generated across the hues in play, in bands of eight so lightness separates them as well. The namespace bar's cap becomes its own concern at 12 segments, no longer tied to how many colours exist. --- log-viewer/src/components/NamespaceTimeBar.ts | 9 +- .../__tests__/NamespaceTimeBar.test.ts | 17 +- .../__tests__/namespacePalette.test.ts | 107 ++++++++++++ .../__tests__/namespaceTime.test.ts | 39 +---- log-viewer/src/components/namespacePalette.ts | 161 ++++++++++++++++++ log-viewer/src/components/namespaceTime.ts | 50 +----- .../database/components/DatabaseOverview.ts | 2 +- 7 files changed, 285 insertions(+), 100 deletions(-) create mode 100644 log-viewer/src/components/__tests__/namespacePalette.test.ts create mode 100644 log-viewer/src/components/namespacePalette.ts diff --git a/log-viewer/src/components/NamespaceTimeBar.ts b/log-viewer/src/components/NamespaceTimeBar.ts index 11fa0b74..e78c6ca1 100644 --- a/log-viewer/src/components/NamespaceTimeBar.ts +++ b/log-viewer/src/components/NamespaceTimeBar.ts @@ -13,17 +13,16 @@ import { globalStyles } from '../styles/global.styles.js'; import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; import { segmentsWithTail } from './StackedTimeBar.js'; import './StackedTimeBar.js'; +import { logNamespacePalette } from './namespacePalette.js'; import { - NAMESPACE_COLORS, cachedNamespaceSelfTimes, - logNamespacePalette, scopedNamespaceSelfTimes, type NamespaceTime, } from './namespaceTime.js'; -/** Namespaces are few, so the whole scale fits; the cap only guards a log that - * somehow holds more than the palette does. */ -const MAX_SEGMENTS = NAMESPACE_COLORS.length; +/** A dock-width bar cannot show more segments wide enough to read or hover, so + * the rest go to the tail however many colours there are. */ +export const MAX_SEGMENTS = 12; /** No scope resolved yet, so the first null scope still reads as a change. */ const UNRESOLVED = Symbol('unresolved scope'); diff --git a/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts index 71de20ae..1a649514 100644 --- a/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts +++ b/log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts @@ -9,9 +9,9 @@ import type { ApexLog } from 'apex-log-parser'; let apexLog: ApexLog | null = null; import type { LogStore } from '../../core/log/LogStore.js'; -import type { NamespaceTimeBar } from '../NamespaceTimeBar.js'; +import { MAX_SEGMENTS, type NamespaceTimeBar } from '../NamespaceTimeBar.js'; import '../NamespaceTimeBar.js'; -import { NAMESPACE_COLORS } from '../namespaceTime.js'; +import { logNamespacePalette } from '../namespacePalette.js'; import { ev, eventByIndex, log, resetEvents, type FakeEvent } from './fixtures/logEvents.js'; const logOf = (children: FakeEvent[], namespaces: string[]) => { @@ -68,7 +68,7 @@ describe('namespace-time-bar', () => { expect(segments(element).map(({ label }) => label)).toEqual(['pkg', 'other']); // The log's palette, not the scope's order: `other` keeps its log colour even // though it is second here and third in the log. - expect(segments(element)[1]?.color).toBe(NAMESPACE_COLORS[2]); + expect(segments(element)[1]?.color).toBe(logNamespacePalette(apexLog!)('other')); }); it('sums every occurrence of an aggregate, counting a nested one once', async () => { @@ -81,8 +81,11 @@ describe('namespace-time-bar', () => { expect(segments(element)[0]).toMatchObject({ label: 'pkg', timeNs: 50 }); }); - it('gathers the namespaces past the palette into one tail segment', async () => { - const namespaces = NAMESPACE_COLORS.map((_, index) => `ns${index}`).concat('ns8', 'ns9'); + it('gathers the namespaces past the cap into one tail segment', async () => { + const namespaces = Array.from({ length: MAX_SEGMENTS }, (_, index) => `ns${index}`).concat( + 'nsA', + 'nsB', + ); // Descending self time, so the two smallest fall past the palette. logOf( namespaces.map((namespace, index) => ev(namespace, (namespaces.length - index) * 10)), @@ -91,8 +94,8 @@ describe('namespace-time-bar', () => { const shown = segments(await mount()); - expect(shown).toHaveLength(NAMESPACE_COLORS.length + 1); - // ns8 at 20 and ns9 at 10. + expect(shown).toHaveLength(MAX_SEGMENTS + 1); + // nsA at 20 and nsB at 10. expect(shown.at(-1)).toMatchObject({ label: '2 others', timeNs: 30 }); }); diff --git a/log-viewer/src/components/__tests__/namespacePalette.test.ts b/log-viewer/src/components/__tests__/namespacePalette.test.ts new file mode 100644 index 00000000..b132f8cb --- /dev/null +++ b/log-viewer/src/components/__tests__/namespacePalette.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { NAMESPACE_COLORS, logNamespacePalette, namespacePalette } from '../namespacePalette.js'; +import { log } from './fixtures/logEvents.js'; + +const names = (count: number, prefix = 'ns') => + Array.from({ length: count }, (_, index) => `${prefix}${index}`); + +/** The hues of {@link NAMESPACE_COLORS}, as the palette holds them. */ +const WONG_HUES = [244, 48, 165, 346, 77, 236, 335, 180]; + +/** The lightness and hue of a generated `oklch(L C H)` colour, or null for a literal. */ +function generated(color: string): { lightness: string; hue: number } | null { + const parts = /^oklch\((\d[\d.]*) [\d.]+ (\d+)\)$/.exec(color); + return parts ? { lightness: parts[1]!, hue: Number(parts[2]) } : null; +} + +/** Degrees between two hues the short way round. */ +function hueGap(a: number, b: number): number { + const gap = Math.abs(a - b) % 360; + return Math.min(gap, 360 - gap); +} + +describe('namespacePalette', () => { + it('holds the first colour for default, whoever asks first', () => { + // `npsp` hashes to slot 0, so without the hold it would take default's colour. + expect(namespacePalette(['npsp', 'default'])('default')).toBe(NAMESPACE_COLORS[0]); + expect(namespacePalette(['default', ...names(20)])('default')).toBe(NAMESPACE_COLORS[0]); + }); + + it('gives a namespace the same colour whatever order the log names them in', () => { + const forwards = namespacePalette(['default', 'c2g', 'ffirule', 'pse']); + const backwards = namespacePalette(['default', 'pse', 'ffirule', 'c2g']); + + for (const namespace of ['c2g', 'ffirule', 'pse']) { + expect(backwards(namespace)).toBe(forwards(namespace)); + } + }); + + it('takes the eight colour-blind-safe colours first', () => { + const namespaces = ['default', ...names(7)]; + + expect(new Set(namespaces.map(namespacePalette(namespaces)))).toEqual( + new Set(NAMESPACE_COLORS), + ); + }); + + it('gives every namespace its own colour well past the eight', () => { + const namespaces = names(30); + const color = namespacePalette(namespaces); + + expect(new Set(namespaces.map(color)).size).toBe(namespaces.length); + }); + + it('keeps a generated hue clear of the literals and of the band it joins', () => { + const namespaces = ['default', ...names(15)]; + const assigned = namespaces.map(namespacePalette(namespaces)).map(generated); + const band = assigned.filter((colour) => colour?.lightness === '0.65'); + + // Eight literals hold the band, so the eight past them are generated into it. + expect(band).toHaveLength(NAMESPACE_COLORS.length); + const hues = band.map((colour) => colour!.hue); + for (const [index, hue] of hues.entries()) { + for (const other of [...WONG_HUES, ...hues.slice(index + 1)]) { + expect(hueGap(hue, other)).toBeGreaterThanOrEqual(20); + } + } + }); + + it('moves to another lightness once a band is full, so hue alone need not carry it', () => { + const namespaces = ['default', ...names(23)]; + const lightnesses = namespaces + .map(namespacePalette(namespaces)) + .map((color) => generated(color)?.lightness) + .filter((lightness): lightness is string => lightness !== undefined); + + expect(new Set(lightnesses).size).toBeGreaterThan(1); + }); + + it('answers the same colour every time it is asked', () => { + const color = namespacePalette(names(12)); + + expect(color('ns11')).toBe(color('ns11')); + }); +}); + +describe('logNamespacePalette', () => { + it('memoises per log, so every bar shares one assignment', () => { + const apexLog = log([], ['pkg']); + + expect(logNamespacePalette(apexLog)).toBe(logNamespacePalette(apexLog)); + }); + + it('lets the log name its own namespaces before an unnamed one asks', () => { + const apexLog = log([], names(8)); + const color = logNamespacePalette(apexLog); + const own = new Set(names(8).map(color)); + + // Eight named namespaces and `default` hold all eight literals, so a late + // asker is generated a colour rather than taking one already in use. + expect(generated(color('late'))).not.toBeNull(); + expect(own.has(color('late'))).toBe(false); + }); +}); diff --git a/log-viewer/src/components/__tests__/namespaceTime.test.ts b/log-viewer/src/components/__tests__/namespaceTime.test.ts index 5ec66d79..228576ba 100644 --- a/log-viewer/src/components/__tests__/namespaceTime.test.ts +++ b/log-viewer/src/components/__tests__/namespaceTime.test.ts @@ -4,12 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import type { FrameBudgetOptions } from '../../core/utility/FrameBudget.js'; -import { - cachedNamespaceSelfTimes, - logNamespacePalette, - scopedNamespaceSelfTimes, - NAMESPACE_COLORS, -} from '../namespaceTime.js'; +import { cachedNamespaceSelfTimes, scopedNamespaceSelfTimes } from '../namespaceTime.js'; import { ev, log, roots, type FakeEvent } from './fixtures/logEvents.js'; const options: FrameBudgetOptions = { yieldFrame: () => Promise.resolve() }; @@ -99,35 +94,3 @@ describe('scopedNamespaceSelfTimes', () => { expect(cachedNamespaceSelfTimes(apexLog)).toBe(slices); }); }); - -describe('logNamespacePalette', () => { - it('colours the log in its own order, default first, whatever the scope asks in', () => { - const apexLog = log([], ['pkg', 'other']); - const color = logNamespacePalette(apexLog); - - // A frame bar asking `other` first still gets the log's colour for it. - expect(color('other')).toBe(NAMESPACE_COLORS[2]); - expect(color('default')).toBe(NAMESPACE_COLORS[0]); - expect(color('pkg')).toBe(NAMESPACE_COLORS[1]); - }); - - it('memoises per log, so every bar shares one assignment', () => { - const apexLog = log([], ['pkg']); - - expect(logNamespacePalette(apexLog)).toBe(logNamespacePalette(apexLog)); - }); - - it('gives a namespace the log never named the next colour', () => { - const color = logNamespacePalette(log([], ['pkg'])); - - expect(color('late')).toBe(NAMESPACE_COLORS[2]); - }); - - it('wraps round the scale once it runs out', () => { - // `default` takes the first colour, so the log's own last namespace wraps. - const names = NAMESPACE_COLORS.map((_, index) => `ns${index}`); - const color = logNamespacePalette(log([], names)); - - expect(color(names.at(-1)!)).toBe(NAMESPACE_COLORS[0]); - }); -}); diff --git a/log-viewer/src/components/namespacePalette.ts b/log-viewer/src/components/namespacePalette.ts new file mode 100644 index 00000000..d5704aa8 --- /dev/null +++ b/log-viewer/src/components/namespacePalette.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ApexLog } from 'apex-log-parser'; + +import { DEFAULT_NAMESPACE } from '../core/utility/CallerNamespace.js'; + +/** + * Wong's colour-blind-safe eight. A data palette, so these stay literal and do + * not follow the host theme, as the timeline categories do. + */ +export const NAMESPACE_COLORS = [ + '#0072b2', + '#d55e00', + '#009e73', + '#cc79a7', + '#e69f00', + '#56b4e9', + '#aa4499', + '#44aa99', +] as const; + +/** The hues of {@link NAMESPACE_COLORS}, converted once, so a generated hue can + * be kept away from the colours already in play. */ +const WONG_HUES = [244, 48, 165, 346, 77, 236, 335, 180] as const; + +/** + * Generated colours run in bands: a band spreads its hues round the wheel, and the + * next band does the same at another lightness. Hue alone cannot hold twenty + * namespaces apart, so a crowded log separates by lightness as well. Each chroma is + * inside sRGB for every hue at that lightness — the tightest hue holds 0.11 at + * lightness 0.65, 0.089 at 0.82 and 0.085 at 0.50 — so no hue is clipped and none + * reads duller than its neighbours. The first band matches Wong's own lightness and + * sits at the bottom of its chroma range, so the two sets look like one. + */ +const GENERATED_BANDS = [ + { lightness: 0.65, chroma: 0.1 }, + { lightness: 0.82, chroma: 0.08 }, + { lightness: 0.5, chroma: 0.08 }, +] as const; + +/** FNV-1a, 32-bit: a namespace's hue comes from its name, so a package keeps its + * colour between logs. */ +function hueOf(name: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < name.length; index++) { + hash ^= name.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0) % 360; +} + +/** Degrees between two hues the short way round. */ +function hueGap(a: number, b: number): number { + const gap = Math.abs(a - b) % 360; + return Math.min(gap, 360 - gap); +} + +/** + * The hue furthest from every hue in use, searched from the name's own hue so the + * choice stays the name's. Spreading this way needs no clearance constant: as a band + * fills the gaps close evenly instead of one namespace suddenly landing on top of + * another. + */ +function spreadHue(name: string, used: readonly number[]): number { + const from = hueOf(name); + let best = from; + let bestGap = -1; + for (let step = 0; step < 360; step++) { + const hue = (from + step) % 360; + let gap = 360; + for (const taken of used) { + gap = Math.min(gap, hueGap(hue, taken)); + } + if (gap > bestGap) { + bestGap = gap; + best = hue; + } + } + return best; +} + +/** + * A colour per namespace, so a namespace reads the same on every bar and no two + * namespaces in one log share a colour. + * + * The name picks the colour, not the log's order, so a package keeps its colour + * between logs. `default` is in every log, so the first colour is held for it + * whenever it asks. Two names can want one of the eight, and then the second takes + * the next free one: with a fixed set and no repeats, stability is the common case + * rather than a guarantee. Past the eight, colours are generated in the bands of + * {@link GENERATED_BANDS}. + */ +export function namespacePalette(namespaces: Iterable): (namespace: string) => string { + const colors = new Map(); + // Slot 0 is held for `default`, so a namespace hashing to it cannot take it. + const takenSlots = new Set([0]); + // A band spreads over its own hues only. The first also carries the literals, + // which share its lightness, so a generated hue keeps clear of them too. + const bandHues: number[][] = GENERATED_BANDS.map(() => []); + const literalHues = bandHues[0] ?? []; + literalHues.push(WONG_HUES[0]); + let generated = 0; + + const assign = (namespace: string): string => { + if (namespace === DEFAULT_NAMESPACE) { + return NAMESPACE_COLORS[0]; + } + const from = hueOf(namespace) % NAMESPACE_COLORS.length; + for (let step = 0; step < NAMESPACE_COLORS.length; step++) { + const slot = (from + step) % NAMESPACE_COLORS.length; + if (!takenSlots.has(slot)) { + takenSlots.add(slot); + // Non-null: the modulo keeps the slot inside both scales. + literalHues.push(WONG_HUES[slot]!); + return NAMESPACE_COLORS[slot]!; + } + } + const index = Math.min( + Math.floor(generated++ / NAMESPACE_COLORS.length), + GENERATED_BANDS.length - 1, + ); + // Non-null: the clamp keeps the index inside both bands and their hues. + const { lightness, chroma } = GENERATED_BANDS[index]!; + const hues = bandHues[index]!; + const hue = spreadHue(namespace, hues); + hues.push(hue); + return `oklch(${lightness} ${chroma} ${hue})`; + }; + + const color = (namespace: string): string => { + let assigned = colors.get(namespace); + if (!assigned) { + assigned = assign(namespace); + colors.set(namespace, assigned); + } + return assigned; + }; + + for (const namespace of namespaces) { + color(namespace); + } + return color; +} + +const palettes = new WeakMap string>(); + +/** + * The log's own colour per namespace. Every bar in every scope shares it, so a + * namespace on the whole-log bar and on a frame's bar reads as one colour and the + * colour carries meaning between sections. The log's own namespaces claim their + * colours first, so a namespace the log never named cannot take one from them. + */ +export function logNamespacePalette(log: ApexLog): (namespace: string) => string { + let palette = palettes.get(log); + if (!palette) { + palette = namespacePalette([DEFAULT_NAMESPACE, ...log.namespaces]); + palettes.set(log, palette); + } + return palette; +} diff --git a/log-viewer/src/components/namespaceTime.ts b/log-viewer/src/components/namespaceTime.ts index 38d7154d..9efee3ac 100644 --- a/log-viewer/src/components/namespaceTime.ts +++ b/log-viewer/src/components/namespaceTime.ts @@ -1,60 +1,12 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import type { ApexLog, LogEvent } from 'apex-log-parser'; +import type { LogEvent } from 'apex-log-parser'; import { DEFAULT_NAMESPACE } from '../core/utility/CallerNamespace.js'; import { outermostEvents } from '../core/utility/EventTree.js'; import { CHECK_EVERY, frameBudget, type FrameBudgetOptions } from '../core/utility/FrameBudget.js'; -export const NAMESPACE_COLORS = [ - '#0072b2', - '#d55e00', - '#009e73', - '#cc79a7', - '#e69f00', - '#56b4e9', - '#aa4499', - '#44aa99', -] as const; - -/** A colour per namespace in the order given, so the same list always reads the - * same way and a namespace on two bars keeps one colour. A namespace the list - * never named takes the next colour on first ask. */ -function namespacePalette(namespaces: Iterable): (namespace: string) => string { - const colors = new Map(); - const color = (namespace: string): string => { - let assigned = colors.get(namespace); - if (!assigned) { - // Non-null: the modulo keeps the index inside the scale. - assigned = NAMESPACE_COLORS[colors.size % NAMESPACE_COLORS.length]!; - colors.set(namespace, assigned); - } - return assigned; - }; - for (const namespace of namespaces) { - color(namespace); - } - return color; -} - -const palettes = new WeakMap string>(); - -/** - * The log's own colour per namespace. Every bar in every scope shares it, so a - * namespace on the whole-log bar and on a frame's bar reads as one colour and the - * colour carries meaning between sections. The log names its namespaces in a - * fixed order; `default` is not one of them, so it is named first. - */ -export function logNamespacePalette(log: ApexLog): (namespace: string) => string { - let palette = palettes.get(log); - if (!palette) { - palette = namespacePalette([DEFAULT_NAMESPACE, ...log.namespaces]); - palettes.set(log, palette); - } - return palette; -} - export interface NamespaceTime { namespace: string; selfTime: number; diff --git a/log-viewer/src/features/database/components/DatabaseOverview.ts b/log-viewer/src/features/database/components/DatabaseOverview.ts index 7c73ac7a..dd062091 100644 --- a/log-viewer/src/features/database/components/DatabaseOverview.ts +++ b/log-viewer/src/features/database/components/DatabaseOverview.ts @@ -11,7 +11,7 @@ import { dispatchInspectorLocate, dispatchInspectorReveal, } from '../../../components/inspectorReveal.js'; -import { logNamespacePalette } from '../../../components/namespaceTime.js'; +import { logNamespacePalette } from '../../../components/namespacePalette.js'; import '../../../components/StackedTimeBar.js'; import { segmentsWithTail } from '../../../components/StackedTimeBar.js'; import { logContext } from '../../../core/log/logContext.js'; From 77c0ba219a218ece90b1bb4e72872c6bfd26c86b Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:15:36 +0100 Subject: [PATCH 2/3] fix(log-viewer): drop the three near-duplicate namespace colours Wong's eight hold three near-duplicate pairs: two blues 7.8 degrees apart, two magentas 11.7, and a green and a mint 14.9. Any log naming six namespaces showed such a pair, so two packages read as one colour. Keep the five that sit at least 29 degrees apart and generate the rest, which the palette holds 24 degrees apart while the wheel allows it. --- .../__tests__/namespacePalette.test.ts | 151 +++++++--- log-viewer/src/components/namespacePalette.ts | 276 +++++++++++++----- 2 files changed, 314 insertions(+), 113 deletions(-) diff --git a/log-viewer/src/components/__tests__/namespacePalette.test.ts b/log-viewer/src/components/__tests__/namespacePalette.test.ts index b132f8cb..57abe352 100644 --- a/log-viewer/src/components/__tests__/namespacePalette.test.ts +++ b/log-viewer/src/components/__tests__/namespacePalette.test.ts @@ -9,73 +9,154 @@ import { log } from './fixtures/logEvents.js'; const names = (count: number, prefix = 'ns') => Array.from({ length: count }, (_, index) => `${prefix}${index}`); -/** The hues of {@link NAMESPACE_COLORS}, as the palette holds them. */ -const WONG_HUES = [244, 48, 165, 346, 77, 236, 335, 180]; +/** {@link NAMESPACE_COLORS} in OKLab, as the palette holds them. */ +const WONG_OKLAB = [ + [0.532, -0.0575, -0.1181], + [0.621, 0.1151, 0.1257], + [0.62, -0.1254, 0.0325], + [0.679, 0.1144, -0.0278], + [0.753, 0.0361, 0.1534], +] as const; + +/** A generated `oklch(L C H)` colour in OKLab, or null for a literal. */ +function generated(color: string): [number, number, number] | null { + const parts = /^oklch\((\d[\d.]*) ([\d.]+) (\d+)\)$/.exec(color); + if (!parts) { + return null; + } + const [lightness, chroma, hue] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]; + const radians = (hue * Math.PI) / 180; + return [lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)]; +} + +/** The hue of a colour, and the shorter way round the wheel between two of them. */ +function hueOf(color: readonly number[]): number { + return ((Math.atan2(color[2]!, color[1]!) * 180) / Math.PI + 360) % 360; +} -/** The lightness and hue of a generated `oklch(L C H)` colour, or null for a literal. */ -function generated(color: string): { lightness: string; hue: number } | null { - const parts = /^oklch\((\d[\d.]*) [\d.]+ (\d+)\)$/.exec(color); - return parts ? { lightness: parts[1]!, hue: Number(parts[2]) } : null; +function hueApart(a: number, b: number): number { + const between = Math.abs(a - b) % 360; + return Math.min(between, 360 - between); } -/** Degrees between two hues the short way round. */ -function hueGap(a: number, b: number): number { - const gap = Math.abs(a - b) % 360; - return Math.min(gap, 360 - gap); +function apart(a: readonly number[], b: readonly number[]): number { + return Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!); +} + +/** Any assigned colour in OKLab, literal or generated. */ +function oklabOf(color: string): readonly number[] { + return generated(color) ?? WONG_OKLAB[NAMESPACE_COLORS.indexOf(color as never)]!; } describe('namespacePalette', () => { it('holds the first colour for default, whoever asks first', () => { - // `npsp` hashes to slot 0, so without the hold it would take default's colour. - expect(namespacePalette(['npsp', 'default'])('default')).toBe(NAMESPACE_COLORS[0]); + // `sf` hashes to slot 0, so without the hold it would take default's colour. + expect(namespacePalette(['sf', 'default'])('default')).toBe(NAMESPACE_COLORS[0]); expect(namespacePalette(['default', ...names(20)])('default')).toBe(NAMESPACE_COLORS[0]); }); it('gives a namespace the same colour whatever order the log names them in', () => { - const forwards = namespacePalette(['default', 'c2g', 'ffirule', 'pse']); - const backwards = namespacePalette(['default', 'pse', 'ffirule', 'c2g']); + // Past the literals, so both the probed and the generated colours are covered. + const namespaces = names(14); + const forwards = namespacePalette(['default', ...namespaces]); + const backwards = namespacePalette(['default', ...[...namespaces].reverse()]); - for (const namespace of ['c2g', 'ffirule', 'pse']) { + for (const namespace of namespaces) { expect(backwards(namespace)).toBe(forwards(namespace)); } }); - it('takes the eight colour-blind-safe colours first', () => { - const namespaces = ['default', ...names(7)]; + it('lets the name pick the generated colour, not the position it is asked in', () => { + // Both palettes hold the same first eight names, so only the ninth differs. + const eight = ['default', ...names(8, 'aa')]; + const first = namespacePalette(eight)('zzz1'); + const second = namespacePalette(eight)('zzz2'); + + expect(generated(first)).not.toBeNull(); + expect(second).not.toBe(first); + }); + + it('takes the colour-blind-safe literals first', () => { + const namespaces = ['default', ...names(4)]; expect(new Set(namespaces.map(namespacePalette(namespaces)))).toEqual( new Set(NAMESPACE_COLORS), ); }); - it('gives every namespace its own colour well past the eight', () => { + it('gives every namespace its own colour well past the literals', () => { const namespaces = names(30); const color = namespacePalette(namespaces); expect(new Set(namespaces.map(color)).size).toBe(namespaces.length); }); - it('keeps a generated hue clear of the literals and of the band it joins', () => { - const namespaces = ['default', ...names(15)]; + it('keeps a generated colour well clear of every colour in use', () => { + // Up to twelve namespaces, which is what a bar shows; past that the wheel is + // crowded enough that holding a hue of its own costs some of this clearance. + const namespaces = ['default', ...names(11)]; const assigned = namespaces.map(namespacePalette(namespaces)).map(generated); - const band = assigned.filter((colour) => colour?.lightness === '0.65'); - - // Eight literals hold the band, so the eight past them are generated into it. - expect(band).toHaveLength(NAMESPACE_COLORS.length); - const hues = band.map((colour) => colour!.hue); - for (const [index, hue] of hues.entries()) { - for (const other of [...WONG_HUES, ...hues.slice(index + 1)]) { - expect(hueGap(hue, other)).toBeGreaterThanOrEqual(20); + const spread = assigned.filter((color): color is [number, number, number] => color !== null); + + // The literals hold their colours, so the rest are generated. + expect(spread).toHaveLength(namespaces.length - NAMESPACE_COLORS.length); + for (const [index, color] of spread.entries()) { + for (const other of [...WONG_OKLAB, ...spread.slice(index + 1)]) { + expect(apart(color, other)).toBeGreaterThan(0.11); + } + } + }); + + it('gives a generated colour a hue of its own, not a literal lighter', () => { + // A hue in common reads as one colour lighter or darker however far apart OKLab + // says the two are, so hue is what the palette settles first. + const namespaces = ['default', ...names(11)]; + const assigned = namespaces.map(namespacePalette(namespaces)); + const spread = assigned.map(generated); + + for (const [index, color] of spread.entries()) { + if (!color) { + continue; + } + const others = [...WONG_OKLAB, ...spread.slice(index + 1).filter((one) => one !== null)]; + for (const other of others) { + expect(hueApart(hueOf(color), hueOf(other))).toBeGreaterThanOrEqual(20); + } + } + }); + + it('gives a generated colour the vividness of the literals, not a duller wash', () => { + // Four of the five literals sit at the sRGB chroma ceiling for their lightness, + // so a generated colour below their range would read as one of them gone dull. + const namespaces = ['default', ...names(23)]; + const chromas = namespaces + .map(namespacePalette(namespaces)) + .map((color) => generated(color)) + .filter((color): color is [number, number, number] => color !== null) + .map(([, a, b]) => Math.hypot(a, b)); + + expect(Math.min(...chromas)).toBeGreaterThanOrEqual(0.085); + }); + + it('keeps every colour apart once the floor can no longer be met', () => { + const namespaces = ['default', ...names(39)]; + const assigned = namespaces.map(namespacePalette(namespaces)).map(oklabOf); + + // The clearance falls with the space left, so the guarantee past it is that it + // falls evenly rather than one colour landing on another. + for (const [index, color] of assigned.entries()) { + for (const other of assigned.slice(index + 1)) { + expect(apart(color, other)).toBeGreaterThan(0.05); } } }); - it('moves to another lightness once a band is full, so hue alone need not carry it', () => { + it('takes another lightness once one is crowded, so hue alone need not carry it', () => { const namespaces = ['default', ...names(23)]; const lightnesses = namespaces .map(namespacePalette(namespaces)) - .map((color) => generated(color)?.lightness) - .filter((lightness): lightness is string => lightness !== undefined); + .map((color) => generated(color)?.[0]) + .filter((lightness): lightness is number => lightness !== undefined); expect(new Set(lightnesses).size).toBeGreaterThan(1); }); @@ -95,12 +176,12 @@ describe('logNamespacePalette', () => { }); it('lets the log name its own namespaces before an unnamed one asks', () => { - const apexLog = log([], names(8)); + const apexLog = log([], names(4)); const color = logNamespacePalette(apexLog); - const own = new Set(names(8).map(color)); + const own = new Set(names(4).map(color)); - // Eight named namespaces and `default` hold all eight literals, so a late - // asker is generated a colour rather than taking one already in use. + // Four named namespaces and `default` hold every literal, so a late asker is + // generated a colour rather than taking one already in use. expect(generated(color('late'))).not.toBeNull(); expect(own.has(color('late'))).toBe(false); }); diff --git a/log-viewer/src/components/namespacePalette.ts b/log-viewer/src/components/namespacePalette.ts index d5704aa8..75e8a851 100644 --- a/log-viewer/src/components/namespacePalette.ts +++ b/log-viewer/src/components/namespacePalette.ts @@ -6,78 +6,199 @@ import type { ApexLog } from 'apex-log-parser'; import { DEFAULT_NAMESPACE } from '../core/utility/CallerNamespace.js'; /** - * Wong's colour-blind-safe eight. A data palette, so these stay literal and do - * not follow the host theme, as the timeline categories do. + * Five of Wong's colour-blind-safe eight. A data palette, so these stay literal + * and do not follow the host theme, as the timeline categories do. + * + * The other three are near-duplicates of these: two blues 7.8° apart, two + * magentas 11.7°, and a green and a mint 14.9°. Any log naming six namespaces + * showed such a pair, so they are left out and their slots generated instead — + * every colour here is at least 29° from the rest. + */ +export const NAMESPACE_COLORS = ['#0072b2', '#d55e00', '#009e73', '#cc79a7', '#e69f00'] as const; + +/** A colour as OKLab coordinates, the space colours are compared in. */ +type Oklab = readonly [lightness: number, a: number, b: number]; + +/** A colour a generated namespace may take, with the hue it was built from. */ +interface Candidate { + readonly lab: Oklab; + readonly hue: number; +} + +/** {@link NAMESPACE_COLORS} in OKLab, converted once. */ +const WONG_OKLAB: readonly Oklab[] = [ + [0.532, -0.0575, -0.1181], + [0.621, 0.1151, 0.1257], + [0.62, -0.1254, 0.0325], + [0.679, 0.1144, -0.0278], + [0.753, 0.0361, 0.1534], +]; + +/** + * The lightnesses a generated colour may take. They stay close to the literals' own + * 0.53–0.75, so no namespace reads washed out or nearly black beside them. Hue does + * the separating; these only carry what a crowded wheel can no longer keep apart by + * hue alone, and having several of them widens the choice at each hue. */ -export const NAMESPACE_COLORS = [ - '#0072b2', - '#d55e00', - '#009e73', - '#cc79a7', - '#e69f00', - '#56b4e9', - '#aa4499', - '#44aa99', -] as const; - -/** The hues of {@link NAMESPACE_COLORS}, converted once, so a generated hue can - * be kept away from the colours already in play. */ -const WONG_HUES = [244, 48, 165, 346, 77, 236, 335, 180] as const; +const GENERATED_LIGHTNESS = [0.55, 0.61, 0.67, 0.73, 0.79] as const; /** - * Generated colours run in bands: a band spreads its hues round the wheel, and the - * next band does the same at another lightness. Hue alone cannot hold twenty - * namespaces apart, so a crowded log separates by lightness as well. Each chroma is - * inside sRGB for every hue at that lightness — the tightest hue holds 0.11 at - * lightness 0.65, 0.089 at 0.82 and 0.085 at 0.50 — so no hue is clipped and none - * reads duller than its neighbours. The first band matches Wong's own lightness and - * sits at the bottom of its chroma range, so the two sets look like one. + * The chroma a generated colour takes, as a share of what sRGB holds at its hue and + * lightness, and the most it may take. Four of the five literals sit within 0.001 + * of that ceiling, so a generated colour has to reach for it too or it reads as one + * of them gone dull. The cap is the literals' own widest chroma: sRGB holds nearly + * twice that around magenta, and taking it there would put one garish chip beside a + * restrained set. */ -const GENERATED_BANDS = [ - { lightness: 0.65, chroma: 0.1 }, - { lightness: 0.82, chroma: 0.08 }, - { lightness: 0.5, chroma: 0.08 }, -] as const; - -/** FNV-1a, 32-bit: a namespace's hue comes from its name, so a package keeps its - * colour between logs. */ -function hueOf(name: string): number { +const CHROMA_REACH = 0.95; +const CHROMA_CAP = 0.17; + +/** How far a generated colour keeps from every hue in use while the wheel allows it. + * Hue is what the eye reads as identity — two colours one hue apart are the same + * colour lighter or darker, however far apart OKLab says they are — so this is met + * before {@link CLEARANCE} is weighed at all. */ +const MIN_HUE_GAP = 24; + +/** How far a generated colour keeps from every colour in use, once its hue is + * settled. A little under the literals' own closest pair, `#d55e00` and `#e69f00` + * at 0.156, since hue is settled first and the wheel cannot always hold both. */ +const CLEARANCE = 0.138; + +/** FNV-1a, 32-bit: a namespace's colour comes from its name, so a package keeps it + * between logs. */ +function seedOf(name: string): number { let hash = 0x811c9dc5; for (let index = 0; index < name.length; index++) { hash ^= name.charCodeAt(index); hash = Math.imul(hash, 0x01000193); } - return (hash >>> 0) % 360; + return hash >>> 0; +} + +function oklabOf(lightness: number, chroma: number, hue: number): Oklab { + const radians = (hue * Math.PI) / 180; + return [lightness, chroma * Math.cos(radians), chroma * Math.sin(radians)]; +} + +const hueOf = ([, a, b]: Oklab): number => ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360; + +const chromaOf = ([, a, b]: Oklab): number => Math.hypot(a, b); + +function apart(a: Oklab, b: Oklab): number { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); +} + +/** The shorter way round the wheel between two hues, in degrees. */ +function hueApart(a: number, b: number): number { + const between = Math.abs(a - b) % 360; + return Math.min(between, 360 - between); } -/** Degrees between two hues the short way round. */ -function hueGap(a: number, b: number): number { - const gap = Math.abs(a - b) % 360; - return Math.min(gap, 360 - gap); +/** Whether an OKLab colour has an sRGB value, so the browser will not clip it to a + * duller one. The matrices are the OKLab specification's own. */ +function inSrgb([lightness, a, b]: Oklab): boolean { + const long = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const medium = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const short = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3; + const channels = [ + 4.0767416621 * long - 3.3077115913 * medium + 0.2309699292 * short, + -1.2684380046 * long + 2.6097574011 * medium - 0.3413193965 * short, + -0.0041960863 * long - 0.7034186147 * medium + 1.707614701 * short, + ]; + return channels.every((channel) => channel >= -0.0005 && channel <= 1.0005); +} + +/** The most chroma sRGB holds at this lightness and hue, to within 0.0005. The + * ceiling has no closed form, so it is bisected. */ +function maxChroma(lightness: number, hue: number): number { + let inside = 0; + let outside = 0.4; + for (let step = 0; step < 12; step++) { + const middle = (inside + outside) / 2; + if (inSrgb(oklabOf(lightness, middle, hue))) { + inside = middle; + } else { + outside = middle; + } + } + return inside; +} + +/** Every colour a generated namespace may take: each lightness across the whole + * wheel, at the most chroma that lightness and hue allow. Built on the first log + * to hold more namespaces than there are literals, since most logs never do. */ +let candidates: readonly Candidate[] | null = null; +function generatedCandidates(): readonly Candidate[] { + candidates ??= GENERATED_LIGHTNESS.flatMap((lightness) => + Array.from({ length: 360 }, (_, hue) => ({ + lab: oklabOf(lightness, Math.min(CHROMA_CAP, maxChroma(lightness, hue) * CHROMA_REACH), hue), + hue, + })), + ); + return candidates; } /** - * The hue furthest from every hue in use, searched from the name's own hue so the - * choice stays the name's. Spreading this way needs no clearance constant: as a band - * fills the gaps close evenly instead of one namespace suddenly landing on top of - * another. + * How far every candidate sits from the colours already in use, kept as the colours + * are handed out so choosing one costs a single pass rather than one per colour. */ -function spreadHue(name: string, used: readonly number[]): number { - const from = hueOf(name); - let best = from; - let bestGap = -1; - for (let step = 0; step < 360; step++) { - const hue = (from + step) % 360; - let gap = 360; - for (const taken of used) { - gap = Math.min(gap, hueGap(hue, taken)); +class Distances { + private readonly pool = generatedCandidates(); + private readonly hueGaps: Float64Array; + private readonly gaps: Float64Array; + + constructor(used: readonly Oklab[]) { + this.hueGaps = new Float64Array(this.pool.length).fill(Infinity); + this.gaps = new Float64Array(this.pool.length).fill(Infinity); + used.forEach((color) => this.note(color)); + } + + /** Note a colour as in use, closing the gaps around it. */ + note(color: Oklab): void { + const hue = hueOf(color); + for (let index = 0; index < this.pool.length; index++) { + const candidate = this.pool[index]!; // in range: the arrays share its length + this.hueGaps[index] = Math.min(this.hueGaps[index]!, hueApart(candidate.hue, hue)); + this.gaps[index] = Math.min(this.gaps[index]!, apart(candidate.lab, color)); } - if (gap > bestGap) { - bestGap = gap; - best = hue; + } + + /** + * The candidate a name takes. Hue is settled first, so no distance in OKLab can + * buy a colour that reads as another one lighter: only once the wheel is too + * crowded to hold {@link MIN_HUE_GAP} does the gap narrow, and lightness and + * chroma carry what hue no longer can. The name then picks from every candidate + * that qualifies, so the colour is the name's rather than the log's order — + * qualifying candidates form arcs, and taking the first would hand every name the + * same arc edge. + */ + pick(seed: number): Candidate { + const wanted = Math.min(MIN_HUE_GAP, this.widest(this.hueGaps) * 0.9); + const byHue: number[] = []; + for (let index = 0; index < this.pool.length; index++) { + if (this.hueGaps[index]! >= wanted) { + byHue.push(index); + } } + const clearance = Math.min(CLEARANCE, this.widest(this.gaps, byHue) * 0.9); + const eligible = byHue.filter((index) => this.gaps[index]! >= clearance); + // Non-null: nine tenths of the widest gap on offer always leaves one candidate. + return this.pool[eligible[seed % eligible.length]!]!; } - return best; + + private widest(gaps: Float64Array, over?: readonly number[]): number { + const indices = over ?? gaps.keys(); + let widest = 0; + for (const index of indices) { + widest = Math.max(widest, gaps[index]!); + } + return widest; + } +} + +/** A candidate as the CSS the bars take. */ +function cssOf({ lab, hue }: Candidate): string { + return `oklch(${lab[0]} ${chromaOf(lab).toFixed(3)} ${hue})`; } /** @@ -86,46 +207,43 @@ function spreadHue(name: string, used: readonly number[]): number { * * The name picks the colour, not the log's order, so a package keeps its colour * between logs. `default` is in every log, so the first colour is held for it - * whenever it asks. Two names can want one of the eight, and then the second takes - * the next free one: with a fixed set and no repeats, stability is the common case - * rather than a guarantee. Past the eight, colours are generated in the bands of - * {@link GENERATED_BANDS}. + * whenever it asks. + * + * No two namespaces in one log may share a colour, and that comes first, so + * stability is the common case rather than a guarantee: two names can want one of + * the literals and the second takes the next free one, and a generated colour has + * to clear whatever is already in use. So the set of namespaces decides, never the + * order they appear in: a namespace keeps its colour between two logs holding the + * same packages, and a log holding a different set can move it. */ export function namespacePalette(namespaces: Iterable): (namespace: string) => string { const colors = new Map(); // Slot 0 is held for `default`, so a namespace hashing to it cannot take it. const takenSlots = new Set([0]); - // A band spreads over its own hues only. The first also carries the literals, - // which share its lightness, so a generated hue keeps clear of them too. - const bandHues: number[][] = GENERATED_BANDS.map(() => []); - const literalHues = bandHues[0] ?? []; - literalHues.push(WONG_HUES[0]); - let generated = 0; + const used: Oklab[] = [WONG_OKLAB[0]!]; + let distances: Distances | null = null; const assign = (namespace: string): string => { if (namespace === DEFAULT_NAMESPACE) { return NAMESPACE_COLORS[0]; } - const from = hueOf(namespace) % NAMESPACE_COLORS.length; + const seed = seedOf(namespace); for (let step = 0; step < NAMESPACE_COLORS.length; step++) { - const slot = (from + step) % NAMESPACE_COLORS.length; + const slot = (seed + step) % NAMESPACE_COLORS.length; if (!takenSlots.has(slot)) { takenSlots.add(slot); // Non-null: the modulo keeps the slot inside both scales. - literalHues.push(WONG_HUES[slot]!); + const literal = WONG_OKLAB[slot]!; + used.push(literal); + distances?.note(literal); return NAMESPACE_COLORS[slot]!; } } - const index = Math.min( - Math.floor(generated++ / NAMESPACE_COLORS.length), - GENERATED_BANDS.length - 1, - ); - // Non-null: the clamp keeps the index inside both bands and their hues. - const { lightness, chroma } = GENERATED_BANDS[index]!; - const hues = bandHues[index]!; - const hue = spreadHue(namespace, hues); - hues.push(hue); - return `oklch(${lightness} ${chroma} ${hue})`; + distances ??= new Distances(used); + const spread = distances.pick(seed); + used.push(spread.lab); + distances.note(spread.lab); + return cssOf(spread); }; const color = (namespace: string): string => { @@ -137,7 +255,9 @@ export function namespacePalette(namespaces: Iterable): (namespace: stri return assigned; }; - for (const namespace of namespaces) { + // Sorted, so the set of namespaces decides the assignment and the order the log + // happens to name them in cannot. + for (const namespace of [...namespaces].sort()) { color(namespace); } return color; From 9be325a3f9201dacca6bc922e336a061d9aa01f1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:45:50 +0100 Subject: [PATCH 3/3] docs(log-viewer): note where the namespace hue gap stops holding The gap holds to twelve namespaces and narrows past it. Spreading hues evenly would hold it further but cannot be colour-blind safe, so the literals are not replaceable by a generated spread. --- log-viewer/src/components/namespacePalette.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/log-viewer/src/components/namespacePalette.ts b/log-viewer/src/components/namespacePalette.ts index 75e8a851..71db8487 100644 --- a/log-viewer/src/components/namespacePalette.ts +++ b/log-viewer/src/components/namespacePalette.ts @@ -56,7 +56,11 @@ const CHROMA_CAP = 0.17; /** How far a generated colour keeps from every hue in use while the wheel allows it. * Hue is what the eye reads as identity — two colours one hue apart are the same * colour lighter or darker, however far apart OKLab says they are — so this is met - * before {@link CLEARANCE} is weighed at all. */ + * before {@link CLEARANCE} is weighed at all. + * + * The wheel holds this to twelve namespaces and narrows past it. Spreading hues + * evenly instead would hold it further, but only the literals are colour-blind + * safe: an even spread cannot be, since deuteranopia collapses red against green. */ const MIN_HUE_GAP = 24; /** How far a generated colour keeps from every colour in use, once its hue is