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.
- package/README.md +393 -2
- package/dist/components/VenueMapEditor/index.d.mts +202 -0
- package/dist/components/VenueMapEditor/index.d.ts +202 -0
- package/dist/components/VenueMapEditor/index.js +2684 -0
- package/dist/components/VenueMapEditor/index.js.map +1 -0
- package/dist/components/VenueMapEditor/index.mjs +2676 -0
- package/dist/components/VenueMapEditor/index.mjs.map +1 -0
- package/dist/components/alerts/index.js.map +1 -1
- package/dist/components/alerts/index.mjs.map +1 -1
- package/dist/components/html/index.d.mts +2 -0
- package/dist/components/html/index.d.ts +2 -0
- package/dist/components/html/index.js +24 -58
- package/dist/components/html/index.js.map +1 -1
- package/dist/components/html/index.mjs +24 -58
- package/dist/components/html/index.mjs.map +1 -1
- package/dist/components/icons/index.d.mts +18 -2
- package/dist/components/icons/index.d.ts +18 -2
- package/dist/components/icons/index.js +97 -11
- package/dist/components/icons/index.js.map +1 -1
- package/dist/components/icons/index.mjs +82 -12
- package/dist/components/icons/index.mjs.map +1 -1
- package/dist/context/theme/index.js.map +1 -1
- package/dist/context/theme/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2734 -69
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2713 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/components/VenueMapEditor/VenueMapEditor.tsx +851 -0
- package/src/components/VenueMapEditor/VenueMapViewer.tsx +13 -0
- package/src/components/VenueMapEditor/components/Artboard.tsx +405 -0
- package/src/components/VenueMapEditor/components/EditorCanvas.tsx +472 -0
- package/src/components/VenueMapEditor/components/ElementNode.tsx +357 -0
- package/src/components/VenueMapEditor/components/FloorTabs.tsx +137 -0
- package/src/components/VenueMapEditor/components/GridOverlay.tsx +67 -0
- package/src/components/VenueMapEditor/components/PropertiesPanel.tsx +198 -0
- package/src/components/VenueMapEditor/components/Toolbar.tsx +254 -0
- package/src/components/VenueMapEditor/components/WallLayer.tsx +117 -0
- package/src/components/VenueMapEditor/hooks/useDrag.ts +79 -0
- package/src/components/VenueMapEditor/hooks/useHistory.ts +74 -0
- package/src/components/VenueMapEditor/hooks/usePanZoom.ts +114 -0
- package/src/components/VenueMapEditor/hooks/useSelection.ts +42 -0
- package/src/components/VenueMapEditor/index.ts +34 -0
- package/src/components/VenueMapEditor/types.ts +173 -0
- package/src/components/VenueMapEditor/utils/idGen.ts +2 -0
- package/src/components/VenueMapEditor/utils/snapUtils.ts +38 -0
- package/src/components/VenueMapEditor/utils/wallGeometry.ts +83 -0
- package/src/components/html/Input.tsx +48 -80
- package/src/components/icons/icons.tsx +153 -14
- package/src/index.ts +1 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import type { VenueMap } from '../types';
|
|
3
|
+
|
|
4
|
+
const MAX_HISTORY = 50;
|
|
5
|
+
|
|
6
|
+
interface HistoryState {
|
|
7
|
+
past: VenueMap[];
|
|
8
|
+
present: VenueMap;
|
|
9
|
+
future: VenueMap[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Undo/redo stack for VenueMap snapshots.
|
|
14
|
+
*
|
|
15
|
+
* - `push(next)` : commit a new state (clears redo stack)
|
|
16
|
+
* - `undo()` : step back one snapshot
|
|
17
|
+
* - `redo()` : step forward one snapshot
|
|
18
|
+
* - `replace(next)`: update present WITHOUT pushing to history (e.g. live-drag)
|
|
19
|
+
*/
|
|
20
|
+
export function useHistory(initial: VenueMap) {
|
|
21
|
+
const [history, setHistory] = useState<HistoryState>({
|
|
22
|
+
past: [],
|
|
23
|
+
present: initial,
|
|
24
|
+
future: [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Commit a new state — adds present to `past`, clears `future`. */
|
|
28
|
+
const push = useCallback((next: VenueMap) => {
|
|
29
|
+
setHistory(h => ({
|
|
30
|
+
past: [...h.past.slice(-(MAX_HISTORY - 1)), h.present],
|
|
31
|
+
present: next,
|
|
32
|
+
future: [],
|
|
33
|
+
}));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
/** Update present WITHOUT touching the undo stack (for live dragging). */
|
|
37
|
+
const replace = useCallback((next: VenueMap) => {
|
|
38
|
+
setHistory(h => ({ ...h, present: next }));
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const undo = useCallback(() => {
|
|
42
|
+
setHistory(h => {
|
|
43
|
+
if (h.past.length === 0) return h;
|
|
44
|
+
const previous = h.past[h.past.length - 1];
|
|
45
|
+
return {
|
|
46
|
+
past: h.past.slice(0, -1),
|
|
47
|
+
present: previous,
|
|
48
|
+
future: [h.present, ...h.future],
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const redo = useCallback(() => {
|
|
54
|
+
setHistory(h => {
|
|
55
|
+
if (h.future.length === 0) return h;
|
|
56
|
+
const next = h.future[0];
|
|
57
|
+
return {
|
|
58
|
+
past: [...h.past, h.present],
|
|
59
|
+
present: next,
|
|
60
|
+
future: h.future.slice(1),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
map: history.present,
|
|
67
|
+
canUndo: history.past.length > 0,
|
|
68
|
+
canRedo: history.future.length > 0,
|
|
69
|
+
push,
|
|
70
|
+
replace,
|
|
71
|
+
undo,
|
|
72
|
+
redo,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import type { WheelEvent as ReactWheelEvent, MouseEvent as ReactMouseEvent } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface PanZoomState {
|
|
5
|
+
panX: number;
|
|
6
|
+
panY: number;
|
|
7
|
+
zoom: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ZOOM_MIN = 0.1;
|
|
11
|
+
const ZOOM_MAX = 10;
|
|
12
|
+
const ZOOM_FACTOR = 1.1;
|
|
13
|
+
|
|
14
|
+
export function usePanZoom(initialZoom = 1, leftClickPan = false) {
|
|
15
|
+
const [state, setState] = useState<PanZoomState>({
|
|
16
|
+
panX: 80,
|
|
17
|
+
panY: 80,
|
|
18
|
+
zoom: initialZoom,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** Track whether a middle-click pan is in progress (ref = no re-render). */
|
|
22
|
+
const isPanningRef = useRef(false);
|
|
23
|
+
const [isPanning, setIsPanning] = useState(false);
|
|
24
|
+
const lastPosRef = useRef({ x: 0, y: 0 });
|
|
25
|
+
|
|
26
|
+
// ── Wheel zoom ──────────────────────────────────────────────────────────────
|
|
27
|
+
const handleWheel = useCallback((e: ReactWheelEvent<SVGSVGElement>) => {
|
|
28
|
+
// NOTE: e.preventDefault() is intentionally NOT called here.
|
|
29
|
+
// React registers synthetic wheel events as passive, so calling preventDefault
|
|
30
|
+
// would throw a warning and be ignored. The non-passive native listener added
|
|
31
|
+
// in EditorCanvas handles scroll prevention instead.
|
|
32
|
+
const factor = e.deltaY < 0 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
|
|
33
|
+
|
|
34
|
+
// Read all event values NOW — before setState — because e.currentTarget
|
|
35
|
+
// is nulled out by React after the event handler returns (event pooling).
|
|
36
|
+
const svgEl = e.currentTarget as SVGSVGElement;
|
|
37
|
+
const rect = svgEl.getBoundingClientRect();
|
|
38
|
+
const mouseX = e.clientX - rect.left;
|
|
39
|
+
const mouseY = e.clientY - rect.top;
|
|
40
|
+
|
|
41
|
+
setState(prev => {
|
|
42
|
+
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev.zoom * factor));
|
|
43
|
+
// Keep the canvas point under the cursor fixed.
|
|
44
|
+
const canvasX = (mouseX - prev.panX) / prev.zoom;
|
|
45
|
+
const canvasY = (mouseY - prev.panY) / prev.zoom;
|
|
46
|
+
return {
|
|
47
|
+
panX: mouseX - canvasX * newZoom,
|
|
48
|
+
panY: mouseY - canvasY * newZoom,
|
|
49
|
+
zoom: newZoom,
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// ── Pan (middle-click, or left-click when leftClickPan=true) ────────────────
|
|
55
|
+
const handleMouseDown = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
|
|
56
|
+
const valid = leftClickPan ? (e.button === 0 || e.button === 1) : e.button === 1;
|
|
57
|
+
if (!valid) return;
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
isPanningRef.current = true;
|
|
60
|
+
setIsPanning(true);
|
|
61
|
+
lastPosRef.current = { x: e.clientX, y: e.clientY };
|
|
62
|
+
}, [leftClickPan]);
|
|
63
|
+
|
|
64
|
+
const handleMouseMove = useCallback((e: ReactMouseEvent<SVGSVGElement>) => {
|
|
65
|
+
if (!isPanningRef.current) return;
|
|
66
|
+
const dx = e.clientX - lastPosRef.current.x;
|
|
67
|
+
const dy = e.clientY - lastPosRef.current.y;
|
|
68
|
+
lastPosRef.current = { x: e.clientX, y: e.clientY };
|
|
69
|
+
setState(prev => ({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy }));
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const stopPan = useCallback((_e: ReactMouseEvent<SVGSVGElement>) => {
|
|
73
|
+
if (!isPanningRef.current) return;
|
|
74
|
+
isPanningRef.current = false;
|
|
75
|
+
setIsPanning(false);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleMouseLeave = useCallback(() => {
|
|
79
|
+
if (isPanningRef.current) {
|
|
80
|
+
isPanningRef.current = false;
|
|
81
|
+
setIsPanning(false);
|
|
82
|
+
}
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
// ── Programmatic zoom ───────────────────────────────────────────────────────
|
|
86
|
+
const zoomBy = useCallback((factor: number, cx?: number, cy?: number) => {
|
|
87
|
+
setState(prev => {
|
|
88
|
+
const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev.zoom * factor));
|
|
89
|
+
if (cx !== undefined && cy !== undefined) {
|
|
90
|
+
const canvasX = (cx - prev.panX) / prev.zoom;
|
|
91
|
+
const canvasY = (cy - prev.panY) / prev.zoom;
|
|
92
|
+
return { panX: cx - canvasX * newZoom, panY: cy - canvasY * newZoom, zoom: newZoom };
|
|
93
|
+
}
|
|
94
|
+
return { ...prev, zoom: newZoom };
|
|
95
|
+
});
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const resetView = useCallback(() => {
|
|
99
|
+
setState({ panX: 80, panY: 80, zoom: 1 });
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
state,
|
|
104
|
+
setState,
|
|
105
|
+
isPanning,
|
|
106
|
+
handleWheel,
|
|
107
|
+
handleMouseDown,
|
|
108
|
+
handleMouseMove,
|
|
109
|
+
handleMouseUp: stopPan,
|
|
110
|
+
handleMouseLeave,
|
|
111
|
+
zoomBy,
|
|
112
|
+
resetView,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Selection state for map elements.
|
|
5
|
+
*
|
|
6
|
+
* - `select(id, multi)` : select an element; if `multi`, toggle it
|
|
7
|
+
* - `selectSet(ids)` : replace selection with a set of ids
|
|
8
|
+
* - `clear()` : deselect everything
|
|
9
|
+
* - `isSelected(id)` : check membership
|
|
10
|
+
*/
|
|
11
|
+
export function useSelection() {
|
|
12
|
+
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
|
13
|
+
|
|
14
|
+
const select = useCallback((id: string, multi = false) => {
|
|
15
|
+
setSelectedIds(prev => {
|
|
16
|
+
if (multi) {
|
|
17
|
+
const next = new Set(prev);
|
|
18
|
+
if (next.has(id)) next.delete(id);
|
|
19
|
+
else next.add(id);
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
// Single select: only switch if not already the sole selection
|
|
23
|
+
if (prev.size === 1 && prev.has(id)) return prev;
|
|
24
|
+
return new Set([id]);
|
|
25
|
+
});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const selectSet = useCallback((ids: string[]) => {
|
|
29
|
+
setSelectedIds(new Set(ids));
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const clear = useCallback(() => {
|
|
33
|
+
setSelectedIds(prev => (prev.size === 0 ? prev : new Set()));
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const isSelected = useCallback(
|
|
37
|
+
(id: string) => selectedIds.has(id),
|
|
38
|
+
[selectedIds],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return { selectedIds, select, selectSet, clear, isSelected };
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Main component
|
|
2
|
+
export { VenueMapEditor } from './VenueMapEditor';
|
|
3
|
+
export { VenueMapViewer } from './VenueMapViewer';
|
|
4
|
+
|
|
5
|
+
// Types
|
|
6
|
+
export type {
|
|
7
|
+
WallMaterial,
|
|
8
|
+
AreaShape,
|
|
9
|
+
ElementShape,
|
|
10
|
+
ToolMode,
|
|
11
|
+
WallNode,
|
|
12
|
+
Wall,
|
|
13
|
+
MapElement,
|
|
14
|
+
FloorArea,
|
|
15
|
+
Floor,
|
|
16
|
+
VenueMap,
|
|
17
|
+
ElementTypeDef,
|
|
18
|
+
DomainConfig,
|
|
19
|
+
ElementGroup,
|
|
20
|
+
ElementLibrary,
|
|
21
|
+
ElementStatus,
|
|
22
|
+
VenueMapEditorProps,
|
|
23
|
+
VenueMapViewerProps,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
export type { PaletteGroup } from './components/Toolbar';
|
|
27
|
+
|
|
28
|
+
// Hooks (for advanced consumers)
|
|
29
|
+
export { usePanZoom } from './hooks/usePanZoom';
|
|
30
|
+
export type { PanZoomState } from './hooks/usePanZoom';
|
|
31
|
+
|
|
32
|
+
// Utils (for advanced consumers)
|
|
33
|
+
export { genId } from './utils/idGen';
|
|
34
|
+
export { snapToGrid, snapPoint, findNearestNode } from './utils/snapUtils';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// ─── Domain primitives ───────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type WallMaterial = 'concrete' | 'brick' | 'glass' | 'drywall' | 'wood';
|
|
4
|
+
export type AreaShape = 'rect' | 'polygon';
|
|
5
|
+
export type ElementShape = 'rect' | 'circle' | 'arrow' | 'path';
|
|
6
|
+
export type ToolMode = 'SELECT' | 'WALL' | 'PLACE' | 'PAN' | 'ERASE';
|
|
7
|
+
|
|
8
|
+
// ─── Wall graph ───────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface WallNode {
|
|
11
|
+
id: string;
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Wall {
|
|
17
|
+
id: string;
|
|
18
|
+
nodeAId: string;
|
|
19
|
+
nodeBId: string;
|
|
20
|
+
/** Thickness in canvas px */
|
|
21
|
+
thickness: number;
|
|
22
|
+
material: WallMaterial;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Map elements ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface MapElement {
|
|
28
|
+
id: string;
|
|
29
|
+
/** e.g. 'TABLE_ROUND', 'PARKING_SPOT', 'DOOR' */
|
|
30
|
+
type: string;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
width: number;
|
|
34
|
+
height: number;
|
|
35
|
+
/** Rotation in degrees */
|
|
36
|
+
rotation: number;
|
|
37
|
+
label?: string;
|
|
38
|
+
metadata?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Floor / Venue ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface FloorArea {
|
|
44
|
+
shape: AreaShape;
|
|
45
|
+
// rect
|
|
46
|
+
x?: number;
|
|
47
|
+
y?: number;
|
|
48
|
+
width?: number;
|
|
49
|
+
height?: number;
|
|
50
|
+
// polygon
|
|
51
|
+
points?: [number, number][];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Floor {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
order: number;
|
|
58
|
+
area: FloorArea;
|
|
59
|
+
wallNodes: WallNode[];
|
|
60
|
+
walls: Wall[];
|
|
61
|
+
elements: MapElement[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface VenueMap {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
floors: Floor[];
|
|
68
|
+
/** Custom element libraries imported by the user; persisted with the map. */
|
|
69
|
+
libraries?: ElementLibrary;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Domain config ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
export interface ElementTypeDef {
|
|
75
|
+
id: string;
|
|
76
|
+
label: string;
|
|
77
|
+
shape: ElementShape;
|
|
78
|
+
defaultWidth: number;
|
|
79
|
+
defaultHeight: number;
|
|
80
|
+
/** SVG fill color */
|
|
81
|
+
color: string;
|
|
82
|
+
strokeColor: string;
|
|
83
|
+
/** Emoji or icon name */
|
|
84
|
+
icon?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Raw SVG path `d` attribute for `shape === 'path'`.
|
|
87
|
+
* Define the path in the coordinate space of `viewBox` (default `"0 0 100 100"`).
|
|
88
|
+
* It will be automatically scaled to fit the element's `width × height` bounding box.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* // A 5-pointed star in a 100×100 viewBox
|
|
92
|
+
* svgPath: "M50 5 L61 35 L95 35 L68 57 L79 91 L50 70 L21 91 L32 57 L5 35 L39 35 Z"
|
|
93
|
+
*/
|
|
94
|
+
svgPath?: string;
|
|
95
|
+
/**
|
|
96
|
+
* ViewBox for `svgPath`. Format: `"minX minY width height"`.
|
|
97
|
+
* Defaults to `"0 0 100 100"` when omitted.
|
|
98
|
+
*/
|
|
99
|
+
viewBox?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface DomainConfig {
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
elementTypes: ElementTypeDef[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Custom element libraries ─────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface ElementGroup {
|
|
111
|
+
name: string;
|
|
112
|
+
objects: ElementTypeDef[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A library JSON file: top-level keys are group IDs. */
|
|
116
|
+
export type ElementLibrary = Record<string, ElementGroup>;
|
|
117
|
+
|
|
118
|
+
// ─── Viewer status ────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface ElementStatus {
|
|
121
|
+
elementId: string;
|
|
122
|
+
status: 'free' | 'occupied' | 'reserved' | 'disabled';
|
|
123
|
+
tooltip?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Editor props ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
export interface VenueMapEditorProps {
|
|
129
|
+
/**
|
|
130
|
+
* Optional built-in element type catalog.
|
|
131
|
+
* If omitted the palette is empty until the user imports a library JSON.
|
|
132
|
+
*/
|
|
133
|
+
domainConfig?: DomainConfig;
|
|
134
|
+
/**
|
|
135
|
+
* Map to render. When this prop changes (by reference) from outside the
|
|
136
|
+
* component, the editor resets its history to the new map — allowing the
|
|
137
|
+
* parent to hydrate the editor from an API or local storage without causing
|
|
138
|
+
* a render loop (changes made inside the editor that are echoed back via
|
|
139
|
+
* `onChange` are detected and ignored).
|
|
140
|
+
*/
|
|
141
|
+
initialMap?: VenueMap;
|
|
142
|
+
/** Called every time the internal map state changes. */
|
|
143
|
+
onChange?: (map: VenueMap) => void;
|
|
144
|
+
width?: string | number;
|
|
145
|
+
height?: string | number;
|
|
146
|
+
gridSize?: number;
|
|
147
|
+
showGrid?: boolean;
|
|
148
|
+
snapToGrid?: boolean;
|
|
149
|
+
readOnly?: boolean;
|
|
150
|
+
/** Viewer-only mode: pan and zoom are allowed but nothing can be edited. */
|
|
151
|
+
fixed?: boolean;
|
|
152
|
+
elementStatus?: ElementStatus[];
|
|
153
|
+
onElementClick?: (element: MapElement) => void;
|
|
154
|
+
/**
|
|
155
|
+
* Per-type click handlers active in viewer/fixed mode.
|
|
156
|
+
* Keys are element type IDs (e.g. `'TABLE_ROUND'`).
|
|
157
|
+
* When an element is clicked, its type-specific handler fires first;
|
|
158
|
+
* if none is registered, `onElementClick` is used as fallback.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```tsx
|
|
162
|
+
* <VenueMapViewer
|
|
163
|
+
* onElementTypeClick={{
|
|
164
|
+
* TABLE_ROUND: (el) => openReservation(el.id),
|
|
165
|
+
* CHAIR: (el) => showInfo(el),
|
|
166
|
+
* }}
|
|
167
|
+
* />
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
onElementTypeClick?: Record<string, (element: MapElement) => void>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type VenueMapViewerProps = VenueMapEditorProps;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Snap a single value to the nearest grid line. */
|
|
2
|
+
export const snapToGrid = (value: number, gridSize: number): number =>
|
|
3
|
+
Math.round(value / gridSize) * gridSize;
|
|
4
|
+
|
|
5
|
+
/** Snap a 2-D point to the grid if `enabled`, otherwise return it unchanged. */
|
|
6
|
+
export const snapPoint = (
|
|
7
|
+
x: number,
|
|
8
|
+
y: number,
|
|
9
|
+
gridSize: number,
|
|
10
|
+
enabled: boolean,
|
|
11
|
+
): { x: number; y: number } => ({
|
|
12
|
+
x: enabled ? snapToGrid(x, gridSize) : x,
|
|
13
|
+
y: enabled ? snapToGrid(y, gridSize) : y,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find the closest WallNode within `threshold` canvas units.
|
|
18
|
+
* Returns the node's id and position, or null when nothing is close enough.
|
|
19
|
+
*/
|
|
20
|
+
export const findNearestNode = (
|
|
21
|
+
x: number,
|
|
22
|
+
y: number,
|
|
23
|
+
nodes: Array<{ id: string; x: number; y: number }>,
|
|
24
|
+
threshold: number,
|
|
25
|
+
): { id: string; x: number; y: number } | null => {
|
|
26
|
+
let best: { id: string; x: number; y: number } | null = null;
|
|
27
|
+
let bestDist = threshold;
|
|
28
|
+
|
|
29
|
+
for (const node of nodes) {
|
|
30
|
+
const dist = Math.hypot(node.x - x, node.y - y);
|
|
31
|
+
if (dist < bestDist) {
|
|
32
|
+
bestDist = dist;
|
|
33
|
+
best = node;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return best;
|
|
38
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// ─── Vector math ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
type Vec2 = { x: number; y: number };
|
|
4
|
+
|
|
5
|
+
function norm(v: Vec2): Vec2 {
|
|
6
|
+
const len = Math.hypot(v.x, v.y);
|
|
7
|
+
return len < 1e-10 ? { x: 1, y: 0 } : { x: v.x / len, y: v.y / len };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 90° CCW rotation (left-normal of a forward direction). */
|
|
11
|
+
function perp(v: Vec2): Vec2 { return { x: -v.y, y: v.x }; }
|
|
12
|
+
|
|
13
|
+
function add(a: Vec2, b: Vec2): Vec2 { return { x: a.x + b.x, y: a.y + b.y }; }
|
|
14
|
+
|
|
15
|
+
function scale(v: Vec2, s: number): Vec2 { return { x: v.x * s, y: v.y * s }; }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Intersect two infinite lines: p1 + s·d1 and p2 + t·d2.
|
|
19
|
+
* Returns null when the lines are parallel.
|
|
20
|
+
*/
|
|
21
|
+
function lineIntersect(p1: Vec2, d1: Vec2, p2: Vec2, d2: Vec2): Vec2 | null {
|
|
22
|
+
const det = d1.x * d2.y - d1.y * d2.x;
|
|
23
|
+
if (Math.abs(det) < 1e-8) return null;
|
|
24
|
+
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
25
|
+
const s = (dx * d2.y - dy * d2.x) / det;
|
|
26
|
+
return { x: p1.x + s * d1.x, y: p1.y + s * d1.y };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Wall polygon ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compute the closed SVG path for a single wall segment (ax,ay)→(bx,by).
|
|
33
|
+
*
|
|
34
|
+
* Miter joints are applied at either end when an adjacent wall direction is
|
|
35
|
+
* provided (exactly one other wall sharing that node). With no adjacent wall
|
|
36
|
+
* the end cap is a flat (square) termination.
|
|
37
|
+
*
|
|
38
|
+
* @param adjDirAtA - normalised direction of the OTHER wall leaving nodeA
|
|
39
|
+
* @param adjDirAtB - normalised direction of the OTHER wall leaving nodeB
|
|
40
|
+
*/
|
|
41
|
+
export function wallSegmentPath(
|
|
42
|
+
ax: number, ay: number,
|
|
43
|
+
bx: number, by: number,
|
|
44
|
+
thickness: number,
|
|
45
|
+
adjDirAtA: Vec2 | null,
|
|
46
|
+
adjDirAtB: Vec2 | null,
|
|
47
|
+
): string {
|
|
48
|
+
const dir = norm({ x: bx - ax, y: by - ay });
|
|
49
|
+
const n = perp(dir);
|
|
50
|
+
const h = thickness / 2;
|
|
51
|
+
const A: Vec2 = { x: ax, y: ay };
|
|
52
|
+
const B: Vec2 = { x: bx, y: by };
|
|
53
|
+
|
|
54
|
+
// Default (un-mitered) corners
|
|
55
|
+
let lA = add(A, scale(n, h));
|
|
56
|
+
let rA = add(A, scale(n, -h));
|
|
57
|
+
let lB = add(B, scale(n, h));
|
|
58
|
+
let rB = add(B, scale(n, -h));
|
|
59
|
+
|
|
60
|
+
// Miter at A:
|
|
61
|
+
// Intersect [left-of-W at A going in dir] with [left-of-W2 at A going in adjDirAtA]
|
|
62
|
+
if (adjDirAtA) {
|
|
63
|
+
const n2 = perp(adjDirAtA);
|
|
64
|
+
const mL = lineIntersect(add(A, scale(n, h)), dir, add(A, scale(n2, h)), adjDirAtA);
|
|
65
|
+
const mR = lineIntersect(add(A, scale(n, -h)), dir, add(A, scale(n2, -h)), adjDirAtA);
|
|
66
|
+
if (mL) lA = mL;
|
|
67
|
+
if (mR) rA = mR;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Miter at B:
|
|
71
|
+
// Intersect [left-of-W at B going in dir] with [left-of-W2 at B going in adjDirAtB]
|
|
72
|
+
if (adjDirAtB) {
|
|
73
|
+
const n2 = perp(adjDirAtB);
|
|
74
|
+
const mL = lineIntersect(add(B, scale(n, h)), dir, add(B, scale(n2, h)), adjDirAtB);
|
|
75
|
+
const mR = lineIntersect(add(B, scale(n, -h)), dir, add(B, scale(n2, -h)), adjDirAtB);
|
|
76
|
+
if (mL) lB = mL;
|
|
77
|
+
if (mR) rB = mR;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `M ${lA.x} ${lA.y} L ${lB.x} ${lB.y} L ${rB.x} ${rB.y} L ${rA.x} ${rA.y} Z`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type { Vec2 };
|