Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions log-viewer/src/components/NamespaceTimeBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
17 changes: 10 additions & 7 deletions log-viewer/src/components/__tests__/NamespaceTimeBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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)),
Expand All @@ -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 });
});

Expand Down
188 changes: 188 additions & 0 deletions log-viewer/src/components/__tests__/namespacePalette.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 1 addition & 38 deletions log-viewer/src/components/__tests__/namespaceTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() };
Expand Down Expand Up @@ -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]);
});
});
Loading