Skip to content

Commit 6949d97

Browse files
committed
Editable diff sources (?v0=/?v1=) + tri-state slice default
- `DiffSources` drawer section: editable v0/v1 URLs with per-row reset (↺), swap (⇄), and a debounced commit so typing doesn't churn the URL state - `loadDiff` accepts `v0Override`/`v1Override`; falls back to material auto-resolution. Convention: v0 = label (DFT ground truth), v1 = input (SAD guess); structure taken from v0 - New `useEffect` drives `?src=diff` from URL — fires on mount and on v0/v1/record/zarr changes. Replaces the prior cycle-handler-only path which left initial mounts unloaded - Gate `initialQuery` with `srcRole !== 'diff'` so the single-file fetch doesn't race the diff effect - Title bar shows `|v0 − v1|` when overrides set, else `|Label − Input|` - Tri-state `?sl=`: undefined → off in diff (slice fights the volumetric heatmap), on otherwise; explicit `?sl=0`/`?sl=1` always wins. New `optBoolParam` helper for the encode/decode
1 parent f0b75fb commit 6949d97

3 files changed

Lines changed: 239 additions & 29 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useState, useEffect, useCallback } from 'react'
2+
3+
interface DiffSourcesProps {
4+
/** Current explicit URL override for v0; empty means "use v0Default". */
5+
v0Url: string
6+
v1Url: string
7+
/** Auto-resolved default URLs (label/input) used when v0Url/v1Url are empty. */
8+
v0Default: string
9+
v1Default: string
10+
onV0Change: (url: string) => void
11+
onV1Change: (url: string) => void
12+
}
13+
14+
const COMMIT_MS = 500
15+
16+
function basename(url: string): string {
17+
if (!url) return ''
18+
const u = url.replace(/\/$/, '')
19+
const i = u.lastIndexOf('/')
20+
return i >= 0 ? u.slice(i + 1) : u
21+
}
22+
23+
function Row({ label, value, defaultValue, onChange }: {
24+
label: string
25+
value: string
26+
defaultValue: string
27+
onChange: (v: string) => void
28+
}) {
29+
const [draft, setDraft] = useState(value)
30+
useEffect(() => { setDraft(value) }, [value])
31+
32+
// Debounced commit: writes to URL state COMMIT_MS after typing stops.
33+
useEffect(() => {
34+
if (draft === value) return
35+
const t = setTimeout(() => onChange(draft), COMMIT_MS)
36+
return () => clearTimeout(t)
37+
}, [draft, value, onChange])
38+
39+
const isOverride = !!value
40+
return (
41+
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
42+
<span style={{ width: 22, color: '#aaa', fontSize: 11, fontFamily: 'system-ui' }}>{label}</span>
43+
<input
44+
type="text"
45+
value={draft}
46+
placeholder={defaultValue}
47+
onChange={e => setDraft(e.target.value)}
48+
style={{
49+
flex: 1,
50+
padding: '4px 6px',
51+
background: '#2a2a3e',
52+
border: '1px solid #444',
53+
borderRadius: 3,
54+
color: '#eee',
55+
fontFamily: 'ui-monospace, SFMono-Regular, monospace',
56+
fontSize: 11,
57+
outline: 'none',
58+
minWidth: 0,
59+
}}
60+
title={isOverride ? `Override: ${value}` : `Auto: ${defaultValue || '(unresolved)'}`}
61+
/>
62+
<button
63+
type="button"
64+
onClick={() => { setDraft(''); onChange('') }}
65+
disabled={!isOverride}
66+
title="Reset this row to auto-resolved URL"
67+
style={{
68+
padding: '2px 6px',
69+
background: 'transparent',
70+
border: '1px solid #444',
71+
borderRadius: 3,
72+
color: isOverride ? '#aaa' : '#555',
73+
fontSize: 10,
74+
cursor: isOverride ? 'pointer' : 'default',
75+
}}
76+
></button>
77+
</div>
78+
)
79+
}
80+
81+
export function DiffSources({
82+
v0Url, v1Url, v0Default, v1Default,
83+
onV0Change, onV1Change,
84+
}: DiffSourcesProps) {
85+
const swap = useCallback(() => {
86+
const eff0 = v0Url || v0Default
87+
const eff1 = v1Url || v1Default
88+
onV0Change(eff1)
89+
onV1Change(eff0)
90+
}, [v0Url, v1Url, v0Default, v1Default, onV0Change, onV1Change])
91+
92+
const resetAll = useCallback(() => {
93+
onV0Change('')
94+
onV1Change('')
95+
}, [onV0Change, onV1Change])
96+
97+
const anyOverride = !!(v0Url || v1Url)
98+
99+
return (
100+
<div style={{ borderBottom: '1px solid #333', padding: '6px 16px 8px' }}>
101+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
102+
<span style={{ color: '#aaa', fontSize: 12, fontWeight: 600 }}>
103+
Diff sources <span style={{ color: '#666', fontWeight: 400 }}>|v0 − v1|</span>
104+
</span>
105+
<div style={{ display: 'flex', gap: 4 }}>
106+
<button
107+
type="button"
108+
onClick={swap}
109+
title={`Swap v0 ↔ v1 (current: |${basename(v0Url || v0Default)}${basename(v1Url || v1Default)}|)`}
110+
style={{
111+
padding: '2px 6px', fontSize: 11, background: 'transparent',
112+
border: '1px solid #444', borderRadius: 3, color: '#aaa', cursor: 'pointer',
113+
}}
114+
></button>
115+
<button
116+
type="button"
117+
onClick={resetAll}
118+
disabled={!anyOverride}
119+
title="Reset both rows to auto-resolved (label / input)"
120+
style={{
121+
padding: '2px 6px', fontSize: 10, background: 'transparent',
122+
border: '1px solid #444', borderRadius: 3,
123+
color: anyOverride ? '#aaa' : '#555',
124+
cursor: anyOverride ? 'pointer' : 'default',
125+
}}
126+
>Reset</button>
127+
</div>
128+
</div>
129+
<Row label="v0" value={v0Url} defaultValue={v0Default} onChange={onV0Change} />
130+
<Row label="v1" value={v1Url} defaultValue={v1Default} onChange={onV1Change} />
131+
</div>
132+
)
133+
}

