Skip to content

Commit a2ecee0

Browse files
Merge pull request #64 from Demonstrandum/cursor/perceptually-distinct-run-colors-c8e3
Perceptually distinct run colors
2 parents 7e215f1 + bb61e76 commit a2ecee0

11 files changed

Lines changed: 1016 additions & 98 deletions

File tree

AGENTS_DEV.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ The frontend uses Angular with NgRx for state management. The pattern is:
170170
| **Pinned card reorder UI** | `webapp/metrics/views/main_view/` (CDK Drag&Drop, arrow buttons on card headers) |
171171
| **Run selection** | `webapp/runs/store/runs_reducers.ts` (single toggle, range toggle, page toggle) |
172172
| **Shift-select runs** | `webapp/runs/views/runs_table/runs_data_table.ts` (`selectionClick` with shift key, `lastClickedIndex`), `webapp/runs/actions/runs_actions.ts` (`runRangeSelectionToggled`) |
173-
| **Run colors** | `webapp/runs/store/runs_reducers.ts` (hash-based fallback, profile overrides) |
173+
| **Run colors** | `webapp/runs/store/runs_reducers.ts` (hash-based fallback, profile overrides), `webapp/util/oklch_colors.ts` (deconfliction algorithm) |
174174
| **Profile system** | `webapp/profile/` directory (types, data_source, store, effects, views) |
175175
| **Profile menu** | `webapp/profile/views/profile_menu_component.ts` (mat-icon-button, bookmark icon, unsaved dot indicator) |
176176
| **Tag filter** | `webapp/metrics/views/main_view/filter_input_*` |
@@ -252,6 +252,7 @@ The frontend persists state to browser localStorage. This is the core mechanism
252252
| `_tb_run_selection.v1` | Run visibility states (NgRx) | `{version: 1, runSelection: [[id, bool], ...]}` | Runs effects |
253253
| `runSelectionState` | Run visibility states (Polymer) | Base64-encoded JSON `{runName: bool, ...}` | tf-runs-selector |
254254
| `_tb_run_colors.v1` | Color overrides | `{version: 1, runColorOverrides: [...], groupKeyToColorId: [...]}` | Runs effects |
255+
| `_tb_run_color_deconfliction.v1` | Auto-computed deconfliction overrides | `{version: 1, darkMode, deconflictedColors: [...], baseColors: [...], processedRunIds: [...]}` | Runs effects |
255256
| `_tb_tag_filter.v1` | Tag filter regex | `{value: string, timestamp: number}` | Metrics effects |
256257
| `_tb_axis_scales.v1` | Axis scales | `{version: 1, yAxisScale?: string, xAxisScale?: string}` | Metrics effects |
257258
| `_tb_tag_group_expansion.v1` | Section expanded/collapsed state | `{version: 1, groups: [[name, bool], ...]}` | Metrics effects |
@@ -405,6 +406,8 @@ This section provides context on _why_ features were built the way they were, ba
405406

406407
TensorBoard assigned random colors to runs, which changed on every page refresh. TensorBored computes colors deterministically from a hash of the run ID/name, so the same run always gets the same color. Colors can also be overridden programmatically via the profile writer. When no explicit colors are set, the frontend uses hash-based fallback colors (never white/invisible). Color overrides are stored in localStorage (`_tb_run_colors.v1`).
407408

409+
After hash-based colors are assigned, a **perceptual deconfliction** pass runs on every `fetchRunsSucceeded`. This checks all pairs of run colors using OKLAB delta-E (threshold 0.075) and, for any clashes, searches across **all three OKLCH axes** (lightness, chroma, hue) to find a maximally-distant replacement color. Deconfliction results are cached in `_tb_run_color_deconfliction.v1` (never in profiles) and are deterministically recomputable given the same set of runs. The algorithm is incremental: when new runs are added, only the new runs are checked against the full set of existing effective colors.
410+
408411
### Dashboard Profiles (#5, #12)
409412

410413
The single biggest architectural addition. TensorBoard stored all dashboard state in URL parameters, hitting browser URL length limits with many pins. TensorBored moved everything to localStorage-based profiles:
@@ -532,6 +535,8 @@ If charts appear blank:
532535

533536
### Run Colors Wrong or Missing
534537

