@ahrowe/ui 0.25.1 → 0.26.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 (82) hide show
  1. package/dist/esm/common/floorPlan/floorPlan.faces.mjs +2 -0
  2. package/dist/esm/common/floorPlan/floorPlan.faces.mjs.map +1 -0
  3. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs +2 -0
  4. package/dist/esm/common/floorPlan/floorPlan.geometry.mjs.map +1 -0
  5. package/dist/esm/common/floorPlan/floorPlan.graph.mjs +2 -0
  6. package/dist/esm/common/floorPlan/floorPlan.graph.mjs.map +1 -0
  7. package/dist/esm/common/floorPlan/floorPlan.hit.mjs +2 -0
  8. package/dist/esm/common/floorPlan/floorPlan.hit.mjs.map +1 -0
  9. package/dist/esm/common/floorPlan/floorPlan.json.mjs +2 -0
  10. package/dist/esm/common/floorPlan/floorPlan.json.mjs.map +1 -0
  11. package/dist/esm/common/floorPlan/floorPlan.path.mjs +2 -0
  12. package/dist/esm/common/floorPlan/floorPlan.path.mjs.map +1 -0
  13. package/dist/esm/common/floorPlan/floorPlan.plan.mjs +2 -0
  14. package/dist/esm/common/floorPlan/floorPlan.plan.mjs.map +1 -0
  15. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs +2 -0
  16. package/dist/esm/common/floorPlan/floorPlan.rooms.mjs.map +1 -0
  17. package/dist/esm/common/floorPlan/floorPlan.snap.mjs +2 -0
  18. package/dist/esm/common/floorPlan/floorPlan.snap.mjs.map +1 -0
  19. package/dist/esm/common/floorPlan/floorPlan.types.mjs +2 -0
  20. package/dist/esm/common/floorPlan/floorPlan.types.mjs.map +1 -0
  21. package/dist/esm/common/floorPlan/legacySvg.mjs +2 -0
  22. package/dist/esm/common/floorPlan/legacySvg.mjs.map +1 -0
  23. package/dist/esm/common/hooks/useSheet.mjs +1 -1
  24. package/dist/esm/common/hooks/useSheet.mjs.map +1 -1
  25. package/dist/esm/common/planCanvas/planCanvas.mjs +2 -0
  26. package/dist/esm/common/planCanvas/planCanvas.mjs.map +1 -0
  27. package/dist/esm/common/planCanvas/planCanvas.module.mjs +2 -0
  28. package/dist/esm/common/planCanvas/planCanvas.module.mjs.map +1 -0
  29. package/dist/esm/common/roomDrawer/roomDrawer.mjs +1 -1
  30. package/dist/esm/common/roomDrawer/roomDrawer.mjs.map +1 -1
  31. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs +1 -1
  32. package/dist/esm/common/roomDrawer/roomDrawer.module.mjs.map +1 -1
  33. package/dist/esm/common/roomDrawer/usePlanHistory.mjs +2 -0
  34. package/dist/esm/common/roomDrawer/usePlanHistory.mjs.map +1 -0
  35. package/dist/esm/common/roomViewer/roomViewer.mjs +1 -1
  36. package/dist/esm/common/roomViewer/roomViewer.mjs.map +1 -1
  37. package/dist/esm/common/roomViewer/roomViewer.module.mjs +1 -1
  38. package/dist/esm/common/roomViewer/roomViewer.module.mjs.map +1 -1
  39. package/dist/esm/common/styles/sheet.module.mjs.map +1 -1
  40. package/dist/esm/index.mjs +1 -1
  41. package/dist/index.cjs +3 -7
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/style.css +1 -1
  44. package/dist/types/package/common/configProvider/configProvider.types.d.ts +4 -0
  45. package/dist/types/package/common/floorPlan/floorPlan.faces.d.ts +33 -0
  46. package/dist/types/package/common/floorPlan/floorPlan.fixtures.d.ts +35 -0
  47. package/dist/types/package/common/floorPlan/floorPlan.geometry.d.ts +111 -0
  48. package/dist/types/package/common/floorPlan/floorPlan.graph.d.ts +139 -0
  49. package/dist/types/package/common/floorPlan/floorPlan.hit.d.ts +26 -0
  50. package/dist/types/package/common/floorPlan/floorPlan.json.d.ts +23 -0
  51. package/dist/types/package/common/floorPlan/floorPlan.path.d.ts +59 -0
  52. package/dist/types/package/common/floorPlan/floorPlan.plan.d.ts +98 -0
  53. package/dist/types/package/common/floorPlan/floorPlan.rooms.d.ts +47 -0
  54. package/dist/types/package/common/floorPlan/floorPlan.snap.d.ts +101 -0
  55. package/dist/types/package/common/floorPlan/floorPlan.types.d.ts +276 -0
  56. package/dist/types/package/common/floorPlan/index.d.ts +28 -0
  57. package/dist/types/package/common/floorPlan/legacySvg.d.ts +27 -0
  58. package/dist/types/package/common/hooks/useSheet.d.ts +4 -1
  59. package/dist/types/package/common/planCanvas/index.d.ts +2 -0
  60. package/dist/types/package/common/planCanvas/planCanvas.d.ts +7 -0
  61. package/dist/types/package/common/planCanvas/planCanvas.types.d.ts +62 -0
  62. package/dist/types/package/common/roomDrawer/roomDrawer.d.ts +3 -3
  63. package/dist/types/package/common/roomDrawer/roomDrawer.types.d.ts +295 -15
  64. package/dist/types/package/common/roomDrawer/usePlanHistory.d.ts +24 -0
  65. package/dist/types/package/common/roomViewer/index.d.ts +1 -0
  66. package/dist/types/package/common/roomViewer/roomViewer.d.ts +9 -2
  67. package/dist/types/package/common/roomViewer/roomViewer.types.d.ts +88 -9
  68. package/dist/types/package/index.d.ts +1 -0
  69. package/docs/CLAUDE.md +1 -0
  70. package/docs/ConfigProvider.md +1 -0
  71. package/docs/Dropdown.md +1 -1
  72. package/docs/FloatingMenu.md +1 -1
  73. package/docs/FloorPlan.md +214 -0
  74. package/docs/Modal.md +2 -2
  75. package/docs/Popover.md +1 -1
  76. package/docs/RoomDrawer.md +330 -30
  77. package/docs/RoomViewer.md +80 -27
  78. package/package.json +1 -1
  79. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs +0 -6
  80. package/dist/esm/common/roomDrawer/roomDrawer.utils.mjs.map +0 -1
  81. package/dist/types/package/common/roomDrawer/roomDrawer.utils.d.ts +0 -32
  82. package/docs/room-drawing-analysis.md +0 -337
@@ -1,53 +1,353 @@
1
1
  # RoomDrawer
2
2
 
3
- **When to use:** Interactive SVG canvas for sketching floor plans — draw rooms as rectangles or polygons, place doors on walls, drag/reshape rooms. Use for venue layouts, seating plans, simple architectural mockups.
3
+ **When to use:** An interactive floor plan editor. Draw walls and rooms, cut doors and windows into them, and read the result back as data. Use it for venue layouts, apartment plans, seating maps, or anywhere a user needs to sketch a space and your app needs the geometry afterwards.
4
4
 
5
- **Keywords:** floor plan editor, venue layout editor
5
+ **Keywords:** blueprint, cad, sketch, grundriss, apartment, house, layout designer, interior, space planner, architect
6
6
 
7
7
  **Import:** `import { RoomDrawer } from '@ahrowe/ui'`