pkgs/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export { IsosurfaceRenderer } from './components/IsosurfaceRenderer.tsx'
4040
export { VolumeRenderer } from './components/VolumeRenderer.tsx'
4141
export { HeatmapRenderer } from './components/HeatmapRenderer.tsx'
4242
export { HeatmapLegend } from './components/HeatmapLegend.tsx'
43+
export { DiffSources } from './components/DiffSources.tsx'
4344
export { GlbPreviewRenderer } from './components/GlbPreviewRenderer.tsx'
4445
export { CrystalStructure as CrystalStructureView } from './components/CrystalStructure.tsx'
4546
export { LatticeGizmo } from './components/LatticeGizmo.tsx'

pkgs/static/src/App.tsx

Lines changed: 105 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
SliceViewer,
99
VolumeGallery,
1010
URLInput,
11+
DiffSources,
1112
AWSCredentialsModal,
1213
SizeConfirmModal,
1314
Settings,
@@ -94,6 +95,13 @@ const boolTrueParam: Param<boolean> = {
9495
decode: (e) => e === undefined,
9596
}
9697

98+
// Optional tri-state bool: undefined (absent), true ('1'), false ('0').
99+
// Lets callers compute a context-dependent default instead of hardwiring one.
100+
const optBoolParam: Param<boolean | undefined> = {
101+
encode: (v) => v === undefined ? undefined : v ? '1' : '0',
102+
decode: (e) => e === undefined ? undefined : e !== '0',
103+
}
104+
97105
// Camera state: theta°, phi°, zoom, roll° (roll optional, defaults to 0)
98106
// Encoded as space-separated values: `?c=-90 150.1 23.5 72.8`
99107
// Spaces become `+` in query strings, so: `?c=-90+150.1+23.5+72.8`
@@ -210,7 +218,10 @@ export default function App() {
210218
const [showXyzBox, setShowXyzBox] = useUrlState('xb', boolParam)
211219
const [showAtomLabels, setShowAtomLabels] = useUrlState('al', boolTrueParam)
212220
const [dashedLines, setDashedLines] = useUrlState('dl', boolParam)
213-
const [showSlice, setShowSlice] = useUrlState('sl', boolTrueParam)
221+
// Tri-state: ?sl=1 forces on, ?sl=0 forces off, absent → context-dependent default
222+
// (off in diff view since the slice fights the volumetric heatmap; on otherwise as a
223+
// free 2D context map). Computed below once srcRole is in scope.
224+
const [showSliceUrl, setShowSlice] = useUrlState('sl', optBoolParam)
214225
const [sliceAxis, setSliceAxis] = useUrlState('sa', intParam(2)) as [0 | 1 | 2, (v: 0 | 1 | 2) => void]
215226
const [sliceIndex, setSliceIndex] = useUrlState('si', optIntParam, { debounce: 300 })
216227
const [orbitDeg, setOrbitDeg] = useUrlState('od', intParam(30))
@@ -229,6 +240,29 @@ export default function App() {
229240
const [materialId, setMaterialId] = useUrlState('m', stringParam(DEFAULT_MP_ID), { push: true })
230241
type SrcRole = 'input' | 'label' | 'diff'
231242
const [srcRole, setSrcRole] = useUrlState('src', stringParam('label')) as [SrcRole, (v: SrcRole) => void]
243+
// Diff mode operand overrides (only meaningful when src=diff). Empty = auto-resolve from `m=`.
244+
const [v0Url, setV0Url] = useUrlState('v0', stringParam(''))
245+
const [v1Url, setV1Url] = useUrlState('v1', stringParam(''))
246+
// Effective slice visibility resolves the tri-state: explicit URL wins, else default
247+
// depends on srcRole (off in diff because the slice fights the volumetric heatmap).
248+
const showSlice = showSliceUrl ?? (srcRole !== 'diff')
249+
// Auto-resolve label/input URLs from the current material — used as DiffSources defaults
250+
// and as fallbacks when v0Url/v1Url overrides are empty.
251+
const currentRecord = useMemo<MaterialRecord | null>(() => {
252+
if (!materialId) return null
253+
return MATERIALS_MANIFEST.records.find(r =>
254+
r.id === materialId ||
255+
Object.values(r.datasets).some(d => d?.task_ids?.includes(materialId)),
256+
) ?? null
257+
}, [materialId])
258+
const v0AutoUrl = useMemo(
259+
() => currentRecord ? (resolveLoadUrl(currentRecord, 'label', 'zarr') ?? '') : '',
260+
[currentRecord],
261+
)
262+
const v1AutoUrl = useMemo(
263+
() => currentRecord ? (resolveLoadUrl(currentRecord, 'input', 'zarr') ?? '') : '',
264+
[currentRecord],
265+
)
232266
const [currentVolumeId, setCurrentVolumeIdRaw] = useState<string | null>(
233267
() => sessionStorage.getItem('elvis-active-volume'),
234268
)
@@ -536,15 +570,11 @@ export default function App() {
536570
)
537571
if (!record) return
538572
if (next === 'diff') {
539-
if (!useZarr) {
540-
setFetchStatus('Diff view requires Zarr mode (Shift+Z)')
541-
return
542-
}
543-
loadDiff(record)
544-
} else {
545-
const url = resolveLoadUrl(record, next, useZarr ? 'zarr' : 'chgcar')
546-
if (url) handleUrlSubmit(url)
573+
// Diff load is driven by the srcRole effect; nothing to do here.
574+
return
547575
}
576+
const url = resolveLoadUrl(record, next, useZarr ? 'zarr' : 'chgcar')
577+
if (url) handleUrlSubmit(url)
548578
},
549579
})
550580
useAction('view:toggle-color-by-density', {
@@ -1051,7 +1081,9 @@ export default function App() {
10511081
return null
10521082
},
10531083
staleTime: Infinity,
1054-
enabled: files.length === 0,
1084+
// Skip when src=diff: the diff effect resolves+loads operands directly via Zarr,
1085+
// and a parallel single-file fetch here would race and overwrite that result.
1086+
enabled: files.length === 0 && srcRole !== 'diff',
10551087
})
10561088