535-
1. Check `_tb_run_colors.v1` in localStorage
536-
2. Verify the profile's `runColors` array entries have valid `runId` and `color` fields
537-
3. If colors are white/invisible, the hash-based fallback may not be working — check the color computation in runs store
538+
1. Check `_tb_run_colors.v1` in localStorage (user overrides)
539+
2. Check `_tb_run_color_deconfliction.v1` in localStorage (auto-computed deconfliction overrides)
540+
3. Verify the profile's `runColors` array entries have valid `runId` and `color` fields
541+
4. If colors are white/invisible, the hash-based fallback may not be working — check the color computation in runs store
542+
5. Deconfliction colors are NOT stored in profiles — they are cached separately and recomputed if the cache is lost

tensorbored/webapp/runs/actions/runs_actions.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,28 @@ export const runColorOverridesFetchedFromApi = createAction(
120120
}>()
121121
);
122122

123+
/**
124+
* Dispatched after perceptual deconfliction computes replacement colors
125+
* for hash-based colors that are too similar. Stored separately from
126+
* user overrides so they are never persisted in profiles.
127+
*/
128+
export const runColorDeconflictionComputed = createAction(
129+
'[Runs] Run Color Deconfliction Computed',
130+
props<{
131+
deconflictedColors: Array<[runId: string, color: string]>;
132+
}>()
133+
);
134+
135+
/**
136+
* Dispatched on startup to load cached deconfliction from localStorage.
137+
*/
138+
export const runColorDeconflictionLoaded = createAction(
139+
'[Runs] Run Color Deconfliction Loaded',
140+
props<{
141+
deconflictedColors: Array<[runId: string, color: string]>;
142+
}>()
143+
);
144+
123145
export const runGroupByChanged = createAction(
124146
'[Runs] Run Group By Changed',
125147
props<{

tensorbored/webapp/runs/effects/runs_effects.ts

Lines changed: 137 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,20 @@ import {
5555
getRunColorOverride,
5656
getRunSelectionMap,
5757
} from '../store/runs_selectors';
58-
import {hashColorIdToHex, resolveColorClashes} from '../../util/oklch_colors';
58+
import {computeDeconfliction, hashColorIdToHex} from '../../util/oklch_colors';
5959
import {getDarkModeEnabled} from '../../feature_flag/store/feature_flag_selectors';
6060

6161
const RUN_COLOR_STORAGE_KEY = '_tb_run_colors.v1';
6262
const RUN_SELECTION_STORAGE_KEY = '_tb_run_selection.v1';
63+
const DECONFLICTION_STORAGE_KEY = '_tb_run_color_deconfliction.v1';
64+
65+
type StoredDeconflictionV1 = {
66+
version: 1;
67+
darkMode: boolean;
68+
deconflictedColors: Array<[runId: string, color: string]>;
69+
baseColors: Array<[runId: string, color: string]>;
70+
processedRunIds: string[];
71+
};
6372

6473
type StoredRunColorsV1 = {
6574
version: 1;
@@ -227,6 +236,40 @@ function storedSelectionEqualsMap(
227236
return true;
228237
}
229238

239+
function loadDeconflictionCache(): StoredDeconflictionV1 | null {
240+
const raw = window.localStorage.getItem(DECONFLICTION_STORAGE_KEY);
241+
if (!raw) return null;
242+
try {
243+
const parsed = JSON.parse(raw) as Partial<StoredDeconflictionV1>;
244+
if (parsed.version !== 1) return null;
245+
if (!Array.isArray(parsed.deconflictedColors)) return null;
246+
if (!Array.isArray(parsed.processedRunIds)) return null;
247+
if (!Array.isArray(parsed.baseColors)) return null;
248+
return parsed as StoredDeconflictionV1;
249+
} catch {
250+
return null;
251+
}
252+
}
253+
254+
function persistDeconflictionCache(
255+
deconflictedColors: Map<string, string>,
256+
baseColors: Map<string, string>,
257+
processedRunIds: string[],
258+
darkMode: boolean
259+
) {
260+
const payload: StoredDeconflictionV1 = {
261+
version: 1,
262+
darkMode,
263+
deconflictedColors: Array.from(deconflictedColors.entries()),
264+
baseColors: Array.from(baseColors.entries()),
265+
processedRunIds,
266+
};
267+
window.localStorage.setItem(
268+
DECONFLICTION_STORAGE_KEY,
269+
JSON.stringify(payload)
270+
);
271+
}
272+
230273
function persistRunColorsToLocalStorage(
231274
runColorOverrides: Map<string, string>,
232275
groupKeyToColorId: Map<string, number>
@@ -434,6 +477,8 @@ export class RunsEffects {
434477
actions.runGroupByChanged,
435478
actions.runColorSettingsLoaded,
436479
actions.runColorOverridesFetchedFromApi,
480+
actions.runColorDeconflictionComputed,
481+
actions.runColorDeconflictionLoaded,
437482
actions.profileRunsSettingsApplied,
438483
featureFlagActions.partialFeatureFlagsLoaded,
439484
featureFlagActions.overrideEnableDarkModeChanged
@@ -520,10 +565,30 @@ export class RunsEffects {
520565
});
521566

522567
/**
523-
* After runs are loaded, compute all active run colors and detect
524-
* perceptual clashes (OKLAB deltaE below threshold). For each clash,
525-
* pick a maximally-distant replacement color and save it as an
526-
* override so it persists across refreshes.
568+
* Load cached deconfliction overrides from localStorage on navigation.
569+
*/
570+
this.loadDeconflictionFromStorage$ = createEffect(() => {
571+
return this.actions$.pipe(
572+
ofType(navigated),
573+
map(() => {
574+
const cache = loadDeconflictionCache();
575+
const entries = cache?.deconflictedColors ?? [];
576+
return actions.runColorDeconflictionLoaded({
577+
deconflictedColors: entries,
578+
});
579+
})
580+
);
581+
});
582+
583+
/**
584+
* After runs are loaded, compute perceptual deconfliction for run colors.
585+
*
586+
* Searches all three OKLCH axes (lightness, chroma, hue) to find
587+
* maximally-distinct replacement colors. Cached results are reused for
588+
* known runs; only new runs are checked against the full color set.
589+
*
590+
* Deconfliction overrides are stored separately from user overrides and
591+
* are never persisted in profiles.
527592
*/
528593
this.resolveColorClashes$ = createEffect(() => {
529594
return this.actions$.pipe(
@@ -536,25 +601,75 @@ export class RunsEffects {
536601
),
537602
filter(([, defaultMap]) => defaultMap.size > 1),
538603
map(([, defaultRunColorIdMap, existingOverrides, darkMode]) => {
539-
// Build the current runId -> hex color map.
540-
const runIdToColor = new Map<string, string>();
604+
const LEGACY_MAX = 6;
605+
const sortedRunIds: string[] = [];
606+
const runIdToBaseColor = new Map<string, string>();
607+
const userOverriddenRuns = new Set<string>();
608+
541609
defaultRunColorIdMap.forEach((colorId, runId) => {
542610
if (existingOverrides.has(runId)) {
543-
runIdToColor.set(runId, existingOverrides.get(runId)!);
544-
} else if (colorId >= 0) {
545-
runIdToColor.set(runId, hashColorIdToHex(colorId, darkMode));
611+
sortedRunIds.push(runId);
612+
runIdToBaseColor.set(runId, existingOverrides.get(runId)!);
613+
userOverriddenRuns.add(runId);
614+
} else if (colorId > LEGACY_MAX) {
615+
sortedRunIds.push(runId);
616+
runIdToBaseColor.set(runId, hashColorIdToHex(colorId, darkMode));
546617
}
618+
// Legacy palette IDs (0-6) and inactive (-1) are skipped:
619+
// they use a separate palette and don't need deconfliction.
547620
});
621+
sortedRunIds.sort();
622+
623+
// Determine which cached deconflictions are still valid.
624+
const cache = loadDeconflictionCache();
625+
let cachedDeconflictions = new Map<string, string>();
626+
let cachedRunIds = new Set<string>();
627+
628+
if (cache && cache.darkMode === darkMode) {
629+
const cachedBaseColors = new Map(cache.baseColors);
630+
const cachedProcessed = new Set(cache.processedRunIds);
631+
const currentRunSet = new Set(sortedRunIds);
632+
633+
let cacheValid = true;
634+
for (const cachedRunId of cachedProcessed) {
635+
if (!currentRunSet.has(cachedRunId)) {
636+
cacheValid = false;
637+
break;
638+
}
639+
const oldBase = cachedBaseColors.get(cachedRunId);
640+
const newBase = runIdToBaseColor.get(cachedRunId);
641+
if (oldBase !== newBase) {
642+
cacheValid = false;
643+
break;
644+
}
645+
}
548646

549-
return resolveColorClashes(runIdToColor, darkMode);
550-
}),
551-
filter((overrides) => overrides.size > 0),
552-
map((overrides) =>
553-
actions.runColorSettingsLoaded({
554-
runColorOverrides: Array.from(overrides.entries()),
555-
groupKeyToColorId: [],
556-
})
557-
)
647+
if (cacheValid) {
648+
cachedDeconflictions = new Map(cache.deconflictedColors);
649+
cachedRunIds = cachedProcessed;
650+
}
651+
}
652+
653+
const deconflictions = computeDeconfliction({
654+
sortedRunIds,
655+
runIdToBaseColor,
656+
userOverriddenRuns,
657+
darkMode,
658+
cachedDeconflictions,
659+
cachedRunIds,
660+
});
661+
662+
persistDeconflictionCache(
663+
deconflictions,
664+
runIdToBaseColor,
665+
sortedRunIds,
666+
darkMode
667+
);
668+
669+
return actions.runColorDeconflictionComputed({
670+
deconflictedColors: Array.from(deconflictions.entries()),
671+
});
672+
})
558673
);
559674
});
560675
}
@@ -616,6 +731,9 @@ export class RunsEffects {
616731
/** @export */
617732
syncRunSelectionFromPolymer$;
618733

734+
/** @export */
735+
loadDeconflictionFromStorage$;
736+
619737
/** @export */
620738
resolveColorClashes$;
621739

tensorbored/webapp/runs/store/runs_reducers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const {
129129
>(
130130
{
131131
runColorOverrideForGroupBy: new Map(),
132+
deconflictedRunColors: new Map(),
132133
defaultRunColorIdForGroupBy: new Map(),
133134
groupKeyToColorId: new Map(),
134135
initialGroupBy: {key: GroupByKey.RUN},
@@ -446,6 +447,22 @@ const dataReducer: ActionReducer<RunsDataState, Action> = createReducer(
446447
return {...state, runColorOverrideForGroupBy: nextRunColorOverride};
447448
}
448449
),
450+
on(
451+
runsActions.runColorDeconflictionComputed,
452+
(state, {deconflictedColors}) => {
453+
return {
454+
...state,
455+
deconflictedRunColors: new Map(deconflictedColors),
456+
};
457+
}
458+
),
459+
on(runsActions.runColorDeconflictionLoaded, (state, {deconflictedColors}) => {
460+
const next = new Map(state.deconflictedRunColors);
461+
for (const [runId, color] of deconflictedColors) {
462+
next.set(runId, color);
463+
}
464+
return {...state, deconflictedRunColors: next};
465+
}),
449466
on(runsActions.runSelectorRegexFilterChanged, (state, action) => {
450467
return {
451468
...state,

tensorbored/webapp/runs/store/runs_selectors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,13 @@ export const getRunColorOverride = createSelector(
300300
}
301301
);
302302

303+
export const getDeconflictedRunColors = createSelector(
304+
getDataState,
305+
(state: RunsDataState): Map<string, string> => {
306+
return state.deconflictedRunColors;
307+
}
308+
);
309+
303310
export const getDefaultRunColorIdMap = createSelector(
304311
getDataState,
305312
(state: RunsDataState): Map<string, number> => {

tensorbored/webapp/runs/store/runs_types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export interface RunsDataNamespacedState {
5151
groupKeyToColorId: Map<string, number>;
5252
// Hex color string user has picked for a run.
5353
runColorOverrideForGroupBy: Map<RunId, string>;
54+
// Auto-computed deconfliction overrides. Separate from user overrides so
55+
// they are never stored in profiles and can be deterministically recomputed.
56+
deconflictedRunColors: Map<RunId, string>;
5457
initialGroupBy: GroupBy;
5558
userSetGroupByKey: GroupByKey | null;
5659
colorGroupRegexString: string;

tensorbored/webapp/runs/store/testing.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export function buildRunsState(
5656
runMetadata: {},
5757
runsLoadState: {},
5858
runColorOverrideForGroupBy: new Map(),
59+
deconflictedRunColors: new Map(),
5960
defaultRunColorIdForGroupBy: new Map(),
6061
groupKeyToColorId: new Map(),
6162
initialGroupBy: {key: GroupByKey.RUN},

tensorbored/webapp/util/BUILD

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ tf_ts_library(
110110
"matcher_test.ts",
111111
"memoize_test.ts",
112112
"ngrx_test.ts",
113+
"oklch_colors_test.ts",
113114
"string_test.ts",
114115
"ui_selectors_test.ts",
115116
"value_formatter_test.ts",
@@ -121,6 +122,7 @@ tf_ts_library(
121122
":matcher",
122123
":memoize",
123124
":ngrx",
125+
":oklch_colors",
124126
":string",
125127
":ui_selectors",
126128
":value_formatter",

0 commit comments

Comments
 (0)