neogestify-ui-components 1.2.21 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +393 -2
  2. package/dist/components/VenueMapEditor/index.d.mts +202 -0
  3. package/dist/components/VenueMapEditor/index.d.ts +202 -0
  4. package/dist/components/VenueMapEditor/index.js +2684 -0
  5. package/dist/components/VenueMapEditor/index.js.map +1 -0
  6. package/dist/components/VenueMapEditor/index.mjs +2676 -0
  7. package/dist/components/VenueMapEditor/index.mjs.map +1 -0
  8. package/dist/components/alerts/index.js.map +1 -1
  9. package/dist/components/alerts/index.mjs.map +1 -1
  10. package/dist/components/html/index.d.mts +2 -0
  11. package/dist/components/html/index.d.ts +2 -0
  12. package/dist/components/html/index.js +24 -58
  13. package/dist/components/html/index.js.map +1 -1
  14. package/dist/components/html/index.mjs +24 -58
  15. package/dist/components/html/index.mjs.map +1 -1
  16. package/dist/components/icons/index.d.mts +18 -2
  17. package/dist/components/icons/index.d.ts +18 -2
  18. package/dist/components/icons/index.js +97 -11
  19. package/dist/components/icons/index.js.map +1 -1
  20. package/dist/components/icons/index.mjs +82 -12
  21. package/dist/components/icons/index.mjs.map +1 -1
  22. package/dist/context/theme/index.js.map +1 -1
  23. package/dist/context/theme/index.mjs.map +1 -1
  24. package/dist/index.d.mts +2 -1
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +2734 -69
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2713 -71
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +9 -4
  31. package/src/components/VenueMapEditor/VenueMapEditor.tsx +851 -0
  32. package/src/components/VenueMapEditor/VenueMapViewer.tsx +13 -0
  33. package/src/components/VenueMapEditor/components/Artboard.tsx +405 -0
  34. package/src/components/VenueMapEditor/components/EditorCanvas.tsx +472 -0
  35. package/src/components/VenueMapEditor/components/ElementNode.tsx +357 -0
  36. package/src/components/VenueMapEditor/components/FloorTabs.tsx +137 -0
  37. package/src/components/VenueMapEditor/components/GridOverlay.tsx +67 -0
  38. package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +198 -0
  39. package/src/components/VenueMapEditor/components/Toolbar.tsx +254 -0
  40. package/src/components/VenueMapEditor/components/WallLayer.tsx +117 -0
  41. package/src/components/VenueMapEditor/hooks/useDrag.ts +79 -0
  42. package/src/components/VenueMapEditor/hooks/useHistory.ts +74 -0
  43. package/src/components/VenueMapEditor/hooks/usePanZoom.ts +114 -0
  44. package/src/components/VenueMapEditor/hooks/useSelection.ts +42 -0
  45. package/src/components/VenueMapEditor/index.ts +34 -0
  46. package/src/components/VenueMapEditor/types.ts +173 -0
  47. package/src/components/VenueMapEditor/utils/idGen.ts +2 -0
  48. package/src/components/VenueMapEditor/utils/snapUtils.ts +38 -0
  49. package/src/components/VenueMapEditor/utils/wallGeometry.ts +83 -0
  50. package/src/components/html/Input.tsx +48 -80
  51. package/src/components/icons/icons.tsx +153 -14
  52. package/src/index.ts +1 -0