8
- **Types:** `import type { RoomDrawerProps, Tool, Point, RoomData, DoorData } from '@ahrowe/ui'`
8
+ **Types:** `import type { RoomDrawerProps, RoomDrawerHandle, RoomDrawerTool } from '@ahrowe/ui'`
9
+
10
+ The plan model is shared with `RoomViewer` and documented separately in [FloorPlan.md](FloorPlan.md). The one thing to know up front: **a wall between two rooms is a single wall**, and rooms are the enclosed spaces the walls form rather than shapes you draw. Draw two rooms flush against each other and you get seven walls, not eight, so a door cut into the boundary opens both rooms at once.
11
+
12
+ ```tsx
13
+ import { useState } from 'react';
14
+ import { RoomDrawer, emptyFloorPlan } from '@ahrowe/ui';
15
+ import type { FloorPlan } from '@ahrowe/ui';
16
+
17
+ // Controlled
18
+ const [plan, setPlan] = useState<FloorPlan>(() => emptyFloorPlan());
19
+ <RoomDrawer value={plan} onChange={setPlan} />
20
+
21
+ // Uncontrolled, reacting only to committed edits
22
+ <RoomDrawer defaultValue={emptyFloorPlan()} onChangeEnd={(plan) => save(plan)} />
23
+ ```
24
+
25
+ ## Tools
26
+
27
+ | Tool | Key | What it does |
28
+ |------|-----|--------------|
29
+ | `select` | `V` | Click to select, drag to edit |
30
+ | `wall` | `W` | Click to place corners, building a chain of single walls |
31
+ | `rect` | `R` | Drag out a rectangular room in one gesture |
32
+ | `door` | `D` | Click a wall to cut a door into it |
33
+ | `window` | `N` | Click a wall to cut a window |
34
+ | `archway` | `A` | Click a wall to cut an opening with no leaf |
35
+
36
+ ```tsx
37
+ // Restrict the toolbar, e.g. a kiosk that only places doors on an existing plan
38
+ <RoomDrawer value={plan} onChange={setPlan} tools={['select', 'door']} />
39
+ ```
40
+
41
+ **There is no "room" tool, and no room drawing mode.** Closing a shape with `rect` or `wall` produces a room by itself, because a room *is* an enclosed face. A mode that declared which walls belonged to which room would reintroduce the ownership relation that stops a door from cutting a shared wall in the first place. Free-standing interior walls work for the same reason: a wall with a loose end does not need to belong to anything.
42
+
43
+ **Dragging with `select`:**
44
+
45
+ - a corner reshapes the rooms that meet there, and dropping it onto another corner welds them
46
+ - a wall pushes it sideways along its own normal, **and the room on the other side moves with it** (that is what shared walls mean)
47
+ - an opening slides along its wall
48
+ - a room moves every corner it owns
49
+
50
+ ## Measuring without drawing
51
+
52
+ The `measure` tool is a ruler. Drag it across the plan and it reports length and angle, snapping to corners, walls and the grid like everything else, with `Shift` for a fixed angle.
53
+
54
+ **Measurements are not part of the plan.** They are not walls, they create no rooms, they never reach `onChange`, a downloaded file or the undo stack. That is deliberate rather than a shortcut: a line in the wall graph would close faces, and faces become rooms, so a "guide" stored as geometry would fight the derived-room model. If you need reference geometry that persists, draw a wall with `WallType.Virtual` instead — that *is* part of the plan and does divide space.
55
+
56
+ They **stay on screen after you let go, and through a change of tool**, because measuring is something you do before drawing rather than instead of it.
57
+
58
+ While the ruler is the active tool, measurements are editable: drag either **end** to adjust one, drag the **line itself** to slide the whole thing, and `Delete` removes the one under the pointer. `Escape` clears them all, as does `handle.clearMeasurements()`. Under any other tool they are inert scenery, so their handles never compete with the plan's own — and `Delete` while the ruler is up only ever touches measurements, never the plan.
59
+
60
+ ## Selecting several things
61
+
62
+ Two gestures, because they suit different jobs:
63
+
64
+ - **Ctrl/Cmd-click** adds or removes one thing at a time, leaving the rest of the selection alone. Precise, and works on rooms, walls, corners and openings alike.
65
+ - **Drag a marquee** across empty canvas with the select tool. Bulk, and it catches **corners** — a room is not a primitive you can enclose, it is whatever its corners form, and selecting the corners is what makes a drag move it. Hold Ctrl/Cmd while dragging to add to the selection rather than replace it.
66
+
67
+ `Ctrl/Cmd+A` selects every room, or every corner in a plan with no enclosed rooms yet. A plain click on empty canvas clears; a Ctrl-click on empty canvas does not, so a near-miss never wipes what you had.
68
+
69
+ **Shift is deliberately not a multi-select modifier here.** It is already the momentary angle-lock override, and overloading it would make two good features fight over the same key.
70
+
71
+ **Moving a mixed selection translates the union of its corners.** A room is its face's corners, a wall is its two ends, a corner is itself, so a selection holding all three moves as one rigid body without any special cases. Pressing something already selected drags the whole set; pressing anything else selects just it. Arrow keys nudge the whole set, and Delete removes all of it.
72
+
73
+ Corners you picked explicitly are drawn filled; corners that came along because their wall or room was selected are outlined, so it is always clear what a drag would actually move. Once more than one thing is selected the inspector shows a count rather than per-entity fields, since a name or thickness box would otherwise be silently editing whichever entry happened to come first.
74
+
75
+ Set `multiSelect={false}` to keep the drawer single-select: Ctrl-click then just selects, the marquee is disabled, and `Ctrl+A` does nothing.
76
+
77
+ **Adding a corner mid-wall.** Selecting a wall puts a hollow dot at its midpoint. Dragging that dot splits the wall in two and places the new corner in one gesture, which is how a straight wall becomes an L. Clicking it without dragging does nothing, so a stray tap never silently adds a corner. The inspector's **Add corner** button does the same thing for keyboard users, splitting at the midpoint and selecting the result so the arrow keys can move it.
78
+
79
+ A corner added this way divides the wall, not the room: the room count is unchanged until walls actually enclose a new space. Deleting the corner again rejoins the two walls into one, straightening the bend out rather than leaving a hole where it was — see the Delete rules below.
80
+
81
+ ## Snapping and angles
82
+
83
+ Three latching toggles sit in the toolbar, each with a hotkey:
84
+
85
+ | Toggle | Key | What it does |
86
+ |--------|-----|--------------|
87
+ | Snap | `S` | Snap to corners, walls, wall crossings, wall extensions, alignments with existing corners, and the grid |
88
+ | Angle lock | `L` | Constrain every new or dragged point to a multiple of `constrainAngle` (45° by default) |
89
+ | Grid | `G` | Show the grid |
90
+
91
+ `Shift` and `Alt` are **momentary overrides of whatever is latched**, not one-way switches. `Shift` turns angle lock on while held, or off while held if it is already latched on. `Alt` does the same for snapping, so it doubles as "snap just this once" when snapping is off.
92
+
93
+ **Angle lock is a hard constraint, not a suggestion.** Once it is on, the point stays on the ray however far the pointer wanders, and a nearby corner can only win if it also lies on that ray. Anything else would mean the lock quietly giving up exactly when you lean on it.
94
+
95
+ **What it constrains depends on what you are doing.** Drawing a wall, it constrains the wall's own direction from the point it starts at. Dragging a corner, it constrains the **direction of travel from where the corner started**, so all eight directions are reachable from the corner you are holding, the same as Shift-dragging in any design tool.
96
+
97
+ **Angles are measured from the wall you are continuing as well as from the page.** Drawing a chain, the lock offers multiples of the step from the world axes *and* from the direction of the previous segment, and picks whichever the pointer is nearer. That is what makes it useful on a building that is not aligned to the page: `90°` to the wall you just drew is available even when that wall runs at 37°. The readout marks a relative angle with `rel`. Dragging a corner works the same way, measuring from the other wall meeting there.
98
+
99
+ **You can see what it is doing.** The construction lines that explain each snap are drawn as you move (`showSnapGuides={false}` turns them off), the snapped point gets a small marker, and the live readout shows length and angle together, highlighted while the lock is holding it.
100
+
101
+ **What gets a measurement depends on what you selected**, because in each case a different set of walls is the one actually changing:
102
+
103
+ - a **corner**: every wall meeting it, since reshaping the corner changes all of them and one figure would be ambiguous about which
104
+ - a **wall**: that wall, plus the walls running off each of its ends. Dragging a wall translates both its endpoints together, so its own length is precisely the measurement that *cannot* change while the neighbours are the ones being stretched
105
+ - a **room being drawn** with the room tool: both sides and the area, each sitting on what it measures. The figures come off the wall centrelines, which is also where the finished room takes its own area from, so the number does not jump the moment you let go
106
+ - a **room being dragged**: how far it has moved, plus any wall straddling its boundary. A room drag translates its whole face rigidly, so its own walls and area are all frozen; the offset and the walls bridging to its neighbours are the only things moving. These appear during the drag and clear on release, since an offset has no meaning at rest
107
+
108
+ Neighbours are drawn dimmer than the wall or corner you are actually moving: they are the consequence of the edit rather than its subject. All of them update live during the drag.
109
+
110
+ Two details that only matter if you are reasoning about why a drag behaves the way it does. The walls attached to the corner being dragged are excluded from snapping, since they move with it and would otherwise make the corner snap to itself. And the direction angle lock measures from is captured once when the drag starts, taken from a wall whose endpoints do not move, rather than recomputed each frame from the corner in flight.
9
111
 
