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