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..57abe352 --- /dev/null +++ b/log-viewer/src/components/__tests__/namespacePalette.test.ts @@ -0,0 +1,188 @@ +/* + * 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}`); + +/** {@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; +} + +function hueApart(a: number, b: number): number { + const between = Math.abs(a - b) % 360; + return Math.min(between, 360 - between); +} + +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', () => { + // `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', () => { + // 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 namespaces) { + expect(backwards(namespace)).toBe(forwards(namespace)); + } + }); + + 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 literals', () => { + const namespaces = names(30); + const color = namespacePalette(namespaces); + + expect(new Set(namespaces.map(color)).size).toBe(namespaces.length); + }); + + 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 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('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)?.[0]) + .filter((lightness): lightness is number => 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(4)); + const color = logNamespacePalette(apexLog); + const own = new Set(names(4).map(color)); + + // 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/__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..71db8487 --- /dev/null +++ b/log-viewer/src/components/namespacePalette.ts @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ApexLog } from 'apex-log-parser'; + +import { DEFAULT_NAMESPACE } from '../core/utility/CallerNamespace.js'; + +/** + * 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. + */ +const GENERATED_LIGHTNESS = [0.55, 0.61, 0.67, 0.73, 0.79] as const; + +/** + * 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 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. + * + * 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 + * 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; +} + +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); +} + +/** 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; +} + +/** + * 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. + */ +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)); + } + } + + /** + * 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]!]!; + } + + 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})`; +} + +/** + * 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. + * + * 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]); + 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 seed = seedOf(namespace); + for (let step = 0; step < NAMESPACE_COLORS.length; step++) { + const slot = (seed + step) % NAMESPACE_COLORS.length; + if (!takenSlots.has(slot)) { + takenSlots.add(slot); + // Non-null: the modulo keeps the slot inside both scales. + const literal = WONG_OKLAB[slot]!; + used.push(literal); + distances?.note(literal); + return NAMESPACE_COLORS[slot]!; + } + } + 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 => { + let assigned = colors.get(namespace); + if (!assigned) { + assigned = assign(namespace); + colors.set(namespace, assigned); + } + return assigned; + }; + + // 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; +} + +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';