Skip to content

Commit b4711d5

Browse files
Add URL deep-linking for Cascades and Muller Resonance (#168)
## Summary - **Cascades**: full URL param support — `?fuel=`, `?material=`, `?temp=`, `?maxLoops=`, and all boolean toggles. Material presets resolve via `getMaterialById()` and auto-enable weighted mode. URL params take priority over context restore. - **Muller Resonance**: adds `?element=`, `?threshold=`, `?naeFilter=`, `?sort=`, `?dir=` alongside existing `?tab=`. Enables deep-linking to specific element analyses and NAE configurations. - Both pages only serialize non-default values to keep URLs clean, and use `replace: true` to avoid cluttering browser history. - Includes `parseFloat` NaN guard fix for invalid URL params. Replaces #163 (auto-closed when base branch was deleted during #162 merge). ## Test plan - [x] `npm run build` passes - [x] E2E tests pass on all 3 browsers (verified on prior CI run) - [x] `parseFloat` NaN propagation fixed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude (staging merge) <noreply@anthropic.com>
1 parent c0adda5 commit b4711d5

2 files changed

Lines changed: 153 additions & 41 deletions

File tree

src/pages/CascadesAll.tsx

Lines changed: 114 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useRef } from 'react'
22
import { useTranslation } from 'react-i18next'
3+
import { useSearchParams } from 'react-router-dom'
34
import { Play, Settings, AlertCircle, CheckCircle, XCircle, Loader2, Download, Scale, BookOpen } from 'lucide-react'
45
import { useDatabase } from '../contexts/DatabaseContext'
56
import { useQueryState } from '../contexts/QueryStateContext'
@@ -12,53 +13,143 @@ import MaterialsCatalog from '../components/MaterialsCatalog'
1213
import DatabaseLoadingCard from '../components/DatabaseLoadingCard'
1314
import { getAllElements } from '../services/queryService'
1415
import { createEqualProportions } from '../services/proportionService'
16+
import { getMaterialById } from '../constants/materials'
1517
import type { CascadeResults, Element, WeightedNuclide, ProportionFormat } from '../types'
1618

19+
// Parse URL search params into cascade state (returns null fields when param is absent)
20+
function parseUrlParams(searchParams: URLSearchParams) {
21+
const has = searchParams.toString().length > 0
22+
if (!has) return null
23+
24+
const str = (key: string) => searchParams.get(key)
25+
const num = (key: string) => { const v = searchParams.get(key); if (v === null) return undefined; const n = parseFloat(v); return isNaN(n) ? undefined : n }
26+
const bool = (key: string) => { const v = searchParams.get(key); return v !== null ? v === 'true' : undefined }
27+
const list = (key: string) => { const v = searchParams.get(key); return v ? v.split(',') : undefined }
28+
29+
// Resolve ?material= to fuel nuclides + weighted mode
30+
const materialId = str('material')
31+
let materialFuel: WeightedNuclide[] | undefined
32+
let materialNuclides: string[] | undefined
33+
if (materialId) {
34+
const mat = getMaterialById(materialId)
35+
if (mat) {
36+
materialFuel = mat.composition.map(c => ({
37+
nuclideId: c.nuclideId,
38+
proportion: c.proportion,
39+
sourceType: 'material' as const,
40+
}))
41+
materialNuclides = mat.composition.map(c => c.nuclideId)
42+
}
43+
}
44+
45+
return {
46+
fuel: list('fuel') ?? materialNuclides,
47+
temperature: num('temp'),
48+
minFusionMeV: num('minFusionMeV'),
49+
minTwoToTwoMeV: num('minTwoToTwoMeV'),
50+
maxLoops: num('maxLoops'),
51+
maxNuclides: num('maxNuclides'),
52+
feedbackBosons: bool('feedbackBosons'),
53+
feedbackFermions: bool('feedbackFermions'),
54+
allowDimers: bool('allowDimers'),
55+
excludeMelted: bool('excludeMelted'),
56+
excludeBoiledOff: bool('excludeBoiledOff'),
57+
materialFuel,
58+
useWeightedMode: materialFuel ? true : undefined,
59+
}
60+
}
61+
62+
const DEFAULT_PARAMS = {
63+
temperature: 2400,
64+
minFusionMeV: 1.0,
65+
minTwoToTwoMeV: 1.0,
66+
maxNuclides: 5000,
67+
maxLoops: 25,
68+
feedbackBosons: true,
69+
feedbackFermions: true,
70+
allowDimers: true,
71+
excludeMelted: false,
72+
excludeBoiledOff: true,
73+
}
74+
75+
const DEFAULT_FUEL = ['H-1', 'Li-7', 'Al-27', 'N-14', 'Ni-58', 'Ni-60', 'Ni-62', 'B-10', 'B-11']
76+
1777
export default function CascadesAll() {
1878
const { t } = useTranslation()
1979
const { db, isLoading: dbLoading, error: dbError, downloadProgress } = useDatabase()
2080
const { getCascadeState, updateCascadeState } = useQueryState()
2181
const { runCascade, cancelCascade, progress, isRunning, error: workerError } = useCascadeWorker()
82+
const [searchParams, setSearchParams] = useSearchParams()
83+
const hasUrlParams = useRef(searchParams.toString().length > 0)
84+
const isInitialMount = useRef(true)
2285
const [hasRestoredFromContext, setHasRestoredFromContext] = useState(false)
2386

24-
const [params, setParams] = useState({
25-
temperature: 2400,
26-
minFusionMeV: 1.0,
27-
minTwoToTwoMeV: 1.0,
28-
maxNuclides: 5000,
29-
maxLoops: 25,
30-
feedbackBosons: true,
31-
feedbackFermions: true,
32-
allowDimers: true,
33-
excludeMelted: false,
34-
excludeBoiledOff: true,
87+
// Parse URL params once on mount
88+
const urlState = useRef(parseUrlParams(searchParams))
89+
90+
const [params, setParams] = useState(() => {
91+
const u = urlState.current
92+
if (!u) return { ...DEFAULT_PARAMS }
93+
return {
94+
temperature: u.temperature ?? DEFAULT_PARAMS.temperature,
95+
minFusionMeV: u.minFusionMeV ?? DEFAULT_PARAMS.minFusionMeV,
96+
minTwoToTwoMeV: u.minTwoToTwoMeV ?? DEFAULT_PARAMS.minTwoToTwoMeV,
97+
maxNuclides: u.maxNuclides ?? DEFAULT_PARAMS.maxNuclides,
98+
maxLoops: u.maxLoops ?? DEFAULT_PARAMS.maxLoops,
99+
feedbackBosons: u.feedbackBosons ?? DEFAULT_PARAMS.feedbackBosons,
100+
feedbackFermions: u.feedbackFermions ?? DEFAULT_PARAMS.feedbackFermions,
101+
allowDimers: u.allowDimers ?? DEFAULT_PARAMS.allowDimers,
102+
excludeMelted: u.excludeMelted ?? DEFAULT_PARAMS.excludeMelted,
103+
excludeBoiledOff: u.excludeBoiledOff ?? DEFAULT_PARAMS.excludeBoiledOff,
104+
}
35105
})
36106

37107
// Local state for sliders during dragging (prevents performance issues)
38-
const [sliderMaxNuclides, setSliderMaxNuclides] = useState(5000)
39-
const [sliderMaxLoops, setSliderMaxLoops] = useState(25)
108+
const [sliderMaxNuclides, setSliderMaxNuclides] = useState(() => urlState.current?.maxNuclides ?? DEFAULT_PARAMS.maxNuclides)
109+
const [sliderMaxLoops, setSliderMaxLoops] = useState(() => urlState.current?.maxLoops ?? DEFAULT_PARAMS.maxLoops)
40110

41111
const [availableElements, setAvailableElements] = useState<Element[]>([])
42-
const [fuelNuclides, setFuelNuclides] = useState<string[]>(['H-1', 'Li-7', 'Al-27', 'N-14', 'Ni-58', 'Ni-60', 'Ni-62', 'B-10', 'B-11'])
112+
const [fuelNuclides, setFuelNuclides] = useState<string[]>(() => urlState.current?.fuel ?? DEFAULT_FUEL)
43113
const [results, setResults] = useState<CascadeResults | null>(null)
44114
const [error, setError] = useState<string | null>(null)
45115

46116
// Weighted mode state (Issue #96)
47-
const [useWeightedMode, setUseWeightedMode] = useState(false)
48-
const [weightedFuel, setWeightedFuel] = useState<WeightedNuclide[]>([])
117+
const [useWeightedMode, setUseWeightedMode] = useState(() => urlState.current?.useWeightedMode ?? false)
118+
const [weightedFuel, setWeightedFuel] = useState<WeightedNuclide[]>(() => urlState.current?.materialFuel ?? [])
49119
const [proportionFormat, setProportionFormat] = useState<ProportionFormat>('percentage')
50120
const [showMaterialsCatalog, setShowMaterialsCatalog] = useState(false)
51121

122+
// Sync state → URL params (skip initial mount)
123+
useEffect(() => {
124+
if (isInitialMount.current) {
125+
isInitialMount.current = false
126+
return
127+
}
128+
const p = new URLSearchParams()
129+
if (fuelNuclides.join(',') !== DEFAULT_FUEL.join(',')) p.set('fuel', fuelNuclides.join(','))
130+
if (params.temperature !== DEFAULT_PARAMS.temperature) p.set('temp', String(params.temperature))
131+
if (params.minFusionMeV !== DEFAULT_PARAMS.minFusionMeV) p.set('minFusionMeV', String(params.minFusionMeV))
132+
if (params.minTwoToTwoMeV !== DEFAULT_PARAMS.minTwoToTwoMeV) p.set('minTwoToTwoMeV', String(params.minTwoToTwoMeV))
133+
if (params.maxLoops !== DEFAULT_PARAMS.maxLoops) p.set('maxLoops', String(params.maxLoops))
134+
if (params.maxNuclides !== DEFAULT_PARAMS.maxNuclides) p.set('maxNuclides', String(params.maxNuclides))
135+
if (params.feedbackBosons !== DEFAULT_PARAMS.feedbackBosons) p.set('feedbackBosons', String(params.feedbackBosons))
136+
if (params.feedbackFermions !== DEFAULT_PARAMS.feedbackFermions) p.set('feedbackFermions', String(params.feedbackFermions))
137+
if (params.allowDimers !== DEFAULT_PARAMS.allowDimers) p.set('allowDimers', String(params.allowDimers))
138+
if (params.excludeMelted !== DEFAULT_PARAMS.excludeMelted) p.set('excludeMelted', String(params.excludeMelted))
139+
if (params.excludeBoiledOff !== DEFAULT_PARAMS.excludeBoiledOff) p.set('excludeBoiledOff', String(params.excludeBoiledOff))
140+
setSearchParams(p, { replace: true })
141+
}, [fuelNuclides, params, setSearchParams])
142+
52143
// Load available elements and restore state when database is ready
53144
useEffect(() => {
54145
if (db) {
55146
const elements = getAllElements(db)
56147
setAvailableElements(elements)
57148

58-
// Restore state from context if not already done
149+
// Restore state from context if not already done (skip if URL params provided)
59150
if (!hasRestoredFromContext) {
60151
const savedState = getCascadeState()
61-
if (savedState) {
152+
if (savedState && !hasUrlParams.current) {
62153
setParams({
63154
temperature: savedState.temperature,
64155
minFusionMeV: savedState.minFusionMeV,
@@ -216,21 +307,10 @@ export default function CascadesAll() {
216307
}
217308

218309
const handleReset = () => {
219-
setParams({
220-
temperature: 2400,
221-
minFusionMeV: 1.0,
222-
minTwoToTwoMeV: 1.0,
223-
maxNuclides: 5000,
224-
maxLoops: 25,
225-
feedbackBosons: true,
226-
feedbackFermions: true,
227-
allowDimers: true,
228-
excludeMelted: false,
229-
excludeBoiledOff: true,
230-
})
231-
setSliderMaxNuclides(5000)
232-
setSliderMaxLoops(25)
233-
setFuelNuclides(['H-1', 'Li-7', 'Al-27', 'N-14', 'Ni-58', 'Ni-60', 'Ni-62', 'B-10', 'B-11'])
310+
setParams({ ...DEFAULT_PARAMS })
311+
setSliderMaxNuclides(DEFAULT_PARAMS.maxNuclides)
312+
setSliderMaxLoops(DEFAULT_PARAMS.maxLoops)
313+
setFuelNuclides([...DEFAULT_FUEL])
234314
setResults(null)
235315
setError(null)
236316
// Reset weighted mode state

src/pages/MullerResonance.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useMemo, useCallback } from 'react'
1+
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
22
import { useTranslation } from 'react-i18next'
33
import { useSearchParams } from 'react-router-dom'
44
import { useDatabase } from '../contexts/DatabaseContext'
@@ -27,18 +27,34 @@ type SortDirection = 'asc' | 'desc'
2727
export default function MullerResonance() {
2828
const { t } = useTranslation()
2929
const { db, isLoading: dbLoading, downloadProgress } = useDatabase()
30-
const [searchParams] = useSearchParams()
30+
const [searchParams, setSearchParams] = useSearchParams()
31+
const isInitialMount = useRef(true)
3132
const [elements, setElements] = useState<Element[]>([])
32-
const [selectedElement, setSelectedElement] = useState<string | null>(null)
33-
const [threshold, setThreshold] = useState(5.0)
33+
34+
// Initialize state from URL params (read once on mount)
35+
const [selectedElement, setSelectedElement] = useState<string | null>(
36+
searchParams.get('element') || null
37+
)
38+
const [threshold, setThreshold] = useState(() => {
39+
const p = searchParams.get('threshold')
40+
if (!p) return 5.0
41+
const n = parseFloat(p)
42+
return isNaN(n) ? 5.0 : n
43+
})
3444

3545
// Tab state
3646
const [activeTab, setActiveTab] = useState(searchParams.get('tab') || 'resonance')
3747

3848
// NAE UI state
39-
const [naeFilter, setNaeFilter] = useState(false)
40-
const [naeSortColumn, setNaeSortColumn] = useState<SortColumn>('naeScore')
41-
const [naeSortDirection, setNaeSortDirection] = useState<SortDirection>('asc')
49+
const [naeFilter, setNaeFilter] = useState(searchParams.get('naeFilter') === 'true')
50+
const [naeSortColumn, setNaeSortColumn] = useState<SortColumn>(() => {
51+
const p = searchParams.get('sort') as SortColumn | null
52+
return p && ['element', 'naeScore', 'wavelength', 'deuterium', 'phonon', 'reactions', 'lenr'].includes(p) ? p : 'naeScore'
53+
})
54+
const [naeSortDirection, setNaeSortDirection] = useState<SortDirection>(() => {
55+
const p = searchParams.get('dir') as SortDirection | null
56+
return p === 'desc' ? 'desc' : 'asc'
57+
})
4258
const [expandedRow, setExpandedRow] = useState<number | null>(null)
4359

4460
// Worker handles all heavy computation off the main thread
@@ -60,6 +76,22 @@ export default function MullerResonance() {
6076
{ id: 'nae', label: t('mullerResonance.tabs.naePredictions') },
6177
], [t])
6278

79+
// Sync state → URL params (skip initial mount to avoid replacing URL on load)
80+
useEffect(() => {
81+
if (isInitialMount.current) {
82+
isInitialMount.current = false
83+
return
84+
}
85+
const params = new URLSearchParams()
86+
if (activeTab !== 'resonance') params.set('tab', activeTab)
87+
if (selectedElement) params.set('element', selectedElement)
88+
if (threshold !== 5.0) params.set('threshold', String(threshold))
89+
if (naeFilter) params.set('naeFilter', 'true')
90+
if (activeTab === 'nae' && naeSortColumn !== 'naeScore') params.set('sort', naeSortColumn)
91+
if (activeTab === 'nae' && naeSortDirection !== 'asc') params.set('dir', naeSortDirection)
92+
setSearchParams(params, { replace: true })
93+
}, [activeTab, selectedElement, threshold, naeFilter, naeSortColumn, naeSortDirection, setSearchParams])
94+
6395
// Load elements and initialize worker
6496
useEffect(() => {
6597
if (!db) return

0 commit comments

Comments
 (0)