10
112
  ```tsx
11
- import { RoomDrawer } from '@ahrowe/ui';
113
+ // Start latched to 30° steps, for a non-rectangular building
114
+ <RoomDrawer value={plan} onChange={setPlan} angleLock constrainAngle={30} />
12
115
 
13
- <RoomDrawer className="my-floor-plan" />
116
+ // No snapping at all, and no guides
117
+ <RoomDrawer value={plan} onChange={setPlan} snap={false} showSnapGuides={false} />
14
118
  ```
15
119
 
16
- **Built-in tools** (selectable via the in-canvas toolbar):
120
+ The `snap`, `angleLock` and `showGrid` props seed the toggles; changing one resets that latch.
121
+
122
+ ## Keyboard
123
+
124
+ | Key | Action |
125
+ |-----|--------|
126
+ | `V` `W` `R` `D` `N` `A` | Switch tool |
127
+ | `S` / `L` / `G` | Toggle snapping / angle lock / grid |
128
+ | `Ctrl/Cmd+A` | Select everything |
129
+ | `Ctrl/Cmd`-click | Add or remove one thing from the selection |
130
+ | `Shift` (hold) | Momentarily invert angle lock |
131
+ | `Alt` (hold) | Momentarily invert snapping |
132
+ | `Esc` | Cancel the wall chain, or clear the selection |
133
+ | `Enter` | Finish an open wall chain |
134
+ | `Delete` / `Backspace` | Delete the selection |
135
+ | `Ctrl/Cmd+Z` | Undo |
136
+ | `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | Redo |
137
+ | `+` / `-` | Zoom |
138
+ | `0` | Fit the plan to the viewport |
139
+ | Arrow keys | Nudge the selection by one grid step (`Shift` for ten) |
17
140
 
18
- - `select` — drag rooms / corners, click walls to add corners, double-click corners to remove
19
- - `rect` — drag to draw a rectangular room
20
- - `polygon` — click to place corners, click first point or press Enter to close, Esc to cancel
21
- - `door` — click on a wall segment to place a door
141
+ Keyboard handling is scoped to the drawer's own focused root, so a drawer on the page never swallows `Delete` or `+` from the rest of your app.
22
142
 
23
- **Keyboard shortcuts:** `Esc` cancel · `Enter` close polygon · `+` / `-` zoom · `Delete` erase selection
143
+ **What Delete removes**, which is the one genuinely ambiguous case:
24
144
 
25
- **Type exports:**
145
+ - a corner **between exactly two walls**: the corner only. The two walls rejoin into one and the rejoined wall is selected, so deleting a bend straightens it out instead of punching a hole in the plan. Any openings on the two walls move onto the merged wall, rescaled to fit its new length.
146
+ - a corner at a **loose end or a junction of three or more walls**: the walls meeting there, since there is nothing to rejoin them into
147
+ - a wall: the wall and its openings
148
+ - an opening: just the opening, and the wall heals
149
+ - a room: its name, and **only the walls no other room is using**. A wall the room next door still needs stays.
26
150
 
27
- ```ts
28
- type Tool = 'select' | 'rect' | 'polygon' | 'door';
151
+ ## Pointer and touch
29
152
 
30
- interface Point { x: number; y: number }
153
+ Built on Pointer Events, so mouse, touch and pen behave the same. Pinch to zoom, two-finger or middle-button drag to pan, or hold space and drag. Scroll wheel zooms at the cursor. Hit tolerances are measured in screen pixels and converted through the current zoom, so a corner is just as easy to grab at any zoom level, and they widen automatically on a coarse (touch) pointer.
31
154
 
32
- interface RoomData {
33
- id: string;
34
- name: string;
35
- points: Point[];
36
- }
155
+ ## Controlled, uncontrolled, and the two change callbacks
37
156
 
38
- interface DoorData {
39
- id: string;
40
- roomId: string;
41
- wallIndex: number; // wall index in room.points; wall goes from points[i] to points[(i+1)%n]
42
- t: number; // 0–1 position along that wall
43
- width: number; // door width in canvas units
44
- }
157
+ `onChange` fires continuously, including for the intermediate values during a drag. `onChangeEnd` fires once per completed gesture or discrete edit. This mirrors `Slider`'s pair: render from `onChange`, persist from `onChangeEnd`.
158
+
159
+ ```tsx
160
+ <RoomDrawer
161
+ value={plan}
162
+ onChange={setPlan} // every frame of a drag
163
+ onChangeEnd={(plan) => savePlan(plan)} // once, when the drag lands
164
+ />
45
165
  ```
46
166
 
47
- **Key props:**
167
+ Both receive a `meta` argument: `{ reason, committed, targetIds }`. `committed` is `false` for mid-gesture values and `true` for the one that also went onto the undo stack.
168
+
169
+ ## Undo and redo
170
+
171
+ On by default, in both controlled and uncontrolled mode: a consumer who has to implement undo in order to get undo will not get undo. A drag is **one** entry, not one per pointer move, and consecutive edits of the same kind on the same target (typing a room name, spinning a number) merge rather than flooding the stack.
172
+
173
+ ```tsx
174
+ <RoomDrawer value={plan} onChange={setPlan} historyMode="none" /> // you own history
175
+ ```
176
+
177
+ One thing worth knowing: when `value` changes from outside the component (a load, a server patch), that counts as one history entry, so a subsequent Undo returns to the plan that existed before it. Call `handle.clearHistory()` after such a write if that is not what you want.
178
+
179
+ ## Imperative handle
180
+
181
+ ```tsx
182
+ const ref = useRef<RoomDrawerHandle>(null);
183
+ <RoomDrawer ref={ref} value={plan} onChange={setPlan} />
184
+
185
+ ref.current?.fitToContent();
186
+ ref.current?.undo();
187
+ ref.current?.select({ type: 'room', id: roomId });
188
+ ref.current?.deleteSelection();
189
+ ```
190
+
191
+ | Method | Description |
192
+ |--------|-------------|
193
+ | `getPlan()` / `setPlan(plan, options?)` | Read or replace the plan. `{ resetHistory: true }` drops the undo stack |
194
+ | `getFloorId()` / `setFloorId(id)` | The storey being edited |
195
+ | `addFloor(options?)` / `duplicateFloor()` | Add an empty storey above, or copy this one. Both switch to the new floor and return its id |
196
+ | `removeFloor(id?)` / `renameFloor(id, label)` | Remove (never the last one) or rename a storey |
197
+ | `moveFloor(id, toIndex)` / `setFloorHeight(id, mm)` | Reorder a storey, or set its height. Both re-stack the elevations |
198
+ | `clear()` | Empty the plan, as one history entry |
199
+ | `clearMeasurements()` | Remove every ruler measurement. View state, so it never reaches `onChange` |
200
+ | `download(fileName?)` | Write the plan to a JSON file, as the toolbar button does |
201
+ | `loadJson(text)` | Load JSON text as a plan, as one undoable entry. Returns a `PlanParseResult` |
202
+ | `undo()` / `redo()` / `canUndo()` / `canRedo()` / `clearHistory()` | History |
203
+ | `getSelection()` / `select(selection)` | Read or replace the selection. `select` also accepts a single entry or `null` |
204
+ | `addToSelection(entries)` / `toggleSelection(entry)` / `selectAll()` | Adjust the selection without replacing it |
205
+ | `deleteSelection()` | Delete everything selected |
206
+ | `getView()` / `setView(view)` / `zoomBy(factor)` / `fitToContent(options?)` | Viewport |
207
+ | `toWorld(clientX, clientY)` / `toScreen(point)` | Coordinate conversion |
208
+ | `getSvgElement()` | The live `<svg>`, for measuring or screenshotting |
209
+
210
+ ## Key props
48
211
 