10571089
// Populate files from initial query result
@@ -1120,35 +1152,43 @@ export default function App() {
11201152
setFetchStatus(null)
11211153
}, [setCurrentVolumeId])
11221154

1123-
const loadDiff = useCallback(async (record: MaterialRecord) => {
1155+
const loadDiff = useCallback(async (
1156+
record: MaterialRecord | null,
1157+
v0Override?: string,
1158+
v1Override?: string,
1159+
) => {
11241160
setUrlLoading(true)
11251161
setFiles([])
1126-
setFetchStatus('Loading input + label for diff...')
1162+
setFetchStatus('Loading v0 + v1 for diff...')
11271163
try {
1128-
const inputUrl = resolveLoadUrl(record, 'input', 'zarr')
1129-
const labelUrl = resolveLoadUrl(record, 'label', 'zarr')
1130-
if (!inputUrl || !labelUrl) {
1131-
setFetchStatus('Diff requires both input and label Zarr URLs')
1164+
// Resolution: v0/v1 overrides take precedence; otherwise auto-derive from record.
1165+
// Convention: v0 = label (DFT ground truth), v1 = input (SAD guess).
1166+
const v0Resolved = v0Override || (record ? resolveLoadUrl(record, 'label', 'zarr') : undefined)
1167+
const v1Resolved = v1Override || (record ? resolveLoadUrl(record, 'input', 'zarr') : undefined)
1168+
if (!v0Resolved || !v1Resolved) {
1169+
setFetchStatus('Diff requires both v0 and v1 Zarr URLs')
11321170
return
11331171
}
1134-
const [inp, lbl] = await Promise.all([
1135-
fetchZarrVolume(s3UriToHttps(inputUrl)),
1136-
fetchZarrVolume(s3UriToHttps(labelUrl)),
1172+
const [a, b] = await Promise.all([
1173+
fetchZarrVolume(s3UriToHttps(v0Resolved)),
1174+
fetchZarrVolume(s3UriToHttps(v1Resolved)),
11371175
])
1138-
const dInp = inp.grid.dims, dLbl = lbl.grid.dims
1139-
if (dInp[0] !== dLbl[0] || dInp[1] !== dLbl[1] || dInp[2] !== dLbl[2]) {
1140-
setFetchStatus(`Diff dim mismatch: input ${dInp.join('×')} vs label ${dLbl.join('×')}`)
1176+
const dA = a.grid.dims, dB = b.grid.dims
1177+
if (dA[0] !== dB[0] || dA[1] !== dB[1] || dA[2] !== dB[2]) {
1178+
setFetchStatus(`Diff dim mismatch: v0 ${dA.join('×')} vs v1 ${dB.join('×')}`)
11411179
return
11421180
}
1143-
const n = lbl.grid.data.length
1181+
const n = a.grid.data.length
11441182
const data = new Float32Array(n)
1145-
for (let i = 0; i < n; i++) data[i] = Math.abs(lbl.grid.data[i] - inp.grid.data[i])
1183+
for (let i = 0; i < n; i++) data[i] = Math.abs(a.grid.data[i] - b.grid.data[i])
1184+
// Structure (atoms, lattice) is taken from v0.
1185+
const id = record?.id ?? 'diff'
11461186
const diff: VolumeData = {
1147-
...lbl,
1148-
title: `${record.id}/diff`,
1149-
grid: { dims: lbl.grid.dims, data },
1187+
...a,
1188+
title: `${id}/diff`,
1189+
grid: { dims: a.grid.dims, data },
11501190
}
1151-
handleLoad(diff, `${record.id}-diff`)
1191+
handleLoad(diff, `${id}-diff`)
11521192
setFetchStatus(null)
11531193
} catch (e) {
11541194
setFetchStatus(`Diff failed: ${e instanceof Error ? e.message : String(e)}`)
@@ -1157,6 +1197,27 @@ export default function App() {
11571197
}
11581198
}, [handleLoad])
11591199

1200+
// Refs let the diff-effect re-fire only on srcRole/url changes, not on every loadDiff
1201+
// identity change (which happens whenever handleLoad's deps change).
1202+
const loadDiffRef = useRef(loadDiff)
1203+
loadDiffRef.current = loadDiff
1204+
1205+
// Drive `?src=diff` from URL: when srcRole becomes 'diff' (initial mount or via hotkey),
1206+
// or when v0/v1/record change while in diff mode, (re)fetch and compute the diff volume.
1207+
useEffect(() => {
1208+
if (srcRole !== 'diff') return
1209+
if (!useZarr) {
1210+
setFetchStatus('Diff view requires Zarr mode (Shift+Z)')
1211+
return
1212+
}
1213+
const haveOverrides = !!(v0Url && v1Url)
1214+
if (!currentRecord && !haveOverrides) {
1215+
if (v0Url || v1Url) setFetchStatus('Diff requires both v0 and v1 URLs')
1216+
return
1217+
}
1218+
loadDiffRef.current(currentRecord, v0Url || undefined, v1Url || undefined)
1219+
}, [srcRole, currentRecord, v0Url, v1Url, useZarr])
1220+
11601221
const handleUrlSubmit = useCallback(async (url: string) => {
11611222
setUrlLoading(true)
11621223
setFetchStatus(null)
@@ -1387,7 +1448,12 @@ export default function App() {
13871448
/>
13881449
) : (
13891450
<DensityViewer
1390-
label={srcRole === 'input' ? 'Input (SAD)' : srcRole === 'diff' ? '|Label − Input|' : 'Label (DFT)'}
1451+
label={
1452+
srcRole === 'input' ? 'Input (SAD)'
1453+
: srcRole === 'diff'
1454+
? ((v0Url && v0Url.length > 0) || (v1Url && v1Url.length > 0)) ? '|v0 − v1|' : '|Label − Input|'
1455+
: 'Label (DFT)'
1456+
}
13911457
volume={primaryFile.data}
13921458
isoLevel={effectiveIsoLevel}
13931459
opacity={opacity}
@@ -1488,6 +1554,16 @@ export default function App() {
14881554
onLineWidthChange={setLineWidth}
14891555
/>
14901556
<URLInput onSubmit={handleUrlSubmit} loading={urlLoading} />
1557+
{srcRole === 'diff' && (
1558+
<DiffSources
1559+
v0Url={v0Url ?? ''}
1560+
v1Url={v1Url ?? ''}
1561+
v0Default={v0AutoUrl}
1562+
v1Default={v1AutoUrl}
1563+
onV0Change={setV0Url}
1564+
onV1Change={setV1Url}
1565+
/>
1566+
)}
14911567
{fetchStatus && (
14921568
<div style={{
14931569
padding: '4px 16px',

0 commit comments

Comments
 (0)