Skip to content

Commit 79172d5

Browse files
committed
feat: route app controls through the URL
Make the UI state shareable and driveable via path and query params so panels, solver settings, analyst views, and layout can be restored directly from a link. Made-with: Cursor
1 parent 308c45c commit 79172d5

18 files changed

Lines changed: 1511 additions & 380 deletions

flexfoil-ui/src/App.tsx

Lines changed: 217 additions & 108 deletions
Large diffs are not rendered by default.

flexfoil-ui/src/components/AirfoilCanvas.tsx

Lines changed: 66 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
1919
import { useShallow } from 'zustand/react/shallow';
2020
import { useAirfoilStore, pauseHistory, resumeHistory } from '../stores/airfoilStore';
2121
import { useVisualizationStore } from '../stores/visualizationStore';
22+
import { useRouteUiStore } from '../stores/routeUiStore';
2223
import { useTheme } from '../contexts/ThemeContext';
2324
import { useLayout } from '../contexts/LayoutContext';
2425
import type { Point, ViewportState, AirfoilPoint } from '../types';
25-
import { computeStreamlines, computePsiGrid, createSmokeSystem, isWasmReady, WasmSmokeSystem, analyzeAirfoil, computeGamma, getBLVisualizationData, type BLVisualizationData } from '../lib/wasm';
26+
import { computeStreamlines, computePsiGrid, createSmokeSystem, isWasmReady, analyzeAirfoil, computeGamma, getBLVisualizationData, type BLVisualizationData, type WasmSmokeSystem } from '../lib/wasm';
2627
import { useMorphingAnimation, getCpColor, computeForceVectors } from '../hooks/useMorphingAnimation';
2728
import { generateCamberSplineCurve } from '../lib/airfoilGeometry';
28-
import { syncToUrl, getCompleteUrlState } from '../lib/urlState';
2929
import { WebGPURenderer, checkWebGPUSupport } from '../lib/webgpu';
3030
// Removed d3-contour - using efficient cell-based rendering instead
3131

@@ -462,11 +462,7 @@ const CONTROL_RADIUS = 6;
462462
const HIT_RADIUS = 10;
463463
const TOUCH_HIT_RADIUS = 24;
464464

465-
interface AirfoilCanvasProps {
466-
initialViewport?: { centerX: number; centerY: number; zoom: number } | null;
467-
}
468-
469-
export function AirfoilCanvas({ initialViewport }: AirfoilCanvasProps) {
465+
export function AirfoilCanvas() {
470466
const canvasRef = useRef<HTMLCanvasElement>(null);
471467
const smokeCanvasRef = useRef<HTMLCanvasElement>(null); // Overlay canvas for smoke (redraws every frame)
472468
const gpuCanvasRef = useRef<HTMLCanvasElement>(null); // WebGPU canvas
@@ -524,36 +520,51 @@ export function AirfoilCanvas({ initialViewport }: AirfoilCanvasProps) {
524520
}))
525521
);
526522

527-
// Viewport state (initialize from URL if provided)
523+
const routeViewport = useRouteUiStore((state) => state.viewport);
524+
const viewportRevision = useRouteUiStore((state) => state.viewportRevision);
525+
const setRouteViewport = useRouteUiStore((state) => state.setViewport);
526+
527+
// Viewport state (initialize from route state)
528528
const [viewport, setViewport] = useState<ViewportState>(() => ({
529529
center: {
530-
x: initialViewport?.centerX ?? 0.5,
531-
y: initialViewport?.centerY ?? 0
530+
x: routeViewport.centerX ?? 0.5,
531+
y: routeViewport.centerY ?? 0,
532532
},
533-
zoom: initialViewport?.zoom ?? 400,
533+
zoom: routeViewport.zoom ?? 400,
534534
width: 800,
535535
height: 600,
536536
}));
537-
538-
// Get airfoil state for URL sync (use shallow equality)
539-
const airfoilState = useAirfoilStore(
540-
useShallow((state) => ({
541-
name: state.name,
542-
nPanels: state.nPanels,
543-
spacingKnots: state.spacingKnots,
544-
controlMode: state.controlMode,
545-
displayAlpha: state.displayAlpha,
546-
thicknessScale: state.thicknessScale,
547-
camberScale: state.camberScale,
548-
curvatureWeight: state.curvatureWeight,
549-
spacingPanelMode: state.spacingPanelMode,
550-
sspInterpolation: state.sspInterpolation,
551-
sspVisualization: state.sspVisualization,
552-
}))
553-
);
554537