@@ -0,0 +1,198 @@
1
+ import { useCallback } from 'react';
2
+ import type { ChangeEvent } from 'react';
3
+ import type { MapElement, ElementTypeDef } from '../types';
4
+
5
+ interface PropertiesPanelProps {
6
+ elements: MapElement[];
7
+ typeDefs: Map<string, ElementTypeDef>;
8
+ onChangeLabel: (id: string, label: string) => void;
9
+ onChangeGeometry: (id: string, x: number, y: number, w: number, h: number, r: number) => void;
10
+ onDelete: (ids: string[]) => void;
11
+ onDuplicate: (ids: string[]) => void;
12
+ }
13
+
14
+ // ─── Small numeric field ──────────────────────────────────────────────────────
15
+
16
+ interface NumFieldProps {
17
+ label: string;
18
+ value: number;
19
+ onChange: (v: number) => void;
20
+ step?: number;
21
+ }
22
+
23
+ function NumField({ label, value, onChange, step = 1 }: NumFieldProps) {
24
+ return (
25
+ <label className="flex flex-col gap-0.5">
26
+ <span className="text-[10px] font-medium text-slate-400 uppercase tracking-wide">
27
+ {label}
28
+ </span>
29
+ <input
30
+ type="number"
31
+ value={Math.round(value)}
32
+ step={step}
33
+ onChange={e => onChange(Number(e.target.value))}
34
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white"
35
+ />
36
+ </label>
37
+ );
38
+ }
39
+
40
+ // ─── Component ────────────────────────────────────────────────────────────────
41
+
42
+ export function PropertiesPanel({
43
+ elements,
44
+ typeDefs,
45
+ onChangeLabel,
46
+ onChangeGeometry,
47
+ onDelete,
48
+ onDuplicate,
49
+ }: PropertiesPanelProps) {
50
+ const count = elements.length;
51
+
52
+ const handleLabelChange = useCallback(
53
+ (id: string, e: ChangeEvent<HTMLInputElement>) => onChangeLabel(id, e.target.value),
54
+ [onChangeLabel],
55
+ );
56
+
57
+ if (count === 0) return null;
58
+
59
+ // ── Multi-selection ───────────────────────────────────────────────────────
60
+ if (count > 1) {
61
+ const ids = elements.map(el => el.id);
62
+ return (
63
+ <div className="w-56 shrink-0 border-l border-slate-200 bg-white flex flex-col">
64
+ <div className="px-3 py-2 border-b border-slate-100 text-xs font-semibold text-slate-500 uppercase tracking-wide">
65
+ Propiedades
66
+ </div>
67
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 p-4 text-center">
68
+ <span className="text-2xl font-bold text-slate-700">{count}</span>
69
+ <span className="text-xs text-slate-400">elementos seleccionados</span>
70
+ </div>
71
+ <div className="px-3 pb-3 flex flex-col gap-2">
72
+ <button
73
+ onClick={() => onDuplicate(ids)}
74
+ className="w-full text-xs px-3 py-1.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50 transition-colors"
75
+ >
76
+ Duplicar selección
77
+ </button>
78
+ <button
79
+ onClick={() => onDelete(ids)}
80
+ className="w-full text-xs px-3 py-1.5 rounded bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 transition-colors"
81
+ >
82
+ Eliminar selección
83
+ </button>
84
+ </div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ // ── Single selection ──────────────────────────────────────────────────────
90
+ const el = elements[0];
91
+ const typeDef = typeDefs.get(el.type);
92
+
93
+ const setGeom = (patch: Partial<{ x: number; y: number; w: number; h: number; r: number }>) => {
94
+ onChangeGeometry(
95
+ el.id,
96
+ patch.x ?? el.x,
97
+ patch.y ?? el.y,
98
+ patch.w ?? el.width,
99
+ patch.h ?? el.height,
100
+ patch.r ?? el.rotation,
101
+ );
102
+ };
103
+
104
+ return (
105
+ <div className="w-56 shrink-0 border-l border-slate-200 bg-white flex flex-col overflow-y-auto">
106
+ {/* Header */}
107
+ <div className="px-3 py-2 border-b border-slate-100 text-xs font-semibold text-slate-500 uppercase tracking-wide">
108
+ Propiedades
109
+ </div>
110
+
111
+ <div className="flex-1 flex flex-col gap-4 p-3">
112
+ {/* Type badge */}
113
+ {typeDef && (
114
+ <div className="flex items-center gap-2">
115
+ <span
116
+ className="w-3.5 h-3.5 rounded-sm shrink-0 border"
117
+ style={{ background: typeDef.color, borderColor: typeDef.strokeColor }}
118
+ />
119
+ <span className="text-xs font-medium text-slate-700 truncate">{typeDef.label}</span>
120
+ </div>
121
+ )}
122
+
123
+ {/* Label */}
124
+ <label className="flex flex-col gap-0.5">
125
+ <span className="text-[10px] font-medium text-slate-400 uppercase tracking-wide">
126
+ Etiqueta
127
+ </span>
128
+ <input
129
+ type="text"
130
+ value={el.label ?? ''}
131
+ placeholder={typeDef?.label ?? ''}
132
+ onChange={e => handleLabelChange(el.id, e)}
133
+ className="w-full border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white"
134
+ />
135
+ </label>
136
+
137
+ {/* Position */}
138
+ <div className="grid grid-cols-2 gap-2">
139
+ <NumField label="X" value={el.x} onChange={v => setGeom({ x: v })} />
140
+ <NumField label="Y" value={el.y} onChange={v => setGeom({ y: v })} />
141
+ </div>
142
+
143
+ {/* Size */}
144
+ <div className="grid grid-cols-2 gap-2">
145
+ <NumField label="Ancho" value={el.width} onChange={v => setGeom({ w: Math.max(1, v) })} />
146
+ <NumField label="Alto" value={el.height} onChange={v => setGeom({ h: Math.max(1, v) })} />
147
+ </div>
148
+
149
+ {/* Rotation */}
150
+ <div className="grid grid-cols-2 gap-2">
151
+ <NumField label="Rotación °" value={el.rotation} onChange={v => setGeom({ r: v })} step={15} />
152
+ <label className="flex flex-col gap-0.5">
153
+ <span className="text-[10px] font-medium text-slate-400 uppercase tracking-wide">
154
+ &nbsp;
155
+ </span>
156
+ <button
157
+ onClick={() => setGeom({ r: 0 })}
158
+ className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-500 hover:bg-slate-50 transition-colors"
159
+ >
160
+ Resetear
161
+ </button>
162
+ </label>
163
+ </div>
164
+
165
+ {/* Metadata (read-only key/value list) */}
166
+ {el.metadata && Object.keys(el.metadata).length > 0 && (
167
+ <div className="flex flex-col gap-1">
168
+ <span className="text-[10px] font-medium text-slate-400 uppercase tracking-wide">
169
+ Metadata
170
+ </span>
171
+ {Object.entries(el.metadata).map(([k, v]) => (
172
+ <div key={k} className="flex justify-between text-xs text-slate-500">
173
+ <span className="truncate">{k}</span>
174
+ <span className="ml-2 text-slate-400 truncate">{String(v)}</span>
175
+ </div>
176
+ ))}
177
+ </div>
178
+ )}
179
+ </div>
180
+
181
+ {/* Actions */}
182
+ <div className="px-3 pb-3 flex flex-col gap-2 border-t border-slate-100 pt-3">
183
+ <button
184
+ onClick={() => onDuplicate([el.id])}
185
+ className="w-full text-xs px-3 py-1.5 rounded border border-slate-200 text-slate-600 hover:bg-slate-50 transition-colors"
186
+ >
187
+ Duplicar (Ctrl+D)
188
+ </button>
189
+ <button
190
+ onClick={() => onDelete([el.id])}
191
+ className="w-full text-xs px-3 py-1.5 rounded bg-red-50 border border-red-200 text-red-600 hover:bg-red-100 transition-colors"
192
+ >
193
+ Eliminar
194
+ </button>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
@@ -0,0 +1,254 @@
1
+ import type { ReactNode } from 'react';
2
+ import {
3
+ IconCursor, IconGrid, IconHand, IconReset, IconZoomIn, IconZoomOut,
4
+ IconUndo, IconRedo, IconPlace, IconErase, IconWall,
5
+ IconDownload, IconUpload, IconLayers,
6
+ } from '../../icons';
7
+ import type { ToolMode, ElementTypeDef, AreaShape } from '../types';
8
+
9
+ // ─── ToolButton ───────────────────────────────────────────────────────────────
10
+
11
+ interface ToolButtonProps {
12
+ active?: boolean;
13
+ disabled?: boolean;
14
+ title: string;
15
+ onClick: () => void;
16
+ children: ReactNode;
17
+ }
18
+
19
+ function ToolButton({ active, disabled, title, onClick, children }: ToolButtonProps) {
20
+ return (
21
+ <button
22
+ title={title}
23
+ onClick={onClick}
24
+ disabled={disabled}
25
+ className={[
26
+ 'flex items-center justify-center w-8 h-8 rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed',
27
+ active
28
+ ? 'bg-blue-100 text-blue-700 ring-1 ring-blue-400'
29
+ : 'text-slate-600 hover:bg-slate-100 hover:text-slate-800',
30
+ ].join(' ')}
31
+ >
32
+ {children}
33
+ </button>
34
+ );
35
+ }
36
+
37
+ function Sep() {
38
+ return <div className="w-px h-6 bg-slate-200 mx-1" />;
39
+ }
40
+
41
+ // ─── Props ────────────────────────────────────────────────────────────────────
42
+
43
+ export interface PaletteGroup {
44
+ id: string;
45
+ name: string;
46
+ /** True for the built-in domain config group; false for imported library groups. */
47
+ isBase?: boolean;
48
+ types: ElementTypeDef[];
49
+ }
50
+
51
+ interface ToolbarProps {
52
+ tool: ToolMode;
53
+ onToolChange: (tool: ToolMode) => void;
54
+ showGrid: boolean;
55
+ onToggleGrid: () => void;
56
+ zoom: number;
57
+ onZoomIn: () => void;
58
+ onZoomOut: () => void;
59
+ onResetView: () => void;
60
+ canUndo: boolean;
61
+ canRedo: boolean;
62
+ onUndo: () => void;
63
+ onRedo: () => void;
64
+ /** All element groups: base config group + any imported library groups. */
65
+ paletteGroups: PaletteGroup[];
66
+ activePlaceTypeId: string | null;
67
+ onActivePlaceTypeChange: (id: string) => void;
68
+ areaShape?: AreaShape;
69
+ onToggleAreaShape?: () => void;
70
+ onExportMap?: () => void;
71
+ onImportMap?: () => void;
72
+ onLoadLibrary?: () => void;
73
+ onRemoveLibraryGroup?: (groupId: string) => void;
74
+ }
75
+
76
+ // ─── TypeChip ─────────────────────────────────────────────────────────────────
77
+
78
+ interface TypeChipProps {
79
+ typeDef: ElementTypeDef;
80
+ active: boolean;
81
+ onClick: () => void;
82
+ }
83
+
84
+ function TypeChip({ typeDef, active, onClick }: TypeChipProps) {
85
+ return (
86
+ <button
87
+ title={typeDef.label}
88
+ onClick={onClick}
89
+ className={[
90
+ 'flex items-center gap-1.5 px-2 py-1 rounded border text-xs whitespace-nowrap transition-colors',
91
+ active
92
+ ? 'border-blue-400 bg-blue-50 text-blue-700 font-medium'
93
+ : 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50',
94
+ ].join(' ')}
95
+ >
96
+ <span
97
+ className="w-2.5 h-2.5 rounded-sm shrink-0"
98
+ style={{ background: typeDef.color, border: `1px solid ${typeDef.strokeColor}` }}
99
+ />
100
+ {typeDef.label}
101
+ </button>
102
+ );
103
+ }
104
+
105
+ // ─── Toolbar ─────────────────────────────────────────────────────────────────
106
+
107
+ export function Toolbar({
108
+ tool,
109
+ onToolChange,
110
+ showGrid,
111
+ onToggleGrid,
112
+ zoom,
113
+ onZoomIn,
114
+ onZoomOut,
115
+ onResetView,
116
+ canUndo,
117
+ canRedo,
118
+ onUndo,
119
+ onRedo,
120
+ paletteGroups,
121
+ activePlaceTypeId,
122
+ onActivePlaceTypeChange,
123
+ areaShape,
124
+ onToggleAreaShape,
125
+ onExportMap,
126
+ onImportMap,
127
+ onLoadLibrary,
128
+ onRemoveLibraryGroup,
129
+ }: ToolbarProps) {
130
+ return (
131
+ <div className="flex flex-col bg-white border-b border-slate-200 shadow-sm shrink-0">
132
+ {/* ── Main row ── */}
133
+ <div className="flex items-center gap-0.5 px-2 py-1.5">
134
+ {/* Selection tools */}
135
+ <ToolButton title="Seleccionar (V)" active={tool === 'SELECT'} onClick={() => onToolChange('SELECT')}>
136
+ <IconCursor className="w-4 h-4" />
137
+ </ToolButton>
138
+ <ToolButton title="Desplazar (H)" active={tool === 'PAN'} onClick={() => onToolChange('PAN')}>
139
+ <IconHand className="w-4 h-4" />
140
+ </ToolButton>
141
+ <ToolButton title="Dibujar pared (W)" active={tool === 'WALL'} onClick={() => onToolChange('WALL')}>
142
+ <IconWall className="w-4 h-4" />
143
+ </ToolButton>
144
+ <ToolButton title="Colocar elemento (P)" active={tool === 'PLACE'} onClick={() => onToolChange('PLACE')}>
145
+ <IconPlace className="w-4 h-4" />
146
+ </ToolButton>
147
+ <ToolButton title="Borrar (E)" active={tool === 'ERASE'} onClick={() => onToolChange('ERASE')}>
148
+ <IconErase className="w-4 h-4" />
149
+ </ToolButton>
150
+
151
+ <Sep />
152
+
153
+ {/* History */}
154
+ <ToolButton title="Deshacer (Ctrl+Z)" disabled={!canUndo} onClick={onUndo}>
155
+ <IconUndo className="w-4 h-4" />
156
+ </ToolButton>
157
+ <ToolButton title="Rehacer (Ctrl+Y)" disabled={!canRedo} onClick={onRedo}>
158
+ <IconRedo className="w-4 h-4" />
159
+ </ToolButton>
160
+
161
+ <Sep />
162
+
163
+ {/* View */}
164
+ <ToolButton
165
+ title={showGrid ? 'Ocultar cuadrícula' : 'Mostrar cuadrícula'}
166
+ active={showGrid}
167
+ onClick={onToggleGrid}
168
+ >
169
+ <IconGrid className="w-4 h-4" />
170
+ </ToolButton>
171
+
172
+ <Sep />
173
+
174
+ {/* Zoom */}
175
+ <ToolButton title="Acercar (+)" onClick={onZoomIn}>
176
+ <IconZoomIn className="w-4 h-4" />
177
+ </ToolButton>
178
+ <span className="text-xs text-slate-500 w-10 text-center tabular-nums select-none">
179
+ {Math.round(zoom * 100)}%
180
+ </span>
181
+ <ToolButton title="Alejar (-)" onClick={onZoomOut}>
182
+ <IconZoomOut className="w-4 h-4" />
183
+ </ToolButton>
184
+ <ToolButton title="Restablecer vista" onClick={onResetView}>
185
+ <IconReset className="w-4 h-4" />
186
+ </ToolButton>
187
+
188
+ <Sep />
189
+
190
+ {/* Map export / import */}
191
+ <ToolButton title="Exportar mapa JSON" onClick={() => onExportMap?.()}>
192
+ <IconDownload className="w-4 h-4" />
193
+ </ToolButton>
194
+ <ToolButton title="Importar mapa JSON" onClick={() => onImportMap?.()}>
195
+ <IconUpload className="w-4 h-4" />
196
+ </ToolButton>
197
+
198
+ {/* Element library import */}
199
+ <ToolButton title="Cargar librería de elementos (.json)" onClick={() => onLoadLibrary?.()}>
200
+ <IconLayers className="w-4 h-4" />
201
+ </ToolButton>
202
+
203
+ {areaShape !== undefined && (
204
+ <>
205
+ <Sep />
206
+ <ToolButton title={areaShape === 'polygon' ? 'Cambiar a rectángulo' : 'Cambiar a polígono'} onClick={() => onToggleAreaShape?.()}>
207
+ <span className="text-xs font-medium">{areaShape === 'polygon' ? 'Poly' : 'Rect'}</span>
208
+ </ToolButton>
209
+ </>
210
+ )}
211
+ </div>
212
+
213
+ {/* ── Element palette (only when PLACE is active) ── */}
214
+ {tool === 'PLACE' && (
215
+ <div className="flex items-stretch gap-0 border-t border-slate-100 bg-slate-50 overflow-x-auto">
216
+ {paletteGroups.map((group, gi) => (
217
+ <div key={group.id} className="flex items-center shrink-0">
218
+ {/* Group divider (not before the first group) */}
219
+ {gi > 0 && (
220
+ <div className="w-px self-stretch bg-slate-200 mx-1" />
221
+ )}
222
+ {/* Group label + optional remove button */}
223
+ <div className="flex items-center gap-0.5 px-1.5 shrink-0">
224
+ <span className="text-[10px] text-slate-400 font-medium whitespace-nowrap select-none">
225
+ {group.name}
226
+ </span>
227
+ {!group.isBase && onRemoveLibraryGroup && (
228
+ <button
229
+ title={`Eliminar grupo "${group.name}"`}
230
+ onClick={() => onRemoveLibraryGroup(group.id)}
231
+ className="text-slate-300 hover:text-red-400 leading-none text-xs ml-0.5 transition-colors"
232
+ >
233
+ ×
234
+ </button>
235
+ )}
236
+ </div>
237
+ {/* Chips */}
238
+ <div className="flex items-center gap-1 px-1 py-1.5">
239
+ {group.types.map(typeDef => (
240
+ <TypeChip
241
+ key={typeDef.id}
242
+ typeDef={typeDef}
243
+ active={activePlaceTypeId === typeDef.id}
244
+ onClick={() => onActivePlaceTypeChange(typeDef.id)}
245
+ />
246
+ ))}
247
+ </div>
248
+ </div>
249
+ ))}
250
+ </div>
251
+ )}
252
+ </div>
253
+ );
254
+ }
@@ -0,0 +1,117 @@
1
+ import type { WallNode, Wall, ToolMode } from '../types';
2
+ import { wallSegmentPath } from '../utils/wallGeometry';
3
+ import type { Vec2 } from '../utils/wallGeometry';
4
+
5
+ // ─── Props ────────────────────────────────────────────────────────────────────
6
+
7
+ interface WallLayerProps {
8
+ nodes: WallNode[];
9
+ walls: Wall[];
10
+ zoom: number;
11
+ tool: ToolMode;
12
+ onDeleteWall?: (id: string) => void;
13
+ }
14
+
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ function vecNorm(v: Vec2): Vec2 {
18
+ const len = Math.hypot(v.x, v.y);
19
+ return len < 1e-10 ? { x: 1, y: 0 } : { x: v.x / len, y: v.y / len };
20
+ }
21
+
22
+ // ─── Component ────────────────────────────────────────────────────────────────
23
+
24
+ export function WallLayer({ nodes, walls, zoom, tool, onDeleteWall }: WallLayerProps) {
25
+ if (walls.length === 0 && nodes.length === 0) return null;
26
+
27
+ // ── Build lookup maps ──────────────────────────────────────────────────────
28
+ const nodeMap = new Map<string, WallNode>(nodes.map(n => [n.id, n]));
29
+
30
+ // nodeId → list of walls that touch it
31
+ const nodeWalls = new Map<string, Wall[]>();
32
+ for (const n of nodes) nodeWalls.set(n.id, []);
33
+ for (const wall of walls) {
34
+ nodeWalls.get(wall.nodeAId)?.push(wall);
35
+ nodeWalls.get(wall.nodeBId)?.push(wall);
36
+ }
37
+
38
+ // ── Compute per-wall paths with miter ─────────────────────────────────────
39
+ const sw = 0.75 / zoom;
40
+ const isErase = tool === 'ERASE';
41
+
42
+ const items = walls.map(wall => {
43
+ const nodeA = nodeMap.get(wall.nodeAId);
44
+ const nodeB = nodeMap.get(wall.nodeBId);
45
+ if (!nodeA || !nodeB) return null;
46
+
47
+ // Adjacent-wall direction at nodeA (only when exactly 1 other wall present)
48
+ const wallsAtA = (nodeWalls.get(wall.nodeAId) ?? []).filter(w => w.id !== wall.id);
49
+ let adjDirAtA: Vec2 | null = null;
50
+ if (wallsAtA.length === 1) {
51
+ const w2 = wallsAtA[0];
52
+ const otherId = w2.nodeAId === wall.nodeAId ? w2.nodeBId : w2.nodeAId;
53
+ const other = nodeMap.get(otherId);
54
+ if (other) adjDirAtA = vecNorm({ x: other.x - nodeA.x, y: other.y - nodeA.y });
55
+ }
56
+
57
+ // Adjacent-wall direction at nodeB
58
+ const wallsAtB = (nodeWalls.get(wall.nodeBId) ?? []).filter(w => w.id !== wall.id);
59
+ let adjDirAtB: Vec2 | null = null;
60
+ if (wallsAtB.length === 1) {
61
+ const w2 = wallsAtB[0];
62
+ const otherId = w2.nodeAId === wall.nodeBId ? w2.nodeBId : w2.nodeAId;
63
+ const other = nodeMap.get(otherId);
64
+ if (other) adjDirAtB = vecNorm({ x: other.x - nodeB.x, y: other.y - nodeB.y });
65
+ }
66
+
67
+ const path = wallSegmentPath(
68
+ nodeA.x, nodeA.y, nodeB.x, nodeB.y,
69
+ wall.thickness, adjDirAtA, adjDirAtB,
70
+ );
71
+ return { wall, path };
72
+ }).filter((x): x is NonNullable<typeof x> => x !== null);
73
+
74
+ return (
75
+ <g>
76
+ {/* ── Wall fills ── */}
77
+ {items.map(({ wall, path }) => (
78
+ <g key={wall.id}>
79
+ {/* Visible fill */}
80
+ <path
81
+ d={path}
82
+ fill="#475569"
83
+ stroke="#1e293b"
84
+ strokeWidth={sw}
85
+ strokeLinejoin="miter"
86
+ style={{ pointerEvents: 'none' }}
87
+ />
88
+ {/* Wide invisible hit area for erasing */}
89
+ {isErase && (
90
+ <path
91
+ d={path}
92
+ fill="transparent"
93
+ stroke="transparent"
94
+ strokeWidth={Math.max(wall.thickness, 16) / zoom}
95
+ style={{ cursor: 'crosshair', pointerEvents: 'all' }}
96
+ onClick={() => onDeleteWall?.(wall.id)}
97
+ />
98
+ )}
99
+ </g>
100
+ ))}
101
+
102
+ {/* ── Node dots (visible in WALL mode to show the graph) ── */}
103
+ {tool === 'WALL' && nodes.map(node => (
104
+ <circle
105
+ key={node.id}
106
+ cx={node.x}
107
+ cy={node.y}
108
+ r={4 / zoom}
109
+ fill="#3b82f6"
110
+ stroke="white"
111
+ strokeWidth={1.5 / zoom}
112
+ style={{ pointerEvents: 'none' }}
113
+ />
114
+ ))}
115
+ </g>
116
+ );
117
+ }
@@ -0,0 +1,79 @@
1
+ import { useRef, useCallback } from 'react';
2
+ import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
3
+ import type { PanZoomState } from './usePanZoom';
4
+
5
+ /** A ref whose current value is always a live PanZoomState (never null). */
6
+ type PanZoomRef = { current: PanZoomState };
7
+
8
+ export interface DragCallbacks {
9
+ onDragStart?: (canvasX: number, canvasY: number) => void;
10
+ onDragMove: (dx: number, dy: number, canvasX: number, canvasY: number) => void;
11
+ onDragEnd?: (canvasX: number, canvasY: number) => void;
12
+ }
13
+
14
+ /**
15
+ * Generic SVG drag hook.
16
+ *
17
+ * Converts raw clientX/Y into canvas coordinates using the current pan/zoom,
18
+ * then fires `onDragMove` with the delta since the last frame.
19
+ *
20
+ * Uses window-level listeners so that the drag continues even when the cursor
21
+ * leaves the SVG element.
22
+ */
23
+ export function useDrag(
24
+ svgRef: RefObject<SVGSVGElement | null>,
25
+ panZoomRef: PanZoomRef,
26
+ callbacks: DragCallbacks,
27
+ ) {
28
+ // Keep callbacks in a ref so window listeners always call the latest version.
29
+ const cbRef = useRef(callbacks);
30
+ cbRef.current = callbacks;
31
+
32
+ const lastCanvas = useRef({ x: 0, y: 0 });
33
+
34
+ /** Convert a screen position (clientX, clientY) to canvas coordinates. */
35
+ const toCanvas = useCallback(
36
+ (clientX: number, clientY: number): { x: number; y: number } => {
37
+ const rect = svgRef.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
38
+ const { panX, panY, zoom } = panZoomRef.current;
39
+ return {
40
+ x: (clientX - rect.left - panX) / zoom,
41
+ y: (clientY - rect.top - panY) / zoom,
42
+ };
43
+ },
44
+ [svgRef, panZoomRef],
45
+ );
46
+
47
+ const handleMouseDown = useCallback(
48
+ (e: ReactMouseEvent) => {
49
+ if (e.button !== 0) return;
50
+ e.stopPropagation();
51
+ e.preventDefault();
52
+
53
+ const canvas = toCanvas(e.clientX, e.clientY);
54
+ lastCanvas.current = canvas;
55
+ cbRef.current.onDragStart?.(canvas.x, canvas.y);
56
+
57
+ const onMove = (ev: MouseEvent) => {
58
+ const c = toCanvas(ev.clientX, ev.clientY);
59
+ const dx = c.x - lastCanvas.current.x;
60
+ const dy = c.y - lastCanvas.current.y;
61
+ lastCanvas.current = c;
62
+ cbRef.current.onDragMove(dx, dy, c.x, c.y);
63
+ };
64
+
65
+ const onUp = (ev: MouseEvent) => {
66
+ const c = toCanvas(ev.clientX, ev.clientY);
67
+ cbRef.current.onDragEnd?.(c.x, c.y);
68
+ window.removeEventListener('mousemove', onMove);
69
+ window.removeEventListener('mouseup', onUp);
70
+ };
71
+
72
+ window.addEventListener('mousemove', onMove);
73
+ window.addEventListener('mouseup', onUp);
74
+ },
75
+ [toCanvas],
76
+ );
77
+
78
+ return { handleMouseDown };
79
+ }