Skip to content

Commit 38c6744

Browse files
ryan-williamsclaude
andcommitted
In-place node editing overlay + working undo/redo
- `NodeOverlay`: floating panel follows selected node, draggable rotation handle on the bearing axis, click-to-expand label/lat/lon/actions panel. - Highlight selected node (teal fill, larger radius) on the map. - Click empty map to deselect; double-click to add a new node. - `Backspace`/`Delete` delete selected node or edge. - Undo/redo via `useReducer` (pure, StrictMode-safe). A single drag of either a node position or bearing produces exactly one history entry via a `pushHistory`-on-start + transient-`set`-during-drag pattern. - Keyboard bindings: `cmd+z`/`ctrl+z` undo, `cmd+shift+z`/`ctrl+shift+z` redo, `cmd+i`/`ctrl+i` import, `cmd+shift+e`/`ctrl+shift+e` export. (`mod+` was nop'ing — use-kbd doesn't recognize `mod` as an alias.) - Expose `window.__geoSankey` (graph, mapRef, undo, redo, past/future lengths) so e2e tests can observe state. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6158603 commit 38c6744

2 files changed

Lines changed: 305 additions & 56 deletions

File tree

site/src/FlowMapView.tsx

Lines changed: 148 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
1+
import { useMemo, useCallback, useState, useRef, useEffect, useReducer } from 'react'
22
import MapGL, { Source, Layer } from 'react-map-gl/maplibre'
33
import { useUrlState } from 'use-prms'
44
import type { Param } from 'use-prms'
55
import { useActions } from 'use-kbd'
66
import { renderFlowGraph, renderFlowGraphSinglePoly, renderFlowGraphDebug, renderNodes } from 'geo-sankey'
77
import type { FlowGraph, FlowGraphOpts } from 'geo-sankey'
88
import BearingDial from './BearingDial'
9+
import NodeOverlay from './NodeOverlay'
910
import { useLLZ } from './llz'
1011
import { useTheme, MAP_STYLES } from './App'
1112
import 'maplibre-gl/dist/maplibre-gl.css'
@@ -38,8 +39,49 @@ export interface FlowMapViewProps {
3839

3940
type 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+
4179
export 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

Comments
 (0)