|
| 1 | +# Spec: Editable Diff Sources + Heatmap Color Legend |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +Two related gaps surfaced while testing the new `?src=diff` + `?heat=1` view on the worst-case representative materials: |
| 6 | + |
| 7 | +1. **Diff sources are implicit and inflexible.** `?src=diff` hardcodes the comparison as `|label − input|`, both auto-derived from the current `mp_id`. There's no way to inspect *which* URIs are being diffed, and no way to override them. This blocks the natural next use case: comparing **predicted ρ** (from a model checkpoint) against the **DFT label** for the same material. |
| 8 | +2. **The heatmap has no legend.** Turbo colors are perceptually striking but quantitatively opaque — the user can't tell whether red means "0.01 e/ų" or "0.5 e/ų" of residual density. Currently the user has to mentally combine the Gamma + Low-cutoff sliders with a colormap they can't see to interpret the picture. |
| 9 | + |
| 10 | +## Goals |
| 11 | + |
| 12 | +- **Phase 1 (this spec):** make diff sources first-class and configurable; add a quantitative legend to the heatmap. |
| 13 | +- **Phase 2 (deferred):** signed (diverging) diff colormaps, multiple colormap choice, full color-scale editor a la `~/c/jc-taxes/www/src/GradientEditor.tsx` — punt until we have a 2nd consumer. |
| 14 | + |
| 15 | +Out of scope: `?src=predicted` itself (separate spec), drawer reorg / icon rail (separate item per `#4` discussion). |
| 16 | + |
| 17 | +## 1. Editable two-URI diff sources |
| 18 | + |
| 19 | +### URL state |
| 20 | + |
| 21 | +New URL params (both optional): |
| 22 | +- `?v0=<url>` — first volume operand of the diff |
| 23 | +- `?v1=<url>` — second volume operand |
| 24 | + |
| 25 | +(Naming rationale: `v0`/`v1` implies "volume 0, volume 1" with no before/after ambiguity that `a`/`b` carries.) |
| 26 | + |
| 27 | +When `src=diff`: |
| 28 | +- If both `v0` and `v1` are present, use them as the diff operands directly. `m=` is irrelevant for loading (but still affects the structure metadata used for atoms/lattice — see "structure source" below). |
| 29 | +- If only `v0` xor `v1` is present, fill the other from the auto-resolved label/input for `m=`. (Convenience: lets you pin one side and let the material change.) |
| 30 | +- If neither is present, fall back to current behavior: `v0 = resolveLoadUrl(record, 'label', 'zarr')`, `v1 = resolveLoadUrl(record, 'input', 'zarr')`. |
| 31 | + |
| 32 | +The diff is `|v0 − v1|` regardless of ordering. (Phase 2 will add signed diff with a swap-affecting sign.) |
| 33 | + |
| 34 | +### `loadDiff` API change |
| 35 | + |
| 36 | +```ts |
| 37 | +const loadDiff = useCallback(async ( |
| 38 | + record: MaterialRecord | null, // null when fully URL-driven (?a=&?b=) |
| 39 | + v0UrlOverride?: string, |
| 40 | + v1UrlOverride?: string, |
| 41 | +) => { … }) |
| 42 | +``` |
| 43 | + |
| 44 | +Resolution order inside `loadDiff`: |
| 45 | +1. If `v0UrlOverride`/`v1UrlOverride` set → use those. |
| 46 | +2. Else if `record` set → use `resolveLoadUrl(record, 'label', 'zarr')` / `('input', 'zarr')`. |
| 47 | +3. If still missing → set `fetchStatus` error and return. |
| 48 | + |
| 49 | +### Structure source |
| 50 | + |
| 51 | +The heatmap/iso renderer needs an `atoms + lattice + dims` triple. Currently `loadDiff` borrows `lbl` for that. With user-overridden URLs, we keep the same convention: structure is taken from the **`v0` operand**'s Zarr (the "before" / "expected" side). If the user wants atoms from `v1`, they can swap. |
| 52 | + |
| 53 | +### Drawer UI |
| 54 | + |
| 55 | +When `srcRole === 'diff'`, expose a new collapsible "Diff sources" section in the right drawer (below `Load from URL`, above `Examples`): |
| 56 | + |
| 57 | +``` |
| 58 | +▼ DIFF SOURCES |
| 59 | + v0: [s3://openathena/electrai/zarr/mp-X-label.zarr/ ] [↺] |
| 60 | + v1: [s3://openathena/electrai/zarr/mp-X-input.zarr/ ] [↺] |
| 61 | + [⇄ Swap] [Reset to auto (label / input)] |
| 62 | +``` |
| 63 | + |
| 64 | +- Two text inputs (full-width, monospace, same style as `Load from URL`). |
| 65 | +- `↺` per-row reverts that row to its auto-resolved value. |
| 66 | +- `⇄ Swap` swaps v0 ↔ v1 (purely cosmetic for `|v0−v1|`; matters when Phase 2 lands signed diff). |
| 67 | +- `Reset to auto` clears both `?v0=` and `?v1=` from the URL. |
| 68 | +- Editing a field triggers a load (debounced 500 ms, like `Load from URL` already is). |
| 69 | + |
| 70 | +When `srcRole !== 'diff'`, the section is hidden (don't surface useless inputs). |
| 71 | + |
| 72 | +### Compatibility |
| 73 | + |
| 74 | +- Existing URLs `?m=mp-X&src=diff` continue to work (auto-resolves both sides). |
| 75 | +- `?src=predicted` (when it lands later) will be orthogonal: predicted is a single-source mode, not a diff. To compare predicted vs label, the user uses `?src=diff&v0=<label-url>&v1=<predicted-url>` (no `m=` needed, or `m=` only for atoms metadata). |
| 76 | + |
| 77 | +### Title bar |
| 78 | + |
| 79 | +Currently: `|Label − Input|` for `src=diff`. Update to one of: |
| 80 | +- `|Label − Input|` (auto-derived, both sides from the same material) — unchanged. |
| 81 | +- `|v0 − v1|` (user-overridden) with the URL basenames as a hover tooltip. |
| 82 | + |
| 83 | +## 2. Heatmap color legend |
| 84 | + |
| 85 | +### What it should show |
| 86 | + |
| 87 | +A small **vertical or horizontal turbo color bar** with: |
| 88 | +- Tick labels at numeric values in the volume's actual units (e/ų for density, same units for diff). |
| 89 | +- The currently effective `lowCutoff` line marked on the bar (semi-transparent black overlay below the cutoff, to signal "this range is invisible"). |
| 90 | +- Tick spacing is **gamma-corrected** so the bar visually represents what's rendered: where the user sees a particular shade, the corresponding density value can be read off the same vertical position. |
| 91 | + |
| 92 | +### Where it lives |
| 93 | + |
| 94 | +Bottom-right of the canvas (above the existing axis gizmo) when `useHeatmap` is true. ~24 px wide × ~240 px tall. Floats over the canvas like the slice-viewer thumbnail does today. |
| 95 | + |
| 96 | +Hidden when `useHeatmap` is false. |
| 97 | + |
| 98 | +### Numeric range |
| 99 | + |
| 100 | +Today the renderer normalizes the volume to `[0, 1]` based on per-volume `dMin`/`dMax`. Lift that calculation up so it's available to both the renderer AND the legend: |
| 101 | + |
| 102 | +```ts |
| 103 | +// Move into a utility (or a useMemo at the App level): |
| 104 | +function volumeRange(data: Float32Array): { min: number; max: number } { … } |
| 105 | +``` |
| 106 | + |
| 107 | +Pass `dMin`/`dMax` as props into `HeatmapRenderer` (avoids recomputing) and use the same values for the legend. |
| 108 | + |
| 109 | +For the diff view specifically: `dMin = 0` always (since `|a-b|`), `dMax` = max |Δρ|. That's a meaningful number for the user — the legend should display it. |
| 110 | + |
| 111 | +### Tick choices |
| 112 | + |
| 113 | +- 5 ticks: 0%, 25%, 50%, 75%, 100% in **gamma-corrected position** (so visually evenly spaced, but mapped through `pow(p, 1/gamma)` to recover the data value). |
| 114 | +- Labels: 3 sig figs in scientific notation if `|max| < 0.01` or `|max| > 1000`, else fixed-point. |
| 115 | +- Units suffix: configurable prop (default `'e/ų'`). |
| 116 | + |
| 117 | +### Interaction (Phase 1: read-only) |
| 118 | + |
| 119 | +No drag-to-set yet. Just visual. |
| 120 | + |
| 121 | +(Phase 2 candidate: drag the cutoff line, click ticks to snap iso level.) |
| 122 | + |
| 123 | +## 3. Color-scale lib (`$js/`) — investigation note |
| 124 | + |
| 125 | +`~/c/jc-taxes/www/src/GradientEditor.tsx` (372 lines) is interesting but built for **editable color stops + scale type (linear/sqrt/log)** — overkill for elvis Phase 1, where the colormap is a fixed analytic turbo function. |
| 126 | + |
| 127 | +**Decision for Phase 1:** build a small `HeatmapLegend` component locally in `pkgs/core/src/components/`. Don't copy GradientEditor. |
| 128 | + |
| 129 | +**Future (deferred, separate-session work in `~/c/js/`):** if elvis grows multi-colormap support (turbo + viridis + plasma + diverging) AND/OR jc-taxes wants reuse, factor a `$js/use-color-scale` lib. That work belongs in a `~/c/js/use-color-scale/specs/v1.md` written from a session in `~/c/js/`. A stub note here: |
| 130 | + |
| 131 | +``` |
| 132 | +TODO (separate session, ~/c/js/use-color-scale): |
| 133 | + Extract from jc-taxes/GradientEditor.tsx + elvis/HeatmapLegend: |
| 134 | + - ColorStop type |
| 135 | + - ScaleType (linear/sqrt/log) |
| 136 | + - encodeStops/decodeStops for URL state |
| 137 | + - interpolateColor |
| 138 | + - Optional editor UI (with elvis using read-only mode) |
| 139 | +``` |
| 140 | + |
| 141 | +## 4. Drawer visual cues (deferred) |
| 142 | + |
| 143 | +Per discussion: drawer is getting busy. Light-touch ideas (for a follow-up spec, not this one): |
| 144 | + |
| 145 | +- Section heading icons (Surface, Heatmap, Display, Tiling, Camera, Slice each get a 16 px icon). |
| 146 | +- A subtle accent color per section (matches the icon). |
| 147 | +- Hotkey to collapse all sections except the most recently interacted with one. |
| 148 | +- Persist collapsed/expanded state in localStorage. |
| 149 | + |
| 150 | +Not in scope for this spec. |
| 151 | + |
| 152 | +## Implementation order |
| 153 | + |
| 154 | +1. **Lift `dMin`/`dMax` calc** out of `HeatmapRenderer` into a util + `useMemo` at App level. Pass as props back into renderer (no rendering change). |
| 155 | +2. **`HeatmapLegend` component** in `pkgs/core/src/components/`, consuming `dMin/dMax/gamma/lowCutoff/units`. Wire into `DensityViewer` (canvas overlay, only when heatmap mode is on). Visual-only verification in browser. |
| 156 | +3. **`?a=` / `?b=` URL params** + override-aware `loadDiff`. Verify with `?m=mp-2458647&src=diff` (no override) → unchanged behavior, then with explicit `?a=...&b=...` overrides → same render. |
| 157 | +4. **Diff sources drawer section** in `Controls.tsx` (visible only when `srcRole === 'diff'`). Two inputs, swap, reset-to-auto. Wire up to URL state. |
| 158 | +5. **Title update** for user-overridden case. |
| 159 | + |
| 160 | +Each step is independently committable. |
| 161 | + |
| 162 | +## Open questions |
| 163 | + |
| 164 | +- Legend orientation: vertical (next to gizmo) vs horizontal (above the gizmo, full canvas width)? My instinct: vertical, ~28 px wide × 240 px tall, bottom-right inset. Verify in browser before committing. |
| 165 | +- Should the legend show a tick at the **iso level** too (so it's clear how the heatmap relates to the surface threshold)? Probably yes; small overlay marker. |
| 166 | +- Diff dim mismatch: with editable URLs, the user might paste two arbitrarily-shaped Zarrs. Current `loadDiff` already errors cleanly on dim mismatch — keep that. |
0 commit comments