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

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`).

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.

### Dashboard Profiles (#5, #12)

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:
Expand Down Expand Up @@ -532,6 +535,8 @@ If charts appear blank:

### Run Colors Wrong or Missing

1. Check `_tb_run_colors.v1` in localStorage
2. Verify the profile's `runColors` array entries have valid `runId` and `color` fields
3. If colors are white/invisible, the hash-based fallback may not be working — check the color computation in runs store
1. Check `_tb_run_colors.v1` in localStorage (user overrides)
2. Check `_tb_run_color_deconfliction.v1` in localStorage (auto-computed deconfliction overrides)
3. Verify the profile's `runColors` array entries have valid `runId` and `color` fields
4. If colors are white/invisible, the hash-based fallback may not be working — check the color computation in runs store
5. Deconfliction colors are NOT stored in profiles — they are cached separately and recomputed if the cache is lost
22 changes: 22 additions & 0 deletions tensorbored/webapp/runs/actions/runs_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ export const runColorOverridesFetchedFromApi = createAction(
}>()
);

/**
* Dispatched after perceptual deconfliction computes replacement colors
* for hash-based colors that are too similar. Stored separately from
* user overrides so they are never persisted in profiles.
*/
export const runColorDeconflictionComputed = createAction(
'[Runs] Run Color Deconfliction Computed',
props<{
deconflictedColors: Array<[runId: string, color: string]>;
}>()
);

/**
* Dispatched on startup to load cached deconfliction from localStorage.
*/
export const runColorDeconflictionLoaded = createAction(
'[Runs] Run Color Deconfliction Loaded',
props<{
deconflictedColors: Array<[runId: string, color: string]>;
}>()
);

export const runGroupByChanged = createAction(
'[Runs] Run Group By Changed',
props<{
Expand Down
156 changes: 137 additions & 19 deletions tensorbored/webapp/runs/effects/runs_effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,20 @@ import {
getRunColorOverride,
getRunSelectionMap,
} from '../store/runs_selectors';
import {hashColorIdToHex, resolveColorClashes} from '../../util/oklch_colors';
import {computeDeconfliction, hashColorIdToHex} from '../../util/oklch_colors';
import {getDarkModeEnabled} from '../../feature_flag/store/feature_flag_selectors';

const RUN_COLOR_STORAGE_KEY = '_tb_run_colors.v1';
const RUN_SELECTION_STORAGE_KEY = '_tb_run_selection.v1';
const DECONFLICTION_STORAGE_KEY = '_tb_run_color_deconfliction.v1';

type StoredDeconflictionV1 = {
version: 1;
darkMode: boolean;
deconflictedColors: Array<[runId: string, color: string]>;
baseColors: Array<[runId: string, color: string]>;
processedRunIds: string[];
};

type StoredRunColorsV1 = {
version: 1;
Expand Down Expand Up @@ -227,6 +236,40 @@ function storedSelectionEqualsMap(
return true;
}

function loadDeconflictionCache(): StoredDeconflictionV1 | null {
const raw = window.localStorage.getItem(DECONFLICTION_STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<StoredDeconflictionV1>;
if (parsed.version !== 1) return null;
if (!Array.isArray(parsed.deconflictedColors)) return null;
if (!Array.isArray(parsed.processedRunIds)) return null;
if (!Array.isArray(parsed.baseColors)) return null;
return parsed as StoredDeconflictionV1;
} catch {
return null;
}
}

function persistDeconflictionCache(
deconflictedColors: Map<string, string>,
baseColors: Map<string, string>,
processedRunIds: string[],
darkMode: boolean
) {
const payload: StoredDeconflictionV1 = {
version: 1,
darkMode,
deconflictedColors: Array.from(deconflictedColors.entries()),
baseColors: Array.from(baseColors.entries()),
processedRunIds,
};
window.localStorage.setItem(
DECONFLICTION_STORAGE_KEY,
JSON.stringify(payload)
);
}

function persistRunColorsToLocalStorage(
runColorOverrides: Map<string, string>,
groupKeyToColorId: Map<string, number>
Expand Down Expand Up @@ -434,6 +477,8 @@ export class RunsEffects {
actions.runGroupByChanged,
actions.runColorSettingsLoaded,
actions.runColorOverridesFetchedFromApi,
actions.runColorDeconflictionComputed,
actions.runColorDeconflictionLoaded,
actions.profileRunsSettingsApplied,
featureFlagActions.partialFeatureFlagsLoaded,
featureFlagActions.overrideEnableDarkModeChanged
Expand Down Expand Up @@ -520,10 +565,30 @@ export class RunsEffects {
});

/**
* After runs are loaded, compute all active run colors and detect
* perceptual clashes (OKLAB deltaE below threshold). For each clash,
* pick a maximally-distant replacement color and save it as an
* override so it persists across refreshes.
* Load cached deconfliction overrides from localStorage on navigation.
*/
this.loadDeconflictionFromStorage$ = createEffect(() => {
return this.actions$.pipe(
ofType(navigated),
map(() => {
const cache = loadDeconflictionCache();
const entries = cache?.deconflictedColors ?? [];
return actions.runColorDeconflictionLoaded({
deconflictedColors: entries,
});
})
);
});

/**
* After runs are loaded, compute perceptual deconfliction for run colors.
*
* Searches all three OKLCH axes (lightness, chroma, hue) to find
* maximally-distinct replacement colors. Cached results are reused for
* known runs; only new runs are checked against the full color set.
*
* Deconfliction overrides are stored separately from user overrides and
* are never persisted in profiles.
*/
this.resolveColorClashes$ = createEffect(() => {
return this.actions$.pipe(
Expand All @@ -536,25 +601,75 @@ export class RunsEffects {
),
filter(([, defaultMap]) => defaultMap.size > 1),
map(([, defaultRunColorIdMap, existingOverrides, darkMode]) => {
// Build the current runId -> hex color map.
const runIdToColor = new Map<string, string>();
const LEGACY_MAX = 6;
const sortedRunIds: string[] = [];
const runIdToBaseColor = new Map<string, string>();
const userOverriddenRuns = new Set<string>();

defaultRunColorIdMap.forEach((colorId, runId) => {
if (existingOverrides.has(runId)) {
runIdToColor.set(runId, existingOverrides.get(runId)!);
} else if (colorId >= 0) {
runIdToColor.set(runId, hashColorIdToHex(colorId, darkMode));
sortedRunIds.push(runId);
runIdToBaseColor.set(runId, existingOverrides.get(runId)!);
userOverriddenRuns.add(runId);
} else if (colorId > LEGACY_MAX) {
sortedRunIds.push(runId);
runIdToBaseColor.set(runId, hashColorIdToHex(colorId, darkMode));
}
// Legacy palette IDs (0-6) and inactive (-1) are skipped:
// they use a separate palette and don't need deconfliction.
});
sortedRunIds.sort();

// Determine which cached deconflictions are still valid.
const cache = loadDeconflictionCache();
let cachedDeconflictions = new Map<string, string>();
let cachedRunIds = new Set<string>();

if (cache && cache.darkMode === darkMode) {
const cachedBaseColors = new Map(cache.baseColors);
const cachedProcessed = new Set(cache.processedRunIds);
const currentRunSet = new Set(sortedRunIds);

let cacheValid = true;
for (const cachedRunId of cachedProcessed) {
if (!currentRunSet.has(cachedRunId)) {
cacheValid = false;
break;
}
const oldBase = cachedBaseColors.get(cachedRunId);
const newBase = runIdToBaseColor.get(cachedRunId);
if (oldBase !== newBase) {
cacheValid = false;
break;
}
}

return resolveColorClashes(runIdToColor, darkMode);
}),
filter((overrides) => overrides.size > 0),
map((overrides) =>
actions.runColorSettingsLoaded({
runColorOverrides: Array.from(overrides.entries()),
groupKeyToColorId: [],
})
)
if (cacheValid) {
cachedDeconflictions = new Map(cache.deconflictedColors);
cachedRunIds = cachedProcessed;
}
}

const deconflictions = computeDeconfliction({
sortedRunIds,
runIdToBaseColor,
userOverriddenRuns,
darkMode,
cachedDeconflictions,
cachedRunIds,
});

persistDeconflictionCache(
deconflictions,
runIdToBaseColor,
sortedRunIds,
darkMode
);

return actions.runColorDeconflictionComputed({
deconflictedColors: Array.from(deconflictions.entries()),
});
})
);
});
}
Expand Down Expand Up @@ -616,6 +731,9 @@ export class RunsEffects {
/** @export */
syncRunSelectionFromPolymer$;

/** @export */
loadDeconflictionFromStorage$;

/** @export */
resolveColorClashes$;

Expand Down
17 changes: 17 additions & 0 deletions tensorbored/webapp/runs/store/runs_reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const {
>(
{
runColorOverrideForGroupBy: new Map(),
deconflictedRunColors: new Map(),
defaultRunColorIdForGroupBy: new Map(),
groupKeyToColorId: new Map(),
initialGroupBy: {key: GroupByKey.RUN},
Expand Down Expand Up @@ -446,6 +447,22 @@ const dataReducer: ActionReducer<RunsDataState, Action> = createReducer(
return {...state, runColorOverrideForGroupBy: nextRunColorOverride};
}
),
on(
runsActions.runColorDeconflictionComputed,
(state, {deconflictedColors}) => {
return {
...state,
deconflictedRunColors: new Map(deconflictedColors),
};
}
),
on(runsActions.runColorDeconflictionLoaded, (state, {deconflictedColors}) => {
const next = new Map(state.deconflictedRunColors);
for (const [runId, color] of deconflictedColors) {
next.set(runId, color);
}
return {...state, deconflictedRunColors: next};
}),
on(runsActions.runSelectorRegexFilterChanged, (state, action) => {
return {
...state,
Expand Down
7 changes: 7 additions & 0 deletions tensorbored/webapp/runs/store/runs_selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,13 @@ export const getRunColorOverride = createSelector(
}
);

export const getDeconflictedRunColors = createSelector(
getDataState,
(state: RunsDataState): Map<string, string> => {
return state.deconflictedRunColors;
}
);

export const getDefaultRunColorIdMap = createSelector(
getDataState,
(state: RunsDataState): Map<string, number> => {
Expand Down
3 changes: 3 additions & 0 deletions tensorbored/webapp/runs/store/runs_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export interface RunsDataNamespacedState {
groupKeyToColorId: Map<string, number>;
// Hex color string user has picked for a run.
runColorOverrideForGroupBy: Map<RunId, string>;
// Auto-computed deconfliction overrides. Separate from user overrides so
// they are never stored in profiles and can be deterministically recomputed.
deconflictedRunColors: Map<RunId, string>;
initialGroupBy: GroupBy;
userSetGroupByKey: GroupByKey | null;
colorGroupRegexString: string;
Expand Down
1 change: 1 addition & 0 deletions tensorbored/webapp/runs/store/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function buildRunsState(
runMetadata: {},
runsLoadState: {},
runColorOverrideForGroupBy: new Map(),
deconflictedRunColors: new Map(),
defaultRunColorIdForGroupBy: new Map(),
groupKeyToColorId: new Map(),
initialGroupBy: {key: GroupByKey.RUN},
Expand Down
2 changes: 2 additions & 0 deletions tensorbored/webapp/util/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ tf_ts_library(
"matcher_test.ts",
"memoize_test.ts",
"ngrx_test.ts",
"oklch_colors_test.ts",
"string_test.ts",
"ui_selectors_test.ts",
"value_formatter_test.ts",
Expand All @@ -121,6 +122,7 @@ tf_ts_library(
":matcher",
":memoize",
":ngrx",
":oklch_colors",
":string",
":ui_selectors",
":value_formatter",
Expand Down
Loading