neogestify-ui-components 1.2.21 → 2.0.0

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 +352 -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,472 @@
1
+ import { useRef, useEffect, useState, useCallback } from 'react';
2
+ import type { MouseEvent as ReactMouseEvent } from 'react';
3
+ import type { Floor, WallNode, ElementTypeDef, ToolMode } from '../types';
4
+ import type { PanZoomState } from '../hooks/usePanZoom';
5
+ import { usePanZoom } from '../hooks/usePanZoom';
6
+ import { findNearestNode, snapPoint } from '../utils/snapUtils';
7
+ import { wallSegmentPath } from '../utils/wallGeometry';
8
+ import { GridOverlay } from './GridOverlay';
9
+ import { Artboard } from './Artboard';
10
+ import { WallLayer } from './WallLayer';
11
+ import { ElementNode } from './ElementNode';
12
+
13
+ // ─── Constants ────────────────────────────────────────────────────────────────
14
+
15
+ const SNAP_PX = 10; // screen pixels for wall-node snap
16
+ const DEFAULT_THICKNESS = 8; // canvas units
17
+
18
+ // ─── Floor bounds ─────────────────────────────────────────────────────────────
19
+
20
+ function insideFloor(x: number, y: number, floor: Floor): boolean {
21
+ const { area } = floor;
22
+ if (area.shape === 'rect') {
23
+ const ax = area.x ?? 0, ay = area.y ?? 0, aw = area.width ?? 0, ah = area.height ?? 0;
24
+ return x >= ax && x <= ax + aw && y >= ay && y <= ay + ah;
25
+ }
26
+ if (area.shape === 'polygon') {
27
+ const pts = area.points ?? [];
28
+ let inside = false;
29
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
30
+ const [xi, yi] = pts[i], [xj, yj] = pts[j];
31
+ if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside;
32
+ }
33
+ return inside;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ // ─── Lasso ────────────────────────────────────────────────────────────────────
39
+
40
+ interface LassoRect { x: number; y: number; w: number; h: number }
41
+
42
+ function rectsIntersect(a: LassoRect, b: LassoRect): boolean {
43
+ return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
44
+ }
45
+
46
+ // ─── Wall-draw state ──────────────────────────────────────────────────────────
47
+
48
+ interface WallDraw {
49
+ /** Fixed start point (snapped or free). */
50
+ startX: number;
51
+ startY: number;
52
+ /** Non-null when startX/Y snapped to an existing node. */
53
+ snapStartNode: WallNode | null;
54
+ /** Current cursor position in canvas coords. */
55
+ previewX: number;
56
+ previewY: number;
57
+ /** Non-null when cursor is snapping to an existing end node. */
58
+ snapEndNode: WallNode | null;
59
+ }
60
+
61
+ // ─── Props ────────────────────────────────────────────────────────────────────
62
+
63
+ interface EditorCanvasProps {
64
+ floor: Floor;
65
+ tool: ToolMode;
66
+ gridSize: number;
67
+ showGrid: boolean;
68
+ readOnly: boolean;
69
+ snapEnabled: boolean;
70
+ elementTypeDefs: Map<string, ElementTypeDef>;
71
+ selectedIds: ReadonlySet<string>;
72
+ statusMap?: Map<string, string>;
73
+ onAreaResize: (floor: Floor) => void;
74
+ onAreaMove: (dx: number, dy: number) => void;
75
+ onAreaResizeCommit?: (floor: Floor) => void;
76
+ onSelectElement: (id: string, multi: boolean) => void;
77
+ onSelectSet: (ids: string[]) => void;
78
+ onClearSelection: () => void;
79
+ onMoveElement: (id: string, x: number, y: number) => void;
80
+ onMoveCommit: (id: string, x: number, y: number) => void;
81
+ onResizeElement: (id: string, x: number, y: number, w: number, h: number) => void;
82
+ onResizeCommit: (id: string, x: number, y: number, w: number, h: number) => void;
83
+ onRotateElement: (id: string, rotation: number) => void;
84
+ onRotateCommit: (id: string, rotation: number) => void;
85
+ onDeleteElement: (id: string) => void;
86
+ onPlaceElement: (canvasX: number, canvasY: number) => void;
87
+ onAddWall?: (x1: number, y1: number, x2: number, y2: number,
88
+ snapStartId: string | null, snapEndId: string | null) => void;
89
+ onDeleteWall?: (wallId: string) => void;
90
+ onViewerElementClick?: (id: string) => void;
91
+ onZoomChange?: (zoom: number) => void;
92
+ onRegisterZoomBy?: (fn: (factor: number) => void) => void;
93
+ onRegisterResetView?: (fn: () => void) => void;
94
+ }
95
+
96
+ // ─── Component ────────────────────────────────────────────────────────────────
97
+
98
+ export function EditorCanvas({
99
+ floor,
100
+ tool,
101
+ gridSize,
102
+ showGrid,
103
+ readOnly,
104
+ snapEnabled,
105
+ elementTypeDefs,
106
+ selectedIds,
107
+ statusMap,
108
+ onAreaResize,
109
+ onAreaMove,
110
+ onAreaResizeCommit,
111
+ onSelectElement,
112
+ onSelectSet,
113
+ onClearSelection,
114
+ onMoveElement,
115
+ onMoveCommit,
116
+ onResizeElement,
117
+ onResizeCommit,
118
+ onRotateElement,
119
+ onRotateCommit,
120
+ onDeleteElement,
121
+ onPlaceElement,
122
+ onAddWall,
123
+ onDeleteWall,
124
+ onViewerElementClick,
125
+ onZoomChange,
126
+ onRegisterZoomBy,
127
+ onRegisterResetView,
128
+ }: EditorCanvasProps) {
129
+ const svgRef = useRef<SVGSVGElement>(null);
130
+
131
+ const {
132
+ state: panZoom, isPanning,
133
+ handleWheel, handleMouseDown: handlePanMouseDown,
134
+ handleMouseMove: handlePanMouseMove,
135
+ handleMouseUp: handlePanMouseUp,
136
+ handleMouseLeave,
137
+ zoomBy, resetView,
138
+ } = usePanZoom(1, tool === 'PAN');
139
+
140
+ const panZoomRef = useRef<PanZoomState>(panZoom);
141
+ panZoomRef.current = panZoom;
142
+
143
+ // ── Lasso state ─────────────────────────────────────────────────────────────
144
+ const [lasso, setLasso] = useState<LassoRect | null>(null);
145
+ const lassoStart = useRef<{ cx: number; cy: number } | null>(null);
146
+
147
+ // ── Wall draw state ──────────────────────────────────────────────────────────
148
+ const [wallDraw, setWallDraw] = useState<WallDraw | null>(null);
149
+
150
+ // Cancel wall draw when switching away from WALL tool
151
+ useEffect(() => {
152
+ if (tool !== 'WALL') setWallDraw(null);
153
+ }, [tool]);
154
+
155
+ // ── Expose callbacks ────────────────────────────────────────────────────────
156
+ useEffect(() => {
157
+ onRegisterZoomBy?.(zoomBy);
158
+ onRegisterResetView?.(resetView);
159
+ // eslint-disable-next-line react-hooks/exhaustive-deps
160
+ }, []);
161
+
162
+ useEffect(() => { onZoomChange?.(panZoom.zoom); }, [panZoom.zoom, onZoomChange]);
163
+
164
+ // ── Prevent passive wheel ───────────────────────────────────────────────────
165
+ useEffect(() => {
166
+ const el = svgRef.current;
167
+ if (!el) return;
168
+ const prevent = (e: WheelEvent) => e.preventDefault();
169
+ el.addEventListener('wheel', prevent, { passive: false });
170
+ return () => el.removeEventListener('wheel', prevent);
171
+ }, []);
172
+
173
+ // ── Coordinate helper ───────────────────────────────────────────────────────
174
+ const toCanvas = useCallback((clientX: number, clientY: number) => {
175
+ const rect = svgRef.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
176
+ const { panX, panY, zoom } = panZoomRef.current;
177
+ return { x: (clientX - rect.left - panX) / zoom, y: (clientY - rect.top - panY) / zoom };
178
+ }, []);
179
+
180
+ // ── Wall snap helper ─────────────────────────────────────────────────────────
181
+ const findSnapNode = useCallback((
182
+ cx: number, cy: number,
183
+ excludeId?: string | null,
184
+ ): WallNode | null => {
185
+ const threshold = SNAP_PX / panZoomRef.current.zoom;
186
+ const candidates = excludeId
187
+ ? floor.wallNodes.filter(n => n.id !== excludeId)
188
+ : floor.wallNodes;
189
+ return findNearestNode(cx, cy, candidates, threshold) as WallNode | null;
190
+ }, [floor.wallNodes]);
191
+
192
+ // ── SVG mouse events ─────────────────────────────────────────────────────────
193
+ const handleSvgMouseDown = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
194
+ if (e.button === 1 || (e.button === 0 && tool === 'PAN')) {
195
+ handlePanMouseDown(e);
196
+ return;
197
+ }
198
+ if (e.button !== 0) return;
199
+
200
+ const raw = toCanvas(e.clientX, e.clientY);
201
+ const { x: cx, y: cy } = snapPoint(raw.x, raw.y, gridSize, snapEnabled);
202
+
203
+ // ── WALL mode ──
204
+ if (tool === 'WALL') {
205
+ if (!wallDraw) {
206
+ // First click: reject if outside floor
207
+ if (!insideFloor(cx, cy, floor)) return;
208
+ const snapNode = findSnapNode(cx, cy, null);
209
+ const sx = snapNode ? snapNode.x : cx;
210
+ const sy = snapNode ? snapNode.y : cy;
211
+ setWallDraw({
212
+ startX: sx, startY: sy,
213
+ snapStartNode: snapNode,
214
+ previewX: cx, previewY: cy,
215
+ snapEndNode: null,
216
+ });
217
+ } else {
218
+ // Second click: complete the wall
219
+ // Snapped node is always a valid existing point; free point is clamped to floor
220
+ const snapNode = findSnapNode(cx, cy, wallDraw.snapStartNode?.id ?? null);
221
+ let ex: number, ey: number;
222
+ if (snapNode) {
223
+ ex = snapNode.x; ey = snapNode.y;
224
+ } else {
225
+ const { area } = floor;
226
+ if (area.shape === 'rect') {
227
+ const ax = area.x ?? 0, ay = area.y ?? 0;
228
+ const aw = area.width ?? 0, ah = area.height ?? 0;
229
+ ex = Math.max(ax, Math.min(ax + aw, cx));
230
+ ey = Math.max(ay, Math.min(ay + ah, cy));
231
+ } else if (area.shape === 'polygon') {
232
+ const pts = area.points ?? [];
233
+ if (insideFloor(cx, cy, floor)) {
234
+ ex = cx; ey = cy;
235
+ } else {
236
+ // Clamp to nearest point on polygon perimeter
237
+ let bestDist = Infinity;
238
+ ex = cx; ey = cy;
239
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
240
+ const [ax2, ay2] = pts[j], [bx2, by2] = pts[i];
241
+ const dx = bx2 - ax2, dy = by2 - ay2;
242
+ const len2 = dx * dx + dy * dy;
243
+ const t = len2 > 0 ? Math.max(0, Math.min(1, ((cx - ax2) * dx + (cy - ay2) * dy) / len2)) : 0;
244
+ const nx = ax2 + t * dx, ny = ay2 + t * dy;
245
+ const dist = (cx - nx) ** 2 + (cy - ny) ** 2;
246
+ if (dist < bestDist) { bestDist = dist; ex = nx; ey = ny; }
247
+ }
248
+ }
249
+ } else {
250
+ ex = cx; ey = cy;
251
+ }
252
+ }
253
+
254
+ // Ignore zero-length walls
255
+ const dist = Math.hypot(ex - wallDraw.startX, ey - wallDraw.startY);
256
+ if (dist > 2) {
257
+ onAddWall?.(
258
+ wallDraw.startX, wallDraw.startY,
259
+ ex, ey,
260
+ wallDraw.snapStartNode?.id ?? null,
261
+ snapNode?.id ?? null,
262
+ );
263
+ }
264
+
265
+ // Chain: start next wall from end point
266
+ setWallDraw({
267
+ startX: ex, startY: ey,
268
+ snapStartNode: snapNode,
269
+ previewX: cx, previewY: cy,
270
+ snapEndNode: null,
271
+ });
272
+ }
273
+ return;
274
+ }
275
+
276
+ if (tool === 'PLACE') {
277
+ onPlaceElement(cx, cy);
278
+ return;
279
+ }
280
+
281
+ if (tool === 'SELECT') {
282
+ lassoStart.current = { cx, cy };
283
+ setLasso({ x: cx, y: cy, w: 0, h: 0 });
284
+ }
285
+ }, [handlePanMouseDown, tool, toCanvas, gridSize, snapEnabled,
286
+ wallDraw, findSnapNode, onAddWall, onPlaceElement]);
287
+
288
+ const handleSvgMouseMove = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
289
+ handlePanMouseMove(e);
290
+
291
+ const raw = toCanvas(e.clientX, e.clientY);
292
+ const { x: cx, y: cy } = snapPoint(raw.x, raw.y, gridSize, snapEnabled);
293
+
294
+ if (tool === 'WALL' && wallDraw) {
295
+ const snapNode = findSnapNode(cx, cy, wallDraw.snapStartNode?.id ?? null);
296
+ setWallDraw(prev => prev
297
+ ? { ...prev, previewX: cx, previewY: cy, snapEndNode: snapNode }
298
+ : null,
299
+ );
300
+ return;
301
+ }
302
+
303
+ if (tool === 'SELECT' && lassoStart.current) {
304
+ const lx = Math.min(cx, lassoStart.current.cx);
305
+ const ly = Math.min(cy, lassoStart.current.cy);
306
+ setLasso({ x: lx, y: ly, w: Math.abs(cx - lassoStart.current.cx), h: Math.abs(cy - lassoStart.current.cy) });
307
+ }
308
+ }, [handlePanMouseMove, tool, toCanvas, gridSize, snapEnabled, wallDraw, findSnapNode]);
309
+
310
+ const handleSvgMouseUp = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
311
+ handlePanMouseUp(e);
312
+
313
+ if (lassoStart.current && lasso) {
314
+ if (lasso.w > 4 / panZoom.zoom || lasso.h > 4 / panZoom.zoom) {
315
+ const ids = floor.elements
316
+ .filter(el => rectsIntersect(lasso, { x: el.x, y: el.y, w: el.width, h: el.height }))
317
+ .map(el => el.id);
318
+ if (ids.length > 0) onSelectSet(ids);
319
+ else if (!e.ctrlKey && !e.metaKey) onClearSelection();
320
+ } else {
321
+ if (!e.ctrlKey && !e.metaKey) onClearSelection();
322
+ }
323
+ lassoStart.current = null;
324
+ setLasso(null);
325
+ }
326
+ }, [handlePanMouseUp, lasso, panZoom.zoom, floor.elements, onSelectSet, onClearSelection]);
327
+
328
+ const handleContextMenu = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
329
+ e.preventDefault();
330
+ // Right-click cancels in-progress wall drawing
331
+ if (tool === 'WALL') setWallDraw(null);
332
+ }, [tool]);
333
+
334
+ // ── Derived ──────────────────────────────────────────────────────────────────
335
+ const cursor = isPanning ? 'grabbing'
336
+ : tool === 'PAN' ? 'grab'
337
+ : tool === 'WALL' ? 'crosshair'
338
+ : tool === 'PLACE' ? 'crosshair'
339
+ : tool === 'ERASE' ? 'crosshair'
340
+ : 'default';
341
+
342
+ const { panX, panY, zoom } = panZoom;
343
+
344
+ // Preview wall path (while drawing)
345
+ const previewPath = wallDraw && (() => {
346
+ const ex = wallDraw.snapEndNode?.x ?? wallDraw.previewX;
347
+ const ey = wallDraw.snapEndNode?.y ?? wallDraw.previewY;
348
+ const dist = Math.hypot(ex - wallDraw.startX, ey - wallDraw.startY);
349
+ return dist > 2
350
+ ? wallSegmentPath(wallDraw.startX, wallDraw.startY, ex, ey, DEFAULT_THICKNESS, null, null)
351
+ : null;
352
+ })();
353
+
354
+ return (
355
+ <svg
356
+ ref={svgRef}
357
+ className="w-full h-full select-none outline-none"
358
+ style={{ cursor, display: 'block' }}
359
+ onWheel={handleWheel}
360
+ onMouseDown={handleSvgMouseDown}
361
+ onMouseMove={handleSvgMouseMove}
362
+ onMouseUp={handleSvgMouseUp}
363
+ onMouseLeave={handleMouseLeave}
364
+ onContextMenu={handleContextMenu}
365
+ >
366
+ <g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
367
+ {/* Canvas background */}
368
+ <rect x={-50000} y={-50000} width={100000} height={100000} fill="#f1f5f9" />
369
+
370
+ {/* Grid */}
371
+ {showGrid && <GridOverlay gridSize={gridSize} />}
372
+
373
+ {/* Artboard */}
374
+ <Artboard
375
+ area={floor.area}
376
+ onResize={area => onAreaResize({ ...floor, area })}
377
+ onMove={onAreaMove}
378
+ onResizeCommit={area => onAreaResizeCommit?.({ ...floor, area })}
379
+ svgRef={svgRef}
380
+ panZoomRef={panZoomRef}
381
+ zoom={zoom}
382
+ readOnly={readOnly || tool !== 'SELECT'}
383
+ />
384
+
385
+ {/* Walls */}
386
+ <WallLayer
387
+ nodes={floor.wallNodes}
388
+ walls={floor.walls}
389
+ zoom={zoom}
390
+ tool={tool}
391
+ onDeleteWall={onDeleteWall}
392
+ />
393
+
394
+ {/* Elements */}
395
+ {floor.elements.map(el => {
396
+ const typeDef = elementTypeDefs.get(el.type);
397
+ if (!typeDef) return null;
398
+ return (
399
+ <ElementNode
400
+ key={el.id}
401
+ element={el}
402
+ typeDef={typeDef}
403
+ isSelected={selectedIds.has(el.id)}
404
+ tool={tool}
405
+ zoom={zoom}
406
+ svgRef={svgRef}
407
+ panZoomRef={panZoomRef}
408
+ snapEnabled={snapEnabled}
409
+ gridSize={gridSize}
410
+ statusFill={statusMap?.get(el.id)}
411
+ onSelect={multi => onSelectElement(el.id, multi)}
412
+ onMove={(x, y) => onMoveElement(el.id, x, y)}
413
+ onMoveCommit={(x, y) => onMoveCommit(el.id, x, y)}
414
+ onResize={(x, y, w, h) => onResizeElement(el.id, x, y, w, h)}
415
+ onResizeCommit={(x, y, w, h) => onResizeCommit(el.id, x, y, w, h)}
416
+ onRotate={r => onRotateElement(el.id, r)}
417
+ onRotateCommit={r => onRotateCommit(el.id, r)}
418
+ onDelete={() => onDeleteElement(el.id)}
419
+ onViewerClick={onViewerElementClick ? () => onViewerElementClick(el.id) : undefined}
420
+ />
421
+ );
422
+ })}
423
+
424
+ {/* Lasso rectangle */}
425
+ {lasso && lasso.w > 0 && lasso.h > 0 && (
426
+ <rect
427
+ x={lasso.x} y={lasso.y} width={lasso.w} height={lasso.h}
428
+ fill="rgba(59,130,246,0.06)"
429
+ stroke="#3b82f6"
430
+ strokeWidth={1 / zoom}
431
+ strokeDasharray={`${4 / zoom},${2 / zoom}`}
432
+ style={{ pointerEvents: 'none' }}
433
+ />
434
+ )}
435
+
436
+ {/* ── Wall draw preview ── */}
437
+ {wallDraw && (
438
+ <>
439
+ {/* Preview wall body */}
440
+ {previewPath && (
441
+ <path
442
+ d={previewPath}
443
+ fill="#94a3b8"
444
+ fillOpacity={0.45}
445
+ stroke="#3b82f6"
446
+ strokeWidth={1 / zoom}
447
+ strokeDasharray={`${5 / zoom},${3 / zoom}`}
448
+ style={{ pointerEvents: 'none' }}
449
+ />
450
+ )}
451
+ {/* Start node anchor */}
452
+ <circle
453
+ cx={wallDraw.startX} cy={wallDraw.startY}
454
+ r={5 / zoom}
455
+ fill="#3b82f6" stroke="white" strokeWidth={1.5 / zoom}
456
+ style={{ pointerEvents: 'none' }}
457
+ />
458
+ {/* Snap ring around the nearest end node */}
459
+ {wallDraw.snapEndNode && (
460
+ <circle
461
+ cx={wallDraw.snapEndNode.x} cy={wallDraw.snapEndNode.y}
462
+ r={9 / zoom}
463
+ fill="none" stroke="#3b82f6" strokeWidth={2 / zoom}
464
+ style={{ pointerEvents: 'none' }}
465
+ />
466
+ )}
467
+ </>
468
+ )}
469
+ </g>
470
+ </svg>
471
+ );
472
+ }