49
212
  | Prop | Type | Description |
50
213
  |------|------|-------------|
51
- | `className` | `string` | Root element class |
214
+ | `value` / `defaultValue` | `FloorPlan` | Controlled / initial plan |
215
+ | `onChange` | `(plan, meta) => void` | Every mutation, including mid-drag |
216
+ | `onChangeEnd` | `(plan, meta) => void` | Once per committed edit |
217
+ | `floorId` | `string` | Which floor to edit (default: the first) |
218
+ | `tool` / `defaultTool` / `onToolChange` | `RoomDrawerTool` | Active tool |
219
+ | `tools` | `RoomDrawerTool[]` | Restricts and orders the toolbar |
220
+ | `selection` / `defaultSelection` / `onSelectionChange` | `PlanSelection[]` | What is selected. An array: a single selection is a one-element array |
221
+ | `multiSelect` | `boolean` | Allow selecting several things at once (default `true`) |
222
+ | `onHoverChange` | `(target) => void` | Fires as the pointer moves over entities |
223
+ | `view` / `defaultView` / `onViewChange` | `PlanView` | Viewport (`x`, `y`, `zoom` in screen px per mm) |
224
+ | `minZoom` / `maxZoom` | `number` | Zoom bounds (default `0.005` / `2`) |
225
+ | `fitOnMount` | `boolean` | Fit the plan into view once there is something to fit (default `true`) |
226
+ | `gridSize` | `number` | Grid spacing in millimetres (default `100`) |
227
+ | `showGrid` | `boolean` | Default `true` |
228
+ | `snap` | `boolean` | Seeds the snap toggle (default `true`); `Alt` inverts it while held |
229
+ | `angleLock` | `boolean` | Seeds the angle lock toggle (default `false`); `Shift` inverts it while held |
230
+ | `constrainAngle` | `number` | Angle step in degrees that the lock snaps to (default `45`) |
231
+ | `showSnapGuides` | `boolean` | Draw the construction lines explaining each snap (default `true`) |
232
+ | `wallThickness` / `wallHeight` | `number` | Millimetres, applied to newly drawn walls |
233
+ | `openingDefaults` | `Partial<Record<OpeningKind, { width?, sill?, head? }>>` | Size of newly placed openings |
234
+ | `showMeasurements` | `boolean` | Live length, angle and area readouts while drawing (default `true`) |
235
+ | `formatLength` / `formatArea` | `(mm) => string` | Override the readouts |
236
+ | `showToolbar` / `showInspector` / `showHint` / `showHistoryControls` / `showFileControls` / `showFloorBar` | `boolean` | Hide parts of the chrome |
237
+ | `floorId` / `defaultFloorId` / `onFloorChange` | `string` / `string` / `(id) => void` | Which storey is being edited |
238
+ | `showUnderlay` | `boolean` | Trace the storey below behind this one (default `true`) |
239
+ | `fileName` | `string` | Name for the downloaded file; `.json` is appended if missing. Defaults to the plan's `name`, else `floor-plan.json` |
240
+ | `onImport` | `(result: PlanParseResult) => void` | Fires after every file opened, whether it worked or not |
241
+ | `historyMode` | `'internal' \| 'none'` | Default `'internal'` |
242
+ | `historyLimit` | `number` | Maximum undo depth (default `50`) |
243
+ | `readOnly` | `boolean` | View and select, but no edits |
244
+ | `disabled` | `boolean` | No interaction at all |
245
+ | `canvasHeight` | `string` | Any CSS length (default `'420px'`) |
246
+ | `getRoomColor` / `renderRoomLabel` | | Per-room fill colour and label content |
247
+ | `labels` / `icons` | `RoomDrawerLabels` / `RoomDrawerIcons` | Localise or re-icon the chrome |
248
+
249
+ `RoomDrawerProps` extends the full native `div` attribute set plus `data-*`, so `id`, `title`, `aria-*` and the rest pass through to the root.
250
+
251
+ ## Floors
252
+
253
+ A plan holds a stack of storeys, and the drawer edits one at a time. The strip above the canvas lists them **top first**, the way a building reads in section, with `+` to add an empty storey above the current one.
254
+
255
+ ```tsx
256
+ <RoomDrawer defaultFloorId={groundId} onFloorChange={setFloorId} /> {/* uncontrolled */}
257
+ <RoomDrawer floorId={floorId} onFloorChange={setFloorId} /> {/* controlled */}
258
+ <RoomDrawer showFloorBar={false} /> {/* drive it yourself */}
259
+ ```
260
+
261
+ With nothing selected, the selection bar becomes the floor's own: rename it, set its **storey height**, move it **up or down the stack**, **copy** it, or delete it. Copying is the usual way to start an upper storey, since the walls below are mostly where the walls above go — it duplicates the geometry with fresh ids, so the two storeys share nothing and editing one never reaches into the other.
262
+
263
+ **The storey below is traced behind the one you are editing**, faint and dashed, so walls line up between floors. It is a reference only: it takes no pointer events and is never part of what you are drawing. The toolbar toggle turns it off, and it only appears when there is a floor underneath.
264
+
265
+ ```tsx
266
+ <RoomDrawer showUnderlay={false} />
267
+ ```
268
+
269
+ **Elevations re-stack themselves.** Every storey sits on the one below, so inserting a floor lifts the ones above it, removing one drops them, and raising a storey's height pushes everything over it up. The building's own ground level stays put. Nothing in a 2D plan would ever show a floor buried in the one beneath it, which is why this is not left to the caller.
270
+
271
+ Reordering is how a **basement** gets made, since `+` always adds above: add a storey, then move it to the bottom. It is otherwise for fixing a building whose floors were drawn in the wrong order.
272
+
273
+ Every floor edit — add, copy, delete, rename, reorder, height — goes through history, so `Ctrl+Z` takes it back like any other edit.
274
+
275
+ Floors are part of the plan document, so they survive a download and reopen without anything extra.
276
+
277
+ ## The selection bar
278
+
279
+ A bar under the canvas shows what is selected and what can be done with it: a name for a room, thickness for a wall, width and sill for an opening, and delete for any of them. It is **always there and always the same height**, including when nothing is selected — it used to appear on selection and size itself to its contents, which moved the canvas out from under the pointer by up to 116px as you clicked between a corner and a wall.
280
+
281
+ Each selection is named on the left (`Room`, `Wall`, `Door`, `Corner`, `3 selected`), which is the whole content of the bar for a corner: a corner has nothing to edit that dragging it does not already do. Delete sits at the far end, apart from the fields, since it is the one action that applies to every kind of selection and the only destructive one. Override any of the names through `labels` (`selectedRoom`, `selectedWall`, `selectedCorner`, `selectedOpening`, `selectedCount`, `selectedNothing`), and hide the bar with `showInspector={false}`.
282
+
283
+ **Sill**, on an opening, is how far above the floor it starts: 0 for a door, around 900 for a window. It changes nothing in the plan, because a plan is a horizontal cut and every opening looks the same from above. It is stored so the same plan can drive a 3D view later without anyone having to supply the heights again — as are `head` (the top of the opening), `PlanWall.height` and `PlanFloor.elevation`.
284
+
285
+ ## Saving and opening files
286
+
287
+ The toolbar has a download button and an open button. Download writes the plan as JSON; open reads one back. That is the whole round trip, and the JSON is the interchange format: it is what `RoomViewer` takes, and it is stable across versions in a way a rendered SVG is not.
288
+
289
+ ```tsx
290
+ <RoomDrawer showFileControls={false} /> {/* build your own UI instead */}
291
+ <RoomDrawer fileName="flat-3.json" /> {/* default: the plan's own name */}
292
+ <RoomDrawer onImport={(r) => console.log(r.issues)} />
293
+ ```
294
+
295
+ **A file off a disk is untrusted, so opening one repairs rather than trusts.** `planFromJson` rebuilds the plan field by field instead of casting what `JSON.parse` returned, so nothing unchecked reaches the canvas, and it reports everything it had to do:
296
+
297
+ - a wall whose corners are missing, or an opening whose wall is missing, is **dropped** — there is no way to guess where it was meant to go
298
+ - a room whose walls have gone is **kept**, because losing geometry is recoverable by redrawing it and losing the name someone typed is not. Re-adding the wall re-matches the room by itself
299
+ - a thickness of `"250"` is read as `250`, and an unrecognised wall type or opening kind falls back to a known one
300
+ - a file is only **refused** outright when it is not JSON, is not an object, has no floors, or carries a format version this build does not know. Refusing beats guessing there: a later version may mean something different by the same field names
301
+
302
+ Anything that had to be repaired appears in the hint line under the canvas, and in `onImport`. A clean file says nothing, because the canvas changing is the report. Opening a file goes through history, so `Ctrl+Z` puts back what was there before it.
303
+
304
+ To accept a file from your own UI, a drop target or a server, hand the text to `loadJson`:
305
+
306
+ ```tsx
307
+ ref.current?.loadJson(await file.text());
308
+ ```
309
+
310
+ ## Theming
311
+
312
+ All colours come from theme variables. These control the plan itself, and each falls back to a general theme colour when unset:
313
+
314
+ | Variable | Falls back to |
315
+ |----------|---------------|
316
+ | `--plan-canvas-background` | `var(--background)` |
317
+ | `--plan-grid-color` | `var(--text-color)` at low opacity |
318
+ | `--plan-room-fill` | `var(--background-accent)` |
319
+ | `--plan-room-fill-hover` | `var(--background-accent-light)` |
320
+ | `--plan-room-edge-selected` / `--plan-room-edge-selected-fill` | `var(--primary-color)` / `var(--primary-lighter)` |
321
+ | `--plan-wall-fill` | `var(--text-dark)` |
322
+ | `--plan-wall-outline` | `var(--text-color)` |
323
+ | `--plan-wall-miter-limit` | `4` (see below) |
324
+ | `--plan-door-color` | `var(--text-dark)` |
325
+ | `--plan-window-color` | `var(--info-color)` |
326
+
327
+ **`--plan-wall-miter-limit`** is how sharp a corner may get before it is cut flat rather than run to a point. A mitred join extends to `1 / sin(angle / 2)` times the wall thickness, so it grows without bound as the angle closes: at 10 degrees that is eleven times the wall's own thickness, which reads as a spike fired out of the corner. The default of `4` keeps a true mitred point down to about 29 degrees and caps it at twice the wall's own thickness; sharper than that the corner is cut flat instead. Acute rooms bottom out around 45 degrees in practice and anything under 30 is not a room, so the corners that keep their point are the ones a building actually has. Raise it if you are drawing something genuinely needle-shaped and want the point kept. It governs the corners at a junction too, on the rare occasion one needs filling: where three or more walls meet, the corners between them are normally closed already, except when every wall at the node points into the same half-plane.
328
+
329
+ **Hover tints the room's floor; selection colours the walls that bound it.** They answer different questions, so they get different channels and can be read at the same time without being confused. Hover is fleeting and asks *which room is under the pointer*, where a faint fill is instant and unambiguous — a wall is not, being shared between the rooms on either side of it. Selection is state you then work inside, where a wash over the room would bury its doors and its label.
330
+
331
+ The selection highlight is built from **the same geometry as the walls themselves** and drawn in the same two passes, so it inherits their mitred corners and their gaps at openings: a door in the boundary stays a doorway rather than being painted over, and the wall keeps a crisp edge instead of turning into a flat slab. Where two selected rooms share a wall it is drawn once, so it never comes out twice as strong as its neighbours. The room fill carries `data-state="selected" | "hovered"` if you want to style either state further.
332
+
333
+ ## Accessibility
334
+
335
+ Stated plainly: **a free-form drawing canvas cannot be fully operated from a keyboard**, and this one is not. What is keyboard-operable is tool switching, selection, nudging, deletion, undo/redo, zoom, fit, and the whole inspector (renaming a room, retyping a wall thickness, changing an opening's width). Creating geometry needs a pointer; build it in code through `value` instead, using the operations in [FloorPlan.md](FloorPlan.md).
336
+
337
+ The root is `role="application"` (justified here, since single-letter keys are intercepted), the toolbar is a real `role="toolbar"` of `<button>`s with `aria-pressed`, and room labels render with a halo so they stay legible over any fill colour a consumer sets.
338
+
339
+ ## Global defaults
340
+
341
+ Adopts `ConfigProvider`, e.g. `defaultProps={{ RoomDrawer: { gridSize: 250, showGrid: false } }}`. See [ConfigProvider.md](ConfigProvider.md).
342
+
343
+ ## Migration from the previous version
344
+
345
+ The previous `RoomDrawer` had no data contract at all: `RoomDrawerProps` was `{ className }`, and the only way data left the component was a browser file download. Everything below is new rather than changed, so the old usage still renders, it just starts empty.
346
+
347
+ - `Tool`, `RoomData`, `DoorData`, `Point` are gone. Use `RoomDrawerTool` and the `FloorPlan` model.
348
+ - `selection` is an array rather than a single entry, since the drawer supports multi-select. A single selection is `[entry]`, and nothing selected is `[]` rather than `null`.
349
+ - The SVG download and upload buttons are gone. The plan JSON is the interchange format now. To read an SVG exported by the old version, use `planFromLegacySvg` (see [FloorPlan.md](FloorPlan.md)), which also collapses each duplicated room boundary into the single shared wall it should have been.
350
+ - The `polygon` tool is now `wall`, which draws open chains as well as closed ones.
351
+ - Removing a corner was a double-click, which was undiscoverable and impossible on touch. Select it and press `Delete` instead.
52
352
 
53
- **Note:** Room and door state is currently managed internally. Use the exported `RoomData` / `DoorData` types when wiring the data into your own persistence layer.
353
+ **Slots:** `root` `toolbar` `toolButton` `toolIcon` `viewControls` `viewButton` `zoomLabel` `canvasWrapper` `canvas` `grid` `rooms` `roomFill` `roomLabel` `walls` `wallBody` `openings` `opening` `overlay` `measureLine` `floorBar` `floorTab` `inspector` `inspectorLabel` `inspectorField` `hint`
@@ -1,41 +1,94 @@
1
1
  # RoomViewer
2
2
 
3
- **When to use:** Display read-only SVG floor plans with a floor selector — venue maps, seating layouts, building directories. Pair with `RoomDrawer` when you also need editing capability.
3
+ **When to use:** to show a floor plan someone can look at and click, without letting them change it — a building directory, a booking screen, a dashboard beside a room list.
4
4
 
5
- **Keywords:** floor plan viewer
6
-
7
- **Import:** `import { RoomViewer } from '@ahrowe/ui'`
8
- **Types:** `import type { RoomViewerFloor, RoomViewerProps } from '@ahrowe/ui'`
5
+ **Keywords:** map, site plan, wayfinding, hotspot, clickable regions, blueprint, storey, apartment, read-only
9
6
 
10
7
  ```tsx
11
8
  import { RoomViewer } from '@ahrowe/ui';
12
- import type { RoomViewerFloor } from '@ahrowe/ui';
13
-
14
- const floors: RoomViewerFloor[] = [
15
- {
16
- label: 'Ground Floor',
17
- data: <GroundFloorSvg />,
18
- },
19
- {
20
- label: 'First Floor',
21
- data: <FirstFloorSvg />,
22
- },
23
- ];
24
-
25
- <RoomViewer data={floors} />
9
+ import type { RoomViewerProps, RoomViewerHandle } from '@ahrowe/ui';
26
10
  ```
27
11
 
28
- **RoomViewerFloor:**
12
+ It renders a **`FloorPlan`** — the same document [RoomDrawer](./RoomDrawer.md) edits and downloads, drawn on the same canvas — so the two can never drift apart. See [FloorPlan](./FloorPlan.md) for the model.
13
+
14
+ ```tsx
15
+ <RoomViewer plan={plan} onSelect={(roomId, room) => console.log(roomId, room?.area)} />
16
+ ```
17
+
18
+ ## Selecting rooms
19
+
20
+ Rooms are clickable and keyboard-reachable: each is a `role="button"` with `aria-pressed`, a `<title>` naming it, and `Enter` / `Space` to select. Because clicking a shape is silent to a screen reader, the selection is also announced through an `aria-live` region.
21
+
22
+ Selection, hover and the active floor are each controlled or uncontrolled:
29
23
 
30
- ```ts
31
- interface RoomViewerFloor {
32
- label: string; // Tab/selector label shown above the plan
33
- data: React.ReactNode; // SVG floor plan element
34
- }
24
+ ```tsx
25
+ // Uncontrolled
26
+ <RoomViewer plan={plan} defaultSelectedRoomId={roomId} onSelect={setRoomId} />
27
+
28
+ // Controlled, which is what a room list beside the plan needs
29
+ <RoomViewer
30
+ plan={plan}
31
+ selectedRoomId={selectedId}
32
+ onSelect={(id) => setSelectedId(id)}
33
+ hoveredRoomId={hoveredId}
34
+ onHoverChange={setHoveredId}
35
+ />
35
36
  ```
36
37
 
37
- **Key props:**
38
+ `onSelect(roomId, room, floorId)` hands back the derived room, so its `area`, `outer` ring and `wallIds` are there without a second lookup. `roomId` is `null` when the selection is cleared by clicking away.
39
+
40
+ `interactive={false}` makes it a picture: no focus stops, no hit targets, no callbacks.
41
+
42
+ **Hover tints the room's floor; selection colours the walls that bound it** — two channels, so a hovered room and a selected one can be told apart at a glance. Hover answers *which room is this* and a fill is instant; a wall is shared, so colouring one is ambiguous between the rooms on either side. Selection is state you work inside, where a wash over the room would hide its doors and label. Override with `--plan-room-fill-hover` and `--plan-room-edge-selected`, or style either through `classNames.roomFill` and its `data-state` attribute.
43
+
44
+ ## Floors
45
+
46
+ A plan holds a stack of storeys and the viewer shows one at a time, with a tab per floor listed **top first**, the way a building reads in section. The strip hides itself for a single-storey plan.
47
+
48
+ ```tsx
49
+ <RoomViewer plan={plan} activeFloorId={floorId} onFloorChange={setFloorId} />
50
+ <RoomViewer plan={plan} showFloorBar={false} />
51
+ ```
52
+
53
+ ## Panning and zooming
54
+
55
+ Drag to pan, wheel or pinch to zoom, and the controls in the corner zoom and fit. The plan fits itself to the viewport on mount, and refits when the floor or the canvas size changes — **until the viewer pans or zooms**, after which the view is theirs and the component stops moving it. `pannable={false}` freezes it.
56
+
57
+ ## Key props
38
58
 
39
59
  | Prop | Type | Description |
40
60
  |------|------|-------------|
41
- | `data` | `RoomViewerFloor[]` | Floor definitions to display |
61
+ | `plan` | `FloorPlan` | The plan to show. Omit for the empty state |
62
+ | `activeFloorId` / `defaultActiveFloorId` / `onFloorChange` | `string` / `string` / `(id) => void` | Which storey is showing |
63
+ | `selectedRoomId` / `defaultSelectedRoomId` / `onSelect` | `string \| null` / … / `(roomId, room, floorId) => void` | Selection |
64
+ | `hoveredRoomId` / `onHoverChange` | `string \| null` / `(roomId) => void` | Hover, for cross-highlighting |
65
+ | `view` / `defaultView` / `onViewChange` | `PlanView` | Pan and zoom |
66
+ | `interactive` | `boolean` | Rooms respond to pointer and keyboard (default `true`) |
67
+ | `pannable` | `boolean` | Drag to pan, wheel and pinch to zoom (default `true`) |
68
+ | `fitOnMount` | `boolean` | Fit the plan into view (default `true`) |
69
+ | `showFloorBar` / `showViewControls` / `showGrid` / `showRoomLabels` / `showOpeningSymbols` | `boolean` | Chrome and layers |
70
+ | `getRoomColor` / `renderRoomLabel` / `getRoomLabel` / `formatArea` | functions | Colour, label and area rendering |
71
+ | `height` | `number \| string` | Canvas height (default `360`) |
72
+ | `emptyState` | `ReactNode` | Shown when there is no plan, or nothing in it |
73
+ | `labels` / `icons` | objects | Every string and icon in the chrome |
74
+
75
+ **Imperative handle:** `fitToContent(options?)`, `getView()`, `setView(view)`, `zoomBy(factor)`, `select(roomId)`, `getFloorId()`, `setFloorId(id)`, `getSvgElement()`.
76
+
77
+ **Slots:** `root` `floorBar` `floorTab` `canvasWrapper` `canvas` `grid` `rooms` `roomFill` `roomLabel` `walls` `wallBody` `openings` `opening` `viewControls` `viewButton` `empty`
78
+
79
+ ## Accessibility, honestly
80
+
81
+ A free-form plan cannot be fully keyboard operated, and this does not pretend otherwise. What it does give you: every room is a focusable `role="button"` with an accessible name, `Enter`/`Space` select, the floor tabs are real buttons, the zoom controls are real buttons, and the current selection is announced. Panning and zooming are pointer-only; the zoom buttons and `fitToContent()` are the keyboard path.
82
+
83
+ ## Migration from the previous version
84
+
85
+ The old viewer took `data: { label, data: ReactNode }[]` — an array of **pre-rendered SVG elements** — and found rooms by looking for `id="room…"`, highlighting them by mutating `classList`. That is gone, and there is no shim: the old `data` was a React element rather than a string, so there is nothing to parse at runtime.
86
+
87
+ | Before | Now |
88
+ |--------|-----|
89
+ | `data={[{ label, data: <svg/> }]}` | `plan={plan}` — one `FloorPlan`, floors and all |
90
+ | `id="room12"` convention | `PlanRoom` ids in the model |
91
+ | Highlighting via `classList` | `selectedRoomId` / `hoveredRoomId` |
92
+ | No selection callback | `onSelect(roomId, room, floorId)` |
93
+
94
+ To bring an old SVG across, run it through `planFromLegacySvg` once and keep the resulting plan. See [FloorPlan](./FloorPlan.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,6 +0,0 @@
1
- function e(e){return Math.round(e/5)*5}function t(t){return{x:e(t.x),y:e(t.y)}}function n(e,n,r){let i=r.createSVGPoint();i.x=e,i.y=n;let a=r.getScreenCTM();if(!a)return{x:0,y:0};let o=i.matrixTransform(a.inverse());return t({x:o.x,y:o.y})}function r(e,t){return Math.sqrt((e.x-t.x)**2+(e.y-t.y)**2)}function i(e,t,n){let i=n.x-t.x,a=n.y-t.y,o=i*i+a*a;if(o===0)return{dist:r(e,t),t:0};let s=Math.max(0,Math.min(1,((e.x-t.x)*i+(e.y-t.y)*a)/o)),c=t.x+s*i,l=t.y+s*a;return{dist:Math.sqrt((e.x-c)**2+(e.y-l)**2),t:s}}function a(e,t){let n=null;for(let r of t){let t=r.points;for(let a=0;a<t.length;a++){let o=t[a],s=t[(a+1)%t.length],{dist:c,t:l}=i(e,o,s);c<=8&&(!n||c<n.dist)&&(n={roomId:r.id,wallIndex:a,t:l,dist:c})}}return n}function o(e,t){let n=!1;for(let r=0,i=t.length-1;r<t.length;i=r++){let a=t[r].x,o=t[r].y,s=t[i].x,c=t[i].y;o>e.y!=c>e.y&&e.x<(s-a)*(e.y-o)/(c-o)+a&&(n=!n)}return n}function s(e){return{x:e.reduce((e,t)=>e+t.x,0)/e.length,y:e.reduce((e,t)=>e+t.y,0)/e.length}}function c(e,t,n,r){return[{x:e,y:t},{x:e+n,y:t},{x:e+n,y:t+r},{x:e,y:t+r}]}function l(e,t,n,r){let i=t.x-e.x,a=t.y-e.y,o=Math.sqrt(i*i+a*a);if(o<.001)return null;let s=i/o,c=a/o,l=e.x+i*n,u=e.y+a*n,d=Math.min(r/2,o/2-.001);return{p1:{x:l-s*d,y:u-c*d},p2:{x:l+s*d,y:u+c*d},perp:{x:-c,y:s}}}function u(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`)}function d(e){return e.map((e,t)=>`${t===0?`M`:`L`} ${e.x.toFixed(2)},${e.y.toFixed(2)}`).join(` `)+` Z`}function f(e,t){if(e.length<2)return[];if(t.length===0)return[e.map((e,t)=>`${t===0?`M`:`L`} ${e.x.toFixed(2)},${e.y.toFixed(2)}`).join(` `)+` Z`];let n=e.length,r=[],i=[`M ${e[0].x.toFixed(2)},${e[0].y.toFixed(2)}`];for(let a=0;a<n;a++){let o=e[a],s=e[(a+1)%n],c=s.x-o.x,l=s.y-o.y,u=Math.sqrt(c*c+l*l),d=t.filter(e=>e.wallIndex===a).sort((e,t)=>e.t-t.t);if(d.length===0)i.push(`L ${s.x.toFixed(2)},${s.y.toFixed(2)}`);else{for(let e of d){let t=e.width/2,n=Math.max(0,e.t-t/u),a=Math.min(1,e.t+t/u),s={x:o.x+c*n,y:o.y+l*n},d={x:o.x+c*a,y:o.y+l*a};i.push(`L ${s.x.toFixed(2)},${s.y.toFixed(2)}`),r.push(i),i=[`M ${d.x.toFixed(2)},${d.y.toFixed(2)}`]}i.push(`L ${s.x.toFixed(2)},${s.y.toFixed(2)}`)}}return r[0]=[...i,...r[0].slice(1)],r.map(e=>e.join(` `))}function p(e,t,n){let r=l(e,t,n.t,n.width);if(!r)return``;let{p1:i,p2:a,perp:o}=r,s={x:i.x+o.x*n.width,y:i.y+o.y*n.width};return[`M ${i.x.toFixed(2)},${i.y.toFixed(2)} L ${a.x.toFixed(2)},${a.y.toFixed(2)}`,`M ${i.x.toFixed(2)},${i.y.toFixed(2)} A ${n.width},${n.width} 0 0,1 ${s.x.toFixed(2)},${s.y.toFixed(2)}`].join(` `)}function m(e,t){let n=1/0,r=1/0,i=-1/0,a=-1/0;for(let t of e)for(let e of t.points)e.x<n&&(n=e.x),e.y<r&&(r=e.y),e.x>i&&(i=e.x),e.y>a&&(a=e.y);isFinite(n)||(n=0,r=0,i=400,a=300);let o=[(n-5).toFixed(2),(r-5).toFixed(2),(i-n+10).toFixed(2),(a-r+10).toFixed(2)].join(` `),c=e.map(e=>{let n=t.filter(t=>t.roomId===e.id),r=f(e.points,n),i=d(e.points),a=s(e.points),o=r.map(e=>` <path d="${e}" />`).join(`
2
- `),c=n.map(t=>{let n=e.points[t.wallIndex],r=e.points[(t.wallIndex+1)%e.points.length],i=p(n,r,t);return i?` <path d="${i}" fill="none" />`:``}).filter(Boolean).join(`
3
- `),l=e.name?` <text fontSize="5" x="${a.x.toFixed(1)}" y="${a.y.toFixed(1)}">${u(e.name)}</text>`:``;return[` <g id="${e.id}">`,o,` <path id="background" d="${i}" />`,c,l,` </g>`].filter(Boolean).join(`
4
- `)});return[`<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="${o}">`,` <g id="layer1">`,...c,` </g>`,` <use id="use" href="" />`,`</svg>`].join(`
5
- `)}function h(e){let t=[];for(let n of e.matchAll(/[ML]\s*([-\d.]+)[,\s]+([-\d.]+)/gi))t.push({x:parseFloat(n[1]),y:parseFloat(n[2])});return t}function g(e){let t=new DOMParser().parseFromString(e,`image/svg+xml`),n=[];return t.querySelectorAll(`[id^="room"]`).forEach(e=>{let t=e.querySelector(`[id="background"]`);if(!t)return;let r=t.getAttribute(`d`);if(!r)return;let i=h(r);if(i.length<3)return;let a=e.querySelector(`text`)?.textContent?.trim()??``;n.push({id:e.id,name:a,points:i})}),n}function _(e,t){let n=m(e,t),r=new Blob([n],{type:`image/svg+xml`}),i=URL.createObjectURL(r),a=document.createElement(`a`);a.href=i,a.download=`room-plan.svg`,a.click(),URL.revokeObjectURL(i)}export{f as buildWallSegments,s as centroid,n as clientToSVG,r as dist,l as doorEndpoints,_ as downloadSVG,a as nearestWall,g as parseSVGRooms,o as pointInPolygon,i as pointToSegment,c as rectToPoints,t as snapPt};
6
- //# sourceMappingURL=roomDrawer.utils.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"roomDrawer.utils.mjs","names":[],"sources":["../../../../package/common/roomDrawer/roomDrawer.utils.ts"],"sourcesContent":["import type { Point, RoomData, DoorData } from './roomDrawer.types';\n\nexport const CANVAS_W = 400;\nexport const CANVAS_H = 300;\nexport const GRID = 5;\nexport const DOOR_WIDTH = 12;\nexport const CLOSE_THRESHOLD = 8; // px in SVG units to close a polygon\nexport const WALL_TOLERANCE = 8; // max distance to snap to a wall for door placement\n\n// ─── Coordinate helpers ────────────────────────────────────────────────────\n\nexport function snap(v: number): number {\n return Math.round(v / GRID) * GRID;\n}\n\nexport function snapPt(p: Point): Point {\n return { x: snap(p.x), y: snap(p.y) };\n}\n\nexport function clientToSVG(clientX: number, clientY: number, svg: SVGSVGElement): Point {\n const pt = svg.createSVGPoint();\n pt.x = clientX;\n pt.y = clientY;\n const m = svg.getScreenCTM();\n if (!m) return { x: 0, y: 0 };\n const raw = pt.matrixTransform(m.inverse());\n return snapPt({ x: raw.x, y: raw.y });\n}\n\n// ─── Geometry ──────────────────────────────────────────────────────────────\n\nexport function dist(a: Point, b: Point): number {\n return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);\n}\n\nexport function pointToSegment(p: Point, a: Point, b: Point): { dist: number; t: number } {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len2 = dx * dx + dy * dy;\n if (len2 === 0) return { dist: dist(p, a), t: 0 };\n const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));\n const px = a.x + t * dx;\n const py = a.y + t * dy;\n return { dist: Math.sqrt((p.x - px) ** 2 + (p.y - py) ** 2), t };\n}\n\nexport function nearestWall(pt: Point, rooms: RoomData[]): { roomId: string; wallIndex: number; t: number } | null {\n let best: { roomId: string; wallIndex: number; t: number; dist: number } | null = null;\n for (const room of rooms) {\n const pts = room.points;\n for (let i = 0; i < pts.length; i++) {\n const a = pts[i];\n const b = pts[(i + 1) % pts.length];\n const { dist: d, t } = pointToSegment(pt, a, b);\n if (d <= WALL_TOLERANCE && (!best || d < best.dist)) {\n best = { roomId: room.id, wallIndex: i, t, dist: d };\n }\n }\n }\n return best;\n}\n\nexport function pointInPolygon(pt: Point, poly: Point[]): boolean {\n let inside = false;\n for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n const xi = poly[i].x, yi = poly[i].y;\n const xj = poly[j].x, yj = poly[j].y;\n if ((yi > pt.y) !== (yj > pt.y) && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi) {\n inside = !inside;\n }\n }\n return inside;\n}\n\nexport function centroid(pts: Point[]): Point {\n return {\n x: pts.reduce((s, p) => s + p.x, 0) / pts.length,\n y: pts.reduce((s, p) => s + p.y, 0) / pts.length,\n };\n}\n\nexport function rectToPoints(x: number, y: number, w: number, h: number): Point[] {\n return [\n { x, y },\n { x: x + w, y },\n { x: x + w, y: y + h },\n { x, y: y + h },\n ];\n}\n\n// Returns the two endpoints of the door opening and a perpendicular unit vector (for the swing arc).\nexport function doorEndpoints(\n a: Point,\n b: Point,\n t: number,\n width: number,\n): { p1: Point; p2: Point; perp: Point } | null {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len = Math.sqrt(dx * dx + dy * dy);\n if (len < 0.001) return null;\n const nx = dx / len;\n const ny = dy / len;\n const cx = a.x + dx * t;\n const cy = a.y + dy * t;\n const hw = Math.min(width / 2, len / 2 - 0.001);\n return {\n p1: { x: cx - nx * hw, y: cy - ny * hw },\n p2: { x: cx + nx * hw, y: cy + ny * hw },\n perp: { x: -ny, y: nx },\n };\n}\n\n// ─── SVG export ────────────────────────────────────────────────────────────\n\nfunction escapeSVG(s: string): string {\n return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\n}\n\nfunction ptsToClosedPath(pts: Point[]): string {\n return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ' Z';\n}\n\n// Returns one path string per wall segment between door gaps.\n// 0 doors → [closed path] | N doors → N open paths\nexport function buildWallSegments(pts: Point[], wallDoors: DoorData[]): string[] {\n if (pts.length < 2) return [];\n\n if (wallDoors.length === 0) {\n return [pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + ' Z'];\n }\n\n const n = pts.length;\n const segments: string[][] = [];\n let current: string[] = [`M ${pts[0].x.toFixed(2)},${pts[0].y.toFixed(2)}`];\n\n for (let i = 0; i < n; i++) {\n const a = pts[i];\n const b = pts[(i + 1) % n];\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len = Math.sqrt(dx * dx + dy * dy);\n const segs = wallDoors.filter((d) => d.wallIndex === i).sort((x, y) => x.t - y.t);\n\n if (segs.length === 0) {\n current.push(`L ${b.x.toFixed(2)},${b.y.toFixed(2)}`);\n } else {\n for (const door of segs) {\n const hw = door.width / 2;\n const t1 = Math.max(0, door.t - hw / len);\n const t2 = Math.min(1, door.t + hw / len);\n const gs = { x: a.x + dx * t1, y: a.y + dy * t1 };\n const ge = { x: a.x + dx * t2, y: a.y + dy * t2 };\n current.push(`L ${gs.x.toFixed(2)},${gs.y.toFixed(2)}`);\n segments.push(current);\n current = [`M ${ge.x.toFixed(2)},${ge.y.toFixed(2)}`];\n }\n current.push(`L ${b.x.toFixed(2)},${b.y.toFixed(2)}`);\n }\n }\n\n // Wrap-around: the last segment ends at pts[0] where segments[0] started —\n // merge them so each segment is one continuous open path.\n segments[0] = [...current, ...segments[0].slice(1)];\n\n return segments.map((s) => s.join(' '));\n}\n\nfunction buildDoorArcPath(a: Point, b: Point, door: DoorData): string {\n const ep = doorEndpoints(a, b, door.t, door.width);\n if (!ep) return '';\n const { p1, p2, perp } = ep;\n const swingEnd = { x: p1.x + perp.x * door.width, y: p1.y + perp.y * door.width };\n return [\n `M ${p1.x.toFixed(2)},${p1.y.toFixed(2)} L ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`,\n `M ${p1.x.toFixed(2)},${p1.y.toFixed(2)} A ${door.width},${door.width} 0 0,1 ${swingEnd.x.toFixed(2)},${swingEnd.y.toFixed(2)}`,\n ].join(' ');\n}\n\nexport function generateSVG(rooms: RoomData[], doors: DoorData[]): string {\n let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n for (const r of rooms) {\n for (const p of r.points) {\n if (p.x < minX) minX = p.x;\n if (p.y < minY) minY = p.y;\n if (p.x > maxX) maxX = p.x;\n if (p.y > maxY) maxY = p.y;\n }\n }\n if (!isFinite(minX)) { minX = 0; minY = 0; maxX = CANVAS_W; maxY = CANVAS_H; }\n\n const pad = 5;\n const vb = [\n (minX - pad).toFixed(2),\n (minY - pad).toFixed(2),\n (maxX - minX + pad * 2).toFixed(2),\n (maxY - minY + pad * 2).toFixed(2),\n ].join(' ');\n\n const roomEls = rooms.map((room) => {\n const roomDoors = doors.filter((d) => d.roomId === room.id);\n const wallSegs = buildWallSegments(room.points, roomDoors);\n const bgPath = ptsToClosedPath(room.points);\n const c = centroid(room.points);\n\n const wallPaths = wallSegs.map((d) => ` <path d=\"${d}\" />`).join('\\n');\n\n const doorPaths = roomDoors\n .map((door) => {\n const a = room.points[door.wallIndex];\n const b = room.points[(door.wallIndex + 1) % room.points.length];\n const d = buildDoorArcPath(a, b, door);\n return d ? ` <path d=\"${d}\" fill=\"none\" />` : '';\n })\n .filter(Boolean)\n .join('\\n');\n\n const labelEl = room.name\n ? ` <text fontSize=\"5\" x=\"${c.x.toFixed(1)}\" y=\"${c.y.toFixed(1)}\">${escapeSVG(room.name)}</text>`\n : '';\n\n return [` <g id=\"${room.id}\">`, wallPaths, ` <path id=\"background\" d=\"${bgPath}\" />`, doorPaths, labelEl, ` </g>`]\n .filter(Boolean)\n .join('\\n');\n });\n\n return [\n `<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" viewBox=\"${vb}\">`,\n ` <g id=\"layer1\">`,\n ...roomEls,\n ` </g>`,\n ` <use id=\"use\" href=\"\" />`,\n `</svg>`,\n ].join('\\n');\n}\n\n// ─── SVG import ────────────────────────────────────────────────────────────\n\nfunction parseClosedPath(d: string): Point[] {\n const points: Point[] = [];\n for (const m of d.matchAll(/[ML]\\s*([-\\d.]+)[,\\s]+([-\\d.]+)/gi)) {\n points.push({ x: parseFloat(m[1]), y: parseFloat(m[2]) });\n }\n return points;\n}\n\nexport function parseSVGRooms(svgString: string): RoomData[] {\n const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');\n const rooms: RoomData[] = [];\n doc.querySelectorAll('[id^=\"room\"]').forEach((group) => {\n const bg = group.querySelector('[id=\"background\"]');\n if (!bg) return;\n const d = bg.getAttribute('d');\n if (!d) return;\n const points = parseClosedPath(d);\n if (points.length < 3) return;\n const name = group.querySelector('text')?.textContent?.trim() ?? '';\n rooms.push({ id: group.id, name, points });\n });\n return rooms;\n}\n\nexport function downloadSVG(rooms: RoomData[], doors: DoorData[]): void {\n const svg = generateSVG(rooms, doors);\n const blob = new Blob([svg], { type: 'image/svg+xml' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = 'room-plan.svg';\n a.click();\n URL.revokeObjectURL(url);\n}\n"],"mappings":"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"}
@@ -1,32 +0,0 @@
1
- import { Point, RoomData, DoorData } from './roomDrawer.types';
2
- export declare const CANVAS_W = 400;
3
- export declare const CANVAS_H = 300;
4
- export declare const GRID = 5;
5
- export declare const DOOR_WIDTH = 12;
6
- export declare const CLOSE_THRESHOLD = 8;
7
- export declare const WALL_TOLERANCE = 8;
8
- export declare function snap(v: number): number;
9
- export declare function snapPt(p: Point): Point;
10
- export declare function clientToSVG(clientX: number, clientY: number, svg: SVGSVGElement): Point;
11
- export declare function dist(a: Point, b: Point): number;
12
- export declare function pointToSegment(p: Point, a: Point, b: Point): {
13
- dist: number;
14
- t: number;
15
- };
16
- export declare function nearestWall(pt: Point, rooms: RoomData[]): {
17
- roomId: string;
18
- wallIndex: number;
19
- t: number;
20
- } | null;
21
- export declare function pointInPolygon(pt: Point, poly: Point[]): boolean;
22
- export declare function centroid(pts: Point[]): Point;
23
- export declare function rectToPoints(x: number, y: number, w: number, h: number): Point[];
24
- export declare function doorEndpoints(a: Point, b: Point, t: number, width: number): {
25
- p1: Point;
26
- p2: Point;
27
- perp: Point;
28
- } | null;
29
- export declare function buildWallSegments(pts: Point[], wallDoors: DoorData[]): string[];
30
- export declare function generateSVG(rooms: RoomData[], doors: DoorData[]): string;
31
- export declare function parseSVGRooms(svgString: string): RoomData[];
32
- export declare function downloadSVG(rooms: RoomData[], doors: DoorData[]): void;