1- import { useMemo , useCallback , useState , useRef , useEffect } from 'react'
1+ import { useMemo , useCallback , useState , useRef , useEffect , useReducer } from 'react'
22import MapGL , { Source , Layer } from 'react-map-gl/maplibre'
33import { useUrlState } from 'use-prms'
44import type { Param } from 'use-prms'
55import { useActions } from 'use-kbd'
66import { renderFlowGraph , renderFlowGraphSinglePoly , renderFlowGraphDebug , renderNodes } from 'geo-sankey'
77import type { FlowGraph , FlowGraphOpts } from 'geo-sankey'
88import BearingDial from './BearingDial'
9+ import NodeOverlay from './NodeOverlay'
910import { useLLZ } from './llz'
1011import { useTheme , MAP_STYLES } from './App'
1112import 'maplibre-gl/dist/maplibre-gl.css'
@@ -38,8 +39,49 @@ export interface FlowMapViewProps {
3839
3940type Selection = { type : 'node' ; id : string } | { type : 'edge' ; from : string ; to : string } | null
4041
42+ type GraphAction =
43+ | { type : 'set' ; next : FlowGraph | ( ( g : FlowGraph ) => FlowGraph ) ; history : boolean }
44+ | { type : 'undo' }
45+ | { type : 'redo' }
46+ | { type : 'pushHistory' ; snapshot : FlowGraph }
47+
48+ interface GraphState { graph : FlowGraph ; past : FlowGraph [ ] ; future : FlowGraph [ ] }
49+
50+ function graphReducer ( s : GraphState , a : GraphAction ) : GraphState {
51+ switch ( a . type ) {
52+ case 'set' : {
53+ const next = typeof a . next === 'function' ? a . next ( s . graph ) : a . next
54+ if ( ! a . history ) return { ...s , graph : next }
55+ if ( next === s . graph ) return s
56+ return { graph : next , past : [ ...s . past , s . graph ] , future : [ ] }
57+ }
58+ case 'pushHistory' : {
59+ // Dedup only against the LAST past entry (avoid duplicate consecutive snapshots).
60+ // Explicit pushHistory is for begin-of-drag — it should push even when snapshot equals current,
61+ // because transient updates to follow will mutate current without pushing history themselves.
62+ const last = s . past [ s . past . length - 1 ]
63+ if ( last === a . snapshot ) return s
64+ return { ...s , past : [ ...s . past , a . snapshot ] , future : [ ] }
65+ }
66+ case 'undo' : {
67+ if ( s . past . length === 0 ) return s
68+ const prev = s . past [ s . past . length - 1 ]
69+ return { graph : prev , past : s . past . slice ( 0 , - 1 ) , future : [ ...s . future , s . graph ] }
70+ }
71+ case 'redo' : {
72+ if ( s . future . length === 0 ) return s
73+ const next = s . future [ s . future . length - 1 ]
74+ return { graph : next , past : [ ...s . past , s . graph ] , future : s . future . slice ( 0 , - 1 ) }
75+ }
76+ }
77+ }
78+
4179export default function FlowMapView ( { graph : initialGraph , title, description, color, pxPerWeight, refLat, defaults, defaultNodes = 0 } : FlowMapViewProps ) {
42- const [ graph , setGraph ] = useState ( initialGraph )
80+ const [ gs , dispatch ] = useReducer ( graphReducer , { graph : initialGraph , past : [ ] , future : [ ] } )
81+ const graph = gs . graph
82+ const setGraph = useCallback ( ( next : FlowGraph | ( ( g : FlowGraph ) => FlowGraph ) ) => {
83+ dispatch ( { type : 'set' , next, history : false } )
84+ } , [ ] )
4385 const [ llz , setLLZ ] = useLLZ ( defaults )
4486 const fileInputRef = useRef < HTMLInputElement > ( null )
4587 const [ editMode , setEditMode ] = useState ( ( ) => sessionStorage . getItem ( 'geo-sankey-edit' ) === '1' )
@@ -53,40 +95,49 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
5395 const [ dragging , setDragging ] = useState < string | null > ( null )
5496 const [ edgeSource , setEdgeSource ] = useState < string | null > ( null ) // for edge creation
5597
98+ const pushGraph = useCallback ( ( next : FlowGraph | ( ( g : FlowGraph ) => FlowGraph ) ) => {
99+ dispatch ( { type : 'set' , next, history : true } )
100+ } , [ ] )
101+ const pushHistory = useCallback ( ( snapshot : FlowGraph ) => {
102+ dispatch ( { type : 'pushHistory' , snapshot } )
103+ } , [ ] )
104+ const undo = useCallback ( ( ) => dispatch ( { type : 'undo' } ) , [ ] )
105+ const redo = useCallback ( ( ) => dispatch ( { type : 'redo' } ) , [ ] )
106+
56107 // Graph mutation helpers
57108 const updateNode = useCallback ( ( id : string , patch : Partial < { pos : [ number , number ] ; bearing : number ; label : string } > ) => {
58- setGraph ( g => ( {
109+ pushGraph ( g => ( {
59110 ...g ,
60111 nodes : g . nodes . map ( n => n . id === id ? { ...n , ...patch } : n ) ,
61112 } ) )
62- } , [ ] )
113+ } , [ pushGraph ] )
63114 const addNode = useCallback ( ( pos : [ number , number ] ) => {
64115 const id = `n${ Date . now ( ) } `
65- setGraph ( g => ( { ...g , nodes : [ ...g . nodes , { id, pos, bearing : 90 } ] } ) )
116+ pushGraph ( g => ( { ...g , nodes : [ ...g . nodes , { id, pos } ] } ) )
66117 setSelection ( { type : 'node' , id } )
67- } , [ ] )
118+ } , [ pushGraph ] )
68119 const deleteNode = useCallback ( ( id : string ) => {
69- setGraph ( g => ( {
120+ pushGraph ( g => ( {
70121 nodes : g . nodes . filter ( n => n . id !== id ) ,
71122 edges : g . edges . filter ( e => e . from !== id && e . to !== id ) ,
72123 } ) )
73124 setSelection ( null )
74- } , [ ] )
125+ } , [ pushGraph ] )
75126 const addEdge = useCallback ( ( from : string , to : string ) => {
76127 if ( from === to ) return
77- setGraph ( g => ( { ...g , edges : [ ...g . edges , { from, to, weight : 10 } ] } ) )
128+ pushGraph ( g => ( { ...g , edges : [ ...g . edges , { from, to, weight : 10 } ] } ) )
78129 setSelection ( { type : 'edge' , from, to } )
79- } , [ ] )
130+ } , [ pushGraph ] )
80131 const updateEdge = useCallback ( ( from : string , to : string , patch : Partial < { weight : number } > ) => {
81- setGraph ( g => ( {
132+ pushGraph ( g => ( {
82133 ...g ,
83134 edges : g . edges . map ( e => e . from === from && e . to === to ? { ...e , ...patch } : e ) ,
84135 } ) )
85- } , [ ] )
136+ } , [ pushGraph ] )
86137 const deleteEdge = useCallback ( ( from : string , to : string ) => {
87- setGraph ( g => ( { ...g , edges : g . edges . filter ( e => ! ( e . from === from && e . to === to ) ) } ) )
138+ pushGraph ( g => ( { ...g , edges : g . edges . filter ( e => ! ( e . from === from && e . to === to ) ) } ) )
88139 setSelection ( null )
89- } , [ ] )
140+ } , [ pushGraph ] )
90141 const [ singlePoly , setSinglePoly ] = useUrlState ( 'sp' , { encode : ( v ) => v ? undefined : '0' , decode : ( s ) => s !== '0' } )
91142 const [ showRing , setShowRing ] = useUrlState ( 'ring' , boolParam )
92143 const [ showNodes , setShowNodes ] = useUrlState ( 'nodes' , intParam ( defaultNodes ) )
@@ -114,14 +165,16 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
114165 approachDown : { label : 'Decrease approach' , group : 'Config' , defaultBindings : [ 'shift+a' ] , handler : ( ) => setNodeApproach ( Math . max ( 0 , nodeApproach - 0.1 ) ) } ,
115166 widthUp : { label : 'Increase width scale' , group : 'Config' , defaultBindings : [ 'w' ] , handler : ( ) => setWidthScale ( Math . min ( 3 , widthScale + 0.1 ) ) } ,
116167 widthDown : { label : 'Decrease width scale' , group : 'Config' , defaultBindings : [ 'shift+w' ] , handler : ( ) => setWidthScale ( Math . max ( 0 , widthScale - 0.1 ) ) } ,
117- exportScene : { label : 'Export scene (JSON)' , group : 'File' , defaultBindings : [ 'mod +shift+e' ] , handler : ( ) => exportScene ( ) } ,
118- importScene : { label : 'Import scene (JSON)' , group : 'File' , defaultBindings : [ 'mod +i' ] , handler : ( ) => fileInputRef . current ?. click ( ) } ,
168+ exportScene : { label : 'Export scene (JSON)' , group : 'File' , defaultBindings : [ 'cmd+shift+e' , 'ctrl +shift+e'] , handler : ( ) => exportScene ( ) } ,
169+ importScene : { label : 'Import scene (JSON)' , group : 'File' , defaultBindings : [ 'cmd+i' , 'ctrl +i'] , handler : ( ) => fileInputRef . current ?. click ( ) } ,
119170 toggleEdit : { label : 'Toggle edit mode' , group : 'Edit' , defaultBindings : [ 'e' ] , handler : ( ) => { setEditMode ( m => { const v = ! m ; sessionStorage . setItem ( 'geo-sankey-edit' , v ? '1' : '' ) ; return v } ) ; setSelection ( null ) ; setEdgeSource ( null ) } } ,
120- deleteSelected : { label : 'Delete selected' , group : 'Edit' , defaultBindings : [ 'Backspace' ] , handler : ( ) => {
171+ deleteSelected : { label : 'Delete selected' , group : 'Edit' , defaultBindings : [ 'Backspace' , 'Delete' ] , handler : ( ) => {
121172 if ( ! selection ) return
122173 if ( selection . type === 'node' ) deleteNode ( selection . id )
123174 else deleteEdge ( selection . from , selection . to )
124175 } } ,
176+ undo : { label : 'Undo' , group : 'Edit' , defaultBindings : [ 'cmd+z' , 'ctrl+z' ] , handler : undo } ,
177+ redo : { label : 'Redo' , group : 'Edit' , defaultBindings : [ 'cmd+shift+z' , 'ctrl+shift+z' ] , handler : redo } ,
125178 } )
126179
127180 const exportScene = useCallback ( ( ) => {
@@ -239,29 +292,32 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
239292 }
240293 } , [ ] )
241294
242- // Edit mode: click handler
295+ // Edit mode: click = select node or deselect, double-click = add node
243296 const onMapClick = useCallback ( ( e : any ) => {
244297 if ( ! editMode ) return
245- // Check if clicked on a node
246298 const nodeFeatures = e . features ?. filter ( ( f : any ) => f . layer ?. id === 'node-circles' )
247299 if ( nodeFeatures ?. length ) {
248300 const nodeId = nodeFeatures [ 0 ] . properties . id
249301 if ( edgeSource ) {
250- // Complete edge creation
251302 addEdge ( edgeSource , nodeId )
252303 setEdgeSource ( null )
253304 } else {
254305 setSelection ( { type : 'node' , id : nodeId } )
255306 }
256307 return
257308 }
258- // Clicked on empty map
309+ // Clicked empty map — deselect
259310 if ( edgeSource ) {
260- setEdgeSource ( null ) // cancel edge creation
261- } else {
262- addNode ( [ e . lngLat . lat , e . lngLat . lng ] )
311+ setEdgeSource ( null )
263312 }
264- } , [ editMode , edgeSource , addNode , addEdge ] )
313+ setSelection ( null )
314+ } , [ editMode , edgeSource , addEdge , setSelection , setEdgeSource ] )
315+
316+ const onMapDblClick = useCallback ( ( e : any ) => {
317+ if ( ! editMode ) return
318+ e . preventDefault ( )
319+ addNode ( [ e . lngLat . lat , e . lngLat . lng ] )
320+ } , [ editMode , addNode ] )
265321
266322 // Edit mode: drag with document-level listeners for reliability
267323 const mapRef = useRef < any > ( null )
@@ -273,16 +329,38 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
273329 }
274330 } , [ editMode ] )
275331
332+ const graphRef = useRef ( graph )
333+ graphRef . current = graph
334+
335+ // Test hook: expose current graph + map ref + action callbacks on window for e2e tests.
336+ useEffect ( ( ) => {
337+ ; ( window as any ) . __geoSankey = {
338+ graph,
339+ mapRef,
340+ dispatch,
341+ undo,
342+ redo,
343+ pastLen : gs . past . length ,
344+ futureLen : gs . future . length ,
345+ }
346+ } , [ graph , gs . past . length , gs . future . length , undo , redo ] )
347+
276348 useEffect ( ( ) => {
277349 if ( ! dragging || ! mapRef . current ) return
278350 const map = mapRef . current . getMap ( )
279351 const canvas = map . getCanvas ( )
280352 canvas . style . cursor = 'grabbing'
353+ // Snapshot pre-drag state ONCE for a single history entry
354+ const preDrag = graphRef . current
355+ let moved = false
281356 const onMove = ( ev : MouseEvent ) => {
282357 const rect = canvas . getBoundingClientRect ( )
283- const lng = map . unproject ( [ ev . clientX - rect . left , ev . clientY - rect . top ] ) . lng
284- const lat = map . unproject ( [ ev . clientX - rect . left , ev . clientY - rect . top ] ) . lat
285- updateNode ( dragging , { pos : [ lat , lng ] } )
358+ const { lng, lat } = map . unproject ( [ ev . clientX - rect . left , ev . clientY - rect . top ] )
359+ if ( ! moved ) { pushHistory ( preDrag ) ; moved = true }
360+ setGraph ( g => ( {
361+ ...g ,
362+ nodes : g . nodes . map ( n => n . id === dragging ? { ...n , pos : [ lat , lng ] } : n ) ,
363+ } ) )
286364 }
287365 const onUp = ( ) => {
288366 setDragging ( null )
@@ -295,7 +373,7 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
295373 document . removeEventListener ( 'mouseup' , onUp )
296374 canvas . style . cursor = ''
297375 }
298- } , [ dragging , updateNode ] )
376+ } , [ dragging , setGraph , pushHistory ] )
299377
300378 const { theme } = useTheme ( )
301379
@@ -345,6 +423,7 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
345423 onMove = { dragging ? undefined : onMove }
346424 onMouseMove = { dragging ? undefined : onHover }
347425 onMouseDown = { editMode ? onNodeDragStart : undefined }
426+ onDblClick = { editMode ? onMapDblClick : undefined }
348427 onClick = { editMode ? onMapClick : undefined }
349428 onMouseLeave = { ( ) => setTooltip ( null ) }
350429 interactiveLayerIds = { [
@@ -360,10 +439,22 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
360439 < Source id = "nodes" type = "geojson" data = { nodePoints } >
361440 < Layer id = "node-circles" type = "circle"
362441 paint = { {
363- 'circle-radius' : [ 'coalesce' , [ 'get' , 'radius' ] , 5 ] ,
364- 'circle-color' : [ 'coalesce' , [ 'get' , 'color' ] , '#fff' ] ,
365- 'circle-stroke-color' : '#000' ,
366- 'circle-stroke-width' : 1.5 ,
442+ 'circle-radius' : [ 'case' ,
443+ [ '==' , [ 'get' , 'id' ] , selection ?. type === 'node' ? selection . id : '' ] , 8 ,
444+ [ 'coalesce' , [ 'get' , 'radius' ] , 5 ] ,
445+ ] ,
446+ 'circle-color' : [ 'case' ,
447+ [ '==' , [ 'get' , 'id' ] , selection ?. type === 'node' ? selection . id : '' ] , '#14B8A6' ,
448+ [ 'coalesce' , [ 'get' , 'color' ] , '#fff' ] ,
449+ ] ,
450+ 'circle-stroke-color' : [ 'case' ,
451+ [ '==' , [ 'get' , 'id' ] , selection ?. type === 'node' ? selection . id : '' ] , '#fff' ,
452+ '#000' ,
453+ ] ,
454+ 'circle-stroke-width' : [ 'case' ,
455+ [ '==' , [ 'get' , 'id' ] , selection ?. type === 'node' ? selection . id : '' ] , 2.5 ,
456+ 1.5 ,
457+ ] ,
367458 } } />
368459 < Layer id = "node-labels" type = "symbol"
369460 layout = { {
@@ -413,41 +504,42 @@ export default function FlowMapView({ graph: initialGraph, title, description, c
413504 < div style = { { position : 'absolute' , left : tooltip . x + 10 , top : tooltip . y - 10 , background : 'rgba(0,0,0,0.85)' , color : '#fff' , padding : '4px 8px' , borderRadius : 4 , fontSize : 11 , whiteSpace : 'pre' , pointerEvents : 'none' , zIndex : 10 } } > { tooltip . text } </ div >
414505 ) }
415506 { editMode && selection && ( ( ) => {
416- const panelStyle : React . CSSProperties = {
417- position : 'absolute' , top : 8 , right : 8 , background : 'var(--bg-surface, #1e1e2e)' , color : 'var(--fg, #cdd6f4)' ,
418- border : '1px solid var(--border, #45475a)' , borderRadius : 8 , padding : '12px 16px' , fontSize : 13 , zIndex : 20 , minWidth : 200 ,
419- }
420- const inputStyle : React . CSSProperties = { background : 'var(--bg, #11111b)' , color : 'var(--fg, #cdd6f4)' , border : '1px solid var(--border, #45475a)' , borderRadius : 4 , padding : '2px 6px' , width : '100%' , fontSize : 12 }
421- const row = ( label : string , content : React . ReactNode ) => (
422- < div key = { label } style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 4 } } >
423- < span style = { { fontSize : 11 , minWidth : 50 , opacity : 0.7 } } > { label } </ span >
424- { content }
425- </ div >
426- )
427507 if ( selection . type === 'node' ) {
428508 const node = graph . nodes . find ( n => n . id === selection . id )
429509 if ( ! node ) return null
430510 return (
431- < div style = { panelStyle } >
432- < div style = { { fontWeight : 600 , marginBottom : 8 } } > Node: { node . id } </ div >
433- { row ( 'Label' , < input style = { inputStyle } value = { node . label ?? '' } onChange = { e => updateNode ( node . id , { label : e . target . value || undefined } as any ) } /> ) }
434- { row ( 'Bearing' , < BearingDial value = { round ( node . bearing ) } onChange = { b => updateNode ( node . id , { bearing : b } ) } /> ) }
435- { row ( 'Lat' , < input style = { inputStyle } type = "number" step = "0.0001" value = { node . pos [ 0 ] } onChange = { e => updateNode ( node . id , { pos : [ parseFloat ( e . target . value ) , node . pos [ 1 ] ] } ) } /> ) }
436- { row ( 'Lon' , < input style = { inputStyle } type = "number" step = "0.0001" value = { node . pos [ 1 ] } onChange = { e => updateNode ( node . id , { pos : [ node . pos [ 0 ] , parseFloat ( e . target . value ) ] } ) } /> ) }
437- < div style = { { display : 'flex' , gap : 8 , marginTop : 8 } } >
438- < button onClick = { ( ) => { setEdgeSource ( node . id ) ; setSelection ( null ) } } style = { { fontSize : 11 } } > Add edge from</ button >
439- < button onClick = { ( ) => deleteNode ( node . id ) } style = { { fontSize : 11 , color : '#ef4444' } } > Delete</ button >
440- </ div >
441- </ div >
511+ < NodeOverlay
512+ key = { node . id }
513+ nodeId = { node . id }
514+ label = { node . label ?? '' }
515+ bearing = { round ( node . bearing ?? 90 ) }
516+ pos = { node . pos }
517+ mapRef = { mapRef }
518+ onUpdateBearing = { b => updateNode ( node . id , { bearing : b } ) }
519+ onBeginRotate = { ( ) => pushHistory ( graphRef . current ) }
520+ onRotateTransient = { b => setGraph ( g => ( { ...g , nodes : g . nodes . map ( n => n . id === node . id ? { ...n , bearing : b } : n ) } ) ) }
521+ onUpdateLabel = { l => updateNode ( node . id , { label : l || undefined } as any ) }
522+ onUpdatePos = { p => updateNode ( node . id , { pos : p } ) }
523+ onAddEdge = { ( ) => { setEdgeSource ( node . id ) ; setSelection ( null ) } }
524+ onDelete = { ( ) => deleteNode ( node . id ) }
525+ />
442526 )
443527 }
444528 if ( selection . type === 'edge' ) {
445529 const edge = graph . edges . find ( e => e . from === selection . from && e . to === selection . to )
446530 if ( ! edge ) return null
531+ const panelStyle : React . CSSProperties = {
532+ position : 'absolute' , top : 8 , right : 8 , background : 'var(--bg-surface, #1e1e2e)' , color : 'var(--fg, #cdd6f4)' ,
533+ border : '1px solid var(--border, #45475a)' , borderRadius : 8 , padding : '12px 16px' , fontSize : 13 , zIndex : 20 , minWidth : 200 ,
534+ }
535+ const inputStyle : React . CSSProperties = { background : 'var(--bg, #11111b)' , color : 'var(--fg, #cdd6f4)' , border : '1px solid var(--border, #45475a)' , borderRadius : 4 , padding : '2px 6px' , width : '100%' , fontSize : 12 }
447536 return (
448537 < div style = { panelStyle } >
449538 < div style = { { fontWeight : 600 , marginBottom : 8 } } > Edge: { edge . from } → { edge . to } </ div >
450- { row ( 'Weight' , < input style = { inputStyle } type = "number" value = { edge . weight } onChange = { e => updateEdge ( edge . from , edge . to , { weight : parseFloat ( e . target . value ) || 1 } ) } /> ) }
539+ < div style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 4 } } >
540+ < span style = { { fontSize : 11 , minWidth : 50 , opacity : 0.7 } } > Weight</ span >
541+ < input style = { inputStyle } type = "number" value = { edge . weight } onChange = { e => updateEdge ( edge . from , edge . to , { weight : parseFloat ( e . target . value ) || 1 } ) } />
542+ </ div >
451543 < div style = { { marginTop : 8 } } >
452544 < button onClick = { ( ) => deleteEdge ( edge . from , edge . to ) } style = { { fontSize : 11 , color : '#ef4444' } } > Delete</ button >
453545 </ div >
0 commit comments