@@ -6,6 +6,10 @@ import NumericSlider from './design-system/components/numeric-slider/numeric-sli
66const U = 'u' ;
77const U2 = 'u²' ;
88const U3 = 'u³' ;
9+ const DEFAULT_MODE = '2d' ;
10+ const DEFAULT_SHAPE_2D = 'rectangle' ;
11+ const DEFAULT_SHAPE_3D = 'prism' ;
12+ const SNAPSHOT_DEBOUNCE_MS = 250 ;
913
1014function formatNumber ( n ) {
1115 if ( ! Number . isFinite ( n ) ) return '—' ;
@@ -176,6 +180,88 @@ const SHAPES_3D = {
176180 } ,
177181} ;
178182
183+ function getCatalog ( mode ) {
184+ return mode === '3d' ? SHAPES_3D : SHAPES_2D ;
185+ }
186+
187+ function getCurrentShapeKey ( state ) {
188+ return state . mode === '3d' ? state . shape3d : state . shape2d ;
189+ }
190+
191+ function getShapeDefForState ( state ) {
192+ return getCatalog ( state . mode ) [ getCurrentShapeKey ( state ) ] ;
193+ }
194+
195+ function snapToStep ( value , min , step ) {
196+ if ( ! Number . isFinite ( step ) || step <= 0 ) return value ;
197+ const snapped = Math . round ( ( value - min ) / step ) * step + min ;
198+ return Number ( snapped . toFixed ( 6 ) ) ;
199+ }
200+
201+ function normalizeValues ( def , inputValues = { } ) {
202+ const values = inputValues && typeof inputValues === 'object' ? inputValues : { } ;
203+ const out = { } ;
204+ def . keys . forEach ( ( key ) => {
205+ const raw = values [ key ] ;
206+ const numeric = Number ( raw ) ;
207+ const fallback = def . defaults [ key ] ;
208+ const value = Number . isFinite ( numeric ) ? numeric : fallback ;
209+ const clamped = Math . min ( def . ranges . max , Math . max ( def . ranges . min , value ) ) ;
210+ out [ key ] = snapToStep ( clamped , def . ranges . min , def . ranges . step ) ;
211+ } ) ;
212+ return out ;
213+ }
214+
215+ function normalizeInitialState ( config ) {
216+ const initial = config ?. initialState || config ?. state || { } ;
217+ const requestedMode = initial . mode === '3d' ? '3d' : DEFAULT_MODE ;
218+ const catalog = getCatalog ( requestedMode ) ;
219+ const fallbackShape = requestedMode === '3d' ? DEFAULT_SHAPE_3D : DEFAULT_SHAPE_2D ;
220+ const requestedShape =
221+ typeof initial . shape === 'string'
222+ ? initial . shape
223+ : requestedMode === '3d'
224+ ? initial . shape3d
225+ : initial . shape2d ;
226+ const shape = Object . prototype . hasOwnProperty . call ( catalog , requestedShape )
227+ ? requestedShape
228+ : fallbackShape ;
229+ const def = catalog [ shape ] ;
230+ const values = normalizeValues ( def , initial . values ) ;
231+
232+ return {
233+ mode : requestedMode ,
234+ shape2d : requestedMode === '2d' ? shape : DEFAULT_SHAPE_2D ,
235+ shape3d : requestedMode === '3d' ? shape : DEFAULT_SHAPE_3D ,
236+ values,
237+ } ;
238+ }
239+
240+ async function loadRuntimeConfig ( ) {
241+ try {
242+ const response = await fetch ( '/config' , { cache : 'no-store' } ) ;
243+ if ( ! response . ok ) {
244+ console . warn ( 'Geometry Explorer config request failed:' , response . status ) ;
245+ return { } ;
246+ }
247+ const config = await response . json ( ) ;
248+ return config && typeof config === 'object' ? config : { } ;
249+ } catch ( error ) {
250+ console . warn ( 'Geometry Explorer config could not be loaded; using defaults.' , error ) ;
251+ return { } ;
252+ }
253+ }
254+
255+ function buildSnapshot ( state , metrics ) {
256+ return {
257+ version : 1 ,
258+ mode : state . mode ,
259+ shape : getCurrentShapeKey ( state ) ,
260+ values : { ...state . values } ,
261+ metrics : { ...metrics } ,
262+ } ;
263+ }
264+
179265function draw2D ( ctx , canvas , shapeKey , values ) {
180266 const { w, h } = getLogicalCanvasSize ( canvas ) ;
181267 ctx . clearRect ( 0 , 0 , w , h ) ;
@@ -529,7 +615,7 @@ function draw3D(ctx, canvas, shapeKey, values) {
529615 }
530616}
531617
532- function initGeometryExplorer ( ) {
618+ async function initGeometryExplorer ( ) {
533619 const canvas = document . getElementById ( 'geometry-canvas' ) ;
534620 const modeSelect = document . getElementById ( 'geometry-mode' ) ;
535621 const shapeSelect = document . getElementById ( 'geometry-shape' ) ;
@@ -538,19 +624,14 @@ function initGeometryExplorer() {
538624 const formulaEl = document . getElementById ( 'geometry-formula-note' ) ;
539625 if ( ! canvas || ! modeSelect || ! shapeSelect || ! slidersRoot || ! metricsList ) return ;
540626
627+ const runtimeConfig = await loadRuntimeConfig ( ) ;
541628 const ctx = canvas . getContext ( '2d' ) ;
542629 let sliders = [ ] ;
543- let state = {
544- mode : '2d' ,
545- shape2d : 'rectangle' ,
546- shape3d : 'prism' ,
547- values : { ...SHAPES_2D . rectangle . defaults } ,
548- } ;
630+ let snapshotTimer = null ;
631+ let state = normalizeInitialState ( runtimeConfig ) ;
549632
550633 function getShapeDef ( ) {
551- return state . mode === '2d'
552- ? SHAPES_2D [ state . shape2d ]
553- : SHAPES_3D [ state . shape3d ] ;
634+ return getShapeDefForState ( state ) ;
554635 }
555636
556637 function readValuesFromSliders ( ) {
@@ -562,7 +643,46 @@ function initGeometryExplorer() {
562643 return out ;
563644 }
564645
565- function syncMetrics ( ) {
646+ async function postSnapshot ( snapshot ) {
647+ try {
648+ await fetch ( '/snapshot' , {
649+ method : 'POST' ,
650+ headers : { 'Content-Type' : 'application/json' } ,
651+ body : JSON . stringify ( snapshot ) ,
652+ } ) ;
653+ } catch ( error ) {
654+ console . warn ( 'Geometry Explorer snapshot could not be saved.' , error ) ;
655+ }
656+ }
657+
658+ function publishSnapshot ( metrics , immediate = false ) {
659+ const snapshot = buildSnapshot ( state , metrics ) ;
660+ if ( snapshotTimer ) {
661+ clearTimeout ( snapshotTimer ) ;
662+ snapshotTimer = null ;
663+ }
664+
665+ if ( immediate ) {
666+ postSnapshot ( snapshot ) ;
667+ return ;
668+ }
669+
670+ snapshotTimer = setTimeout ( ( ) => {
671+ snapshotTimer = null ;
672+ postSnapshot ( snapshot ) ;
673+ } , SNAPSHOT_DEBOUNCE_MS ) ;
674+ }
675+
676+ function applyUiConfig ( ) {
677+ const ui = runtimeConfig . ui || { } ;
678+ modeSelect . disabled = ui . lockedMode === true ;
679+ shapeSelect . disabled = ui . lockedShape === true ;
680+ if ( formulaEl ) {
681+ formulaEl . hidden = ui . showFormulaHints === false ;
682+ }
683+ }
684+
685+ function syncMetrics ( { publish = true , immediate = false } = { } ) {
566686 const def = getShapeDef ( ) ;
567687 state . values = readValuesFromSliders ( ) ;
568688 const computed = def . compute ( state . values ) ;
@@ -614,6 +734,10 @@ function initGeometryExplorer() {
614734 } else {
615735 draw3D ( ctx , canvas , state . shape3d , state . values ) ;
616736 }
737+
738+ if ( publish ) {
739+ publishSnapshot ( computed , immediate ) ;
740+ }
617741 }
618742
619743 function rebuildSliders ( ) {
@@ -657,8 +781,8 @@ function initGeometryExplorer() {
657781 }
658782
659783 function populateShapeOptions ( ) {
660- const catalog = state . mode === '2d' ? SHAPES_2D : SHAPES_3D ;
661- let currentKey = state . mode === '2d' ? state . shape2d : state . shape3d ;
784+ const catalog = getCatalog ( state . mode ) ;
785+ let currentKey = getCurrentShapeKey ( state ) ;
662786 const keys = Object . keys ( catalog ) ;
663787 if ( ! keys . includes ( currentKey ) ) {
664788 currentKey = keys [ 0 ] ;
@@ -701,16 +825,17 @@ function initGeometryExplorer() {
701825
702826 const frameEl = canvas . parentElement ;
703827 const ro = new ResizeObserver ( ( ) => {
704- syncMetrics ( ) ;
828+ syncMetrics ( { publish : false } ) ;
705829 } ) ;
706830 if ( frameEl ) {
707831 ro . observe ( frameEl ) ;
708832 }
709833
710- populateShapeOptions ( ) ;
834+ applyUiConfig ( ) ;
711835 modeSelect . value = state . mode ;
836+ populateShapeOptions ( ) ;
712837 rebuildSliders ( ) ;
713- syncMetrics ( ) ;
838+ syncMetrics ( { immediate : true } ) ;
714839}
715840
716841export { initGeometryExplorer } ;
0 commit comments