555-
// Visualization settings from store (use shallow equality to prevent infinite loops)
556-
const visualizationState = useVisualizationStore(
538+
const {
539+
showGrid,
540+
showCurve,
541+
showPanels,
542+
showPoints,
543+
showControls,
544+
showStreamlines,
545+
showSmoke,
546+
showPsiContours,
547+
showCp,
548+
showForces,
549+
showBoundaryLayer,
550+
showWake,
551+
showDisplacementThickness,
552+
blThicknessScale,
553+
enableMorphing,
554+
morphDuration,
555+
streamlineDensity,
556+
adaptiveStreamlines,
557+
smokeDensity,
558+
smokeParticlesPerBlob,
559+
smokeWaveSpacing,
560+
smokeResetCounter,
561+
flowSpeed,
562+
cpDisplayMode,
563+
cpBarScale,
564+
forceScale,
565+
useGPU,
566+
gpuAvailable: _gpuAvailable, // Used for conditional WebGPU setup
567+
} = useVisualizationStore(
557568
useShallow((state) => ({
558569
showGrid: state.showGrid,
559570
showCurve: state.showCurve,
@@ -585,61 +596,40 @@ export function AirfoilCanvas({ initialViewport }: AirfoilCanvasProps) {
585596
gpuAvailable: state.gpuAvailable,
586597
}))
587598
);
588-
589-
const {
590-
showGrid,
591-
showCurve,
592-
showPanels,
593-
showPoints,
594-
showControls,
595-
showStreamlines,
596-
showSmoke,
597-
showPsiContours,
598-
showCp,
599-
showForces,
600-
showBoundaryLayer,
601-
showWake,
602-
showDisplacementThickness,
603-
blThicknessScale,
604-
enableMorphing,
605-
morphDuration,
606-
streamlineDensity,
607-
adaptiveStreamlines,
608-
smokeDensity,
609-
smokeParticlesPerBlob,
610-
smokeWaveSpacing,
611-
smokeResetCounter,
612-
flowSpeed,
613-
cpDisplayMode,
614-
cpBarScale,
615-
forceScale,
616-
useGPU,
617-
gpuAvailable: _gpuAvailable, // Used for conditional WebGPU setup
618-
} = visualizationState;
619599
void _gpuAvailable; // Suppress unused warning (used implicitly in GPU init)
620600

621601
// Get store actions separately (not needed for URL sync)
622602
const setSmokeDensity = useVisualizationStore((state) => state.setSmokeDensity);
623603
const setGPUAvailable = useVisualizationStore((state) => state.setGPUAvailable);
624604
const updatePerfMetrics = useVisualizationStore((state) => state.updatePerfMetrics);
625-
626-
// Sync all state to URL (debounced)
605+
const appliedViewportRevisionRef = useRef(viewportRevision);
606+
607+
useEffect(() => {
608+
if (viewportRevision === appliedViewportRevisionRef.current) {
609+
return;
610+
}
611+
appliedViewportRevisionRef.current = viewportRevision;
612+
setViewport((current) => ({
613+
...current,
614+
center: {
615+
x: routeViewport.centerX,
616+
y: routeViewport.centerY,
617+
},
618+
zoom: routeViewport.zoom,
619+
}));
620+
}, [routeViewport.centerX, routeViewport.centerY, routeViewport.zoom, viewportRevision]);
621+
627622
useEffect(() => {
628623
const timeoutId = setTimeout(() => {
629-
const urlState = getCompleteUrlState(
630-
airfoilState,
631-
visualizationState,
632-
{
633-
centerX: viewport.center.x,
634-
centerY: viewport.center.y,
635-
zoom: viewport.zoom,
636-
}
637-
);
638-
syncToUrl(urlState);
639-
}, 500); // Debounce 500ms
640-
624+
setRouteViewport({
625+
centerX: viewport.center.x,
626+
centerY: viewport.center.y,
627+
zoom: viewport.zoom,
628+
});
629+
}, 200);
630+
641631
return () => clearTimeout(timeoutId);
642-
}, [airfoilState, visualizationState, viewport]);
632+
}, [setRouteViewport, viewport.center.x, viewport.center.y, viewport.zoom]);
643633

644634
// Streamlines cache
645635
const [streamlines, setStreamlines] = useState<[number, number][][]>([]);

0 commit comments

Comments
 (0)