@unofficialbox/box-open-elements 0.24.0 → 0.25.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 (36) hide show
  1. package/README.md +13 -13
  2. package/dist/components/actions/button.d.ts +1 -0
  3. package/dist/components/actions/button.js +13 -0
  4. package/dist/components/forms/checkbox.d.ts +1 -0
  5. package/dist/components/forms/checkbox.js +2 -0
  6. package/dist/components/forms/code-editor.d.ts +28 -3
  7. package/dist/components/forms/code-editor.js +71 -6
  8. package/dist/components/forms/combobox.js +7 -8
  9. package/dist/components/forms/number-input.d.ts +1 -0
  10. package/dist/components/forms/number-input.js +4 -1
  11. package/dist/components/forms/select.d.ts +1 -0
  12. package/dist/components/forms/select.js +3 -1
  13. package/dist/components/forms/text-area.js +5 -7
  14. package/dist/components/forms/text-field.d.ts +1 -0
  15. package/dist/components/forms/text-field.js +2 -0
  16. package/dist/components/navigation/accordion.d.ts +7 -0
  17. package/dist/components/navigation/accordion.js +31 -8
  18. package/dist/components/output/code-block.js +3 -1
  19. package/dist/components/overlays/drawer.js +2 -2
  20. package/dist/foundations/geometry/tokens.d.ts +2 -2
  21. package/dist/foundations/geometry/tokens.js +3 -3
  22. package/dist/foundations/icons/box-iconography.generated.d.ts +472 -1
  23. package/dist/foundations/icons/box-iconography.generated.js +473 -2
  24. package/dist/patterns/flow-builder/flow-builder.d.ts +5 -1
  25. package/dist/patterns/flow-builder/flow-builder.js +18 -7
  26. package/dist/patterns/flow-builder/primitives.js +1 -1
  27. package/dist/patterns/process-modeler/bridge.d.ts +10 -0
  28. package/dist/patterns/process-modeler/bridge.js +173 -0
  29. package/dist/patterns/process-modeler/geometry.d.ts +18 -0
  30. package/dist/patterns/process-modeler/geometry.js +307 -0
  31. package/dist/patterns/process-modeler/index.d.ts +2 -0
  32. package/dist/patterns/process-modeler/index.js +2 -0
  33. package/dist/patterns/process-modeler/model.d.ts +41 -1
  34. package/dist/patterns/process-modeler/process-modeler.d.ts +48 -0
  35. package/dist/patterns/process-modeler/process-modeler.js +709 -63
  36. package/package.json +4 -3
@@ -27,7 +27,11 @@ export declare class FlowBuilder<N extends FlowNodeBase = FlowNode> extends Base
27
27
  private headingEl?;
28
28
  private renderedRenderer?;
29
29
  private observer?;
30
- private narrow;
30
+ private narrowValue;
31
+ private visible;
32
+ get narrow(): boolean;
33
+ /** Focus a rendered card without changing selection or opening the editor. */
34
+ focusNode(node: N): void;
31
35
  private insertion?;
32
36
  private invoker?;
33
37
  private spine;
@@ -22,7 +22,11 @@ export class FlowBuilder extends BaseElement {
22
22
  headingEl;
23
23
  renderedRenderer;
24
24
  observer;
25
- narrow = false;
25
+ narrowValue = false;
26
+ visible = true;
27
+ get narrow() { return this.narrowValue; }
28
+ /** Focus a rendered card without changing selection or opening the editor. */
29
+ focusNode(node) { this.spine?.focusNode(node); }
26
30
  insertion;
27
31
  invoker;
28
32
  spine;
@@ -60,12 +64,18 @@ export class FlowBuilder extends BaseElement {
60
64
  this.refresh();
61
65
  }
62
66
  connectedCallback() {
67
+ this.visible = this.getClientRects().length > 0;
63
68
  super.connectedCallback();
64
69
  if (typeof ResizeObserver !== "undefined") {
65
- this.observer = new ResizeObserver(entries => this.setNarrow(entries[0].contentRect.width < 800));
70
+ this.observer = new ResizeObserver(entries => {
71
+ const rect = entries[0].contentRect;
72
+ this.visible = rect.width > 0 && rect.height > 0 && this.getClientRects().length > 0;
73
+ this.setNarrow(this.visible && rect.width < 800);
74
+ this.updateInspector();
75
+ });
66
76
  this.observer.observe(this);
67
77
  }
68
- this.setNarrow((this.getBoundingClientRect().width || this.ownerDocument.defaultView?.innerWidth || 1024) < 800);
78
+ this.setNarrow(this.visible && this.getBoundingClientRect().width < 800);
69
79
  }
70
80
  disconnectedCallback() {
71
81
  this.observer?.disconnect();
@@ -79,9 +89,10 @@ export class FlowBuilder extends BaseElement {
79
89
  setNarrow(narrow) {
80
90
  if (this.narrow === narrow)
81
91
  return;
82
- this.narrow = narrow;
92
+ this.narrowValue = narrow;
83
93
  this.shadowRoot.querySelector("[part=layout]").dataset.narrow = String(narrow);
84
94
  this.updateInspector();
95
+ this.dispatchEvent(new CustomEvent("narrow-changed", { detail: { narrow }, bubbles: true, composed: true }));
85
96
  }
86
97
  renderTemplate() {
87
98
  this.shadowRoot.innerHTML = `<style>
@@ -134,7 +145,7 @@ export class FlowBuilder extends BaseElement {
134
145
  });
135
146
  this.palette.addEventListener("kind-pick", event => this.insert(event.detail.kind, { list: this.nodes, index: this.nodes.length, label: "Append to flow" }));
136
147
  this.drawer.addEventListener("open-changed", event => {
137
- if (!event.detail.open && this.narrow && this.selectedValue)
148
+ if (!event.detail.open && this.visible && this.narrow && this.selectedValue)
138
149
  this.select(null);
139
150
  });
140
151
  }
@@ -220,7 +231,7 @@ export class FlowBuilder extends BaseElement {
220
231
  if (this.inspector.parentElement !== target)
221
232
  target.append(this.inspector);
222
233
  if (this.inspected === this.selected && this.renderedRenderer === this.renderer && (!this.selected || this.headingEl?.tagName === `H${this.headingLevel}`)) {
223
- this.drawer.open = this.narrow && Boolean(this.selected);
234
+ this.drawer.open = this.visible && this.narrow && Boolean(this.selected);
224
235
  return;
225
236
  }
226
237
  this.cleanupInspector?.();
@@ -243,7 +254,7 @@ export class FlowBuilder extends BaseElement {
243
254
  }
244
255
  this.drawer.heading = nodeTitle(this.selected, this.catalog, this.model);
245
256
  }
246
- this.drawer.open = this.narrow && Boolean(this.selected);
257
+ this.drawer.open = this.visible && this.narrow && Boolean(this.selected);
247
258
  }
248
259
  }
249
260
  FlowBuilder.register();
@@ -24,7 +24,7 @@ export class FlowCard extends BaseElement {
24
24
  renderTemplate() {
25
25
  this.shadowRoot.innerHTML = `<style>${styles}
26
26
  button { width:100%; text-align:start; display:grid; gap:6px; padding:16px; border:1px solid var(--boe-token-stroke-stroke,#ddd); border-radius:var(--boe-profile-radius-med,8px); background:var(--boe-token-surface-surface,#fff); overflow-wrap:anywhere; }
27
- button[aria-pressed=true] { border-color:var(--boe-token-surface-surface-brand,#0061d5); background:color-mix(in srgb,var(--boe-token-surface-surface-brand,#0061d5) 8%,var(--boe-token-surface-surface,#fff)); }
27
+ button[aria-pressed=true] { border-color:var(--boe-token-surface-surface-brand,#0061d5); background:color-mix(in srgb,var(--boe-token-surface-surface-brand,#0061d5) 5%,var(--boe-token-surface-surface,#fff)); }
28
28
  button[data-invalid=true] { border-color:var(--boe-token-text-status-text-error,#b92340); }
29
29
  button.has-icon{grid-template-columns:20px minmax(0,1fr);column-gap:12px} button.has-icon [part=icon]{grid-column:1;grid-row:1/span 4} button.has-icon [part=title],button.has-icon [part=kind],button.has-icon [part=description],button.has-icon [part=status],button.has-icon [part=error]{grid-column:2}
30
30
  [part=icon]{color:var(--boe-token-surface-surface-brand,#0061d5);width:20px;height:20px} [part=icon][data-tone=neutral]{color:var(--boe-token-text-text-secondary,#666)} [part=icon] svg{display:block;width:20px;height:20px} [part=title] { font-weight:650; } [part=kind],[part=description] { color:var(--boe-token-text-text-secondary,#666); } [part=error] { color:var(--boe-token-text-status-text-error,#b92340); }
@@ -0,0 +1,10 @@
1
+ import type { NodePath } from "../flow-builder/model.js";
2
+ import type { ProcessCheck, ProcessLayout, ProcessLoadOptions, ProcessPositionSnapshot, ProcessProjection } from "./model.js";
3
+ /** Restore only current paths with explicitly matching host fingerprints. */
4
+ export declare function restoreProcessPositions<N>(projection: ProcessProjection<N>, positions: ProcessLoadOptions["positions"]): ProcessLayout;
5
+ /** Detached, ID-sorted entries; stale layout IDs and unplaced boxes are omitted. */
6
+ export declare function snapshotProcessPositions<N>(projection: ProcessProjection<N>, layout: ProcessLayout): readonly ProcessPositionSnapshot[];
7
+ /** Resolve a host field path to its deepest projected box, without string coercion. */
8
+ export declare function normalizeProcessSelectionPath<N>(projection: ProcessProjection<N>, path: NodePath | null | undefined): NodePath | null;
9
+ /** Generic topology only. Hosts own workflow vocabulary and semantic validation. */
10
+ export declare function graphChecks<N>(projection: ProcessProjection<N>): readonly ProcessCheck[];
@@ -0,0 +1,173 @@
1
+ const validPosition = (position) => Number.isFinite(position.x) && Number.isFinite(position.y) &&
2
+ (position.width === undefined || (Number.isFinite(position.width) && position.width > 0)) &&
3
+ (position.height === undefined || (Number.isFinite(position.height) && position.height > 0));
4
+ /** Restore only current paths with explicitly matching host fingerprints. */
5
+ export function restoreProcessPositions(projection, positions) {
6
+ const savedPositions = Array.isArray(positions)
7
+ ? Object.fromEntries(positions.filter(entry => entry.path).map(entry => [JSON.stringify(entry.path), entry]))
8
+ : positions;
9
+ const boxes = {};
10
+ for (const box of projection.boxes) {
11
+ if (!box.path || box.fingerprint === undefined)
12
+ continue;
13
+ const key = JSON.stringify(box.path);
14
+ const saved = savedPositions && Object.hasOwn(savedPositions, key) ? savedPositions[key] : undefined;
15
+ if (!saved || saved.fingerprint === undefined || saved.fingerprint !== box.fingerprint ||
16
+ !saved.position || !validPosition(saved.position))
17
+ continue;
18
+ Object.defineProperty(boxes, box.id, {
19
+ value: { ...saved.position }, enumerable: true, configurable: true, writable: true,
20
+ });
21
+ }
22
+ return { boxes };
23
+ }
24
+ /** Detached, ID-sorted entries; stale layout IDs and unplaced boxes are omitted. */
25
+ export function snapshotProcessPositions(projection, layout) {
26
+ const snapshots = [];
27
+ for (const box of projection.boxes) {
28
+ if (!Object.hasOwn(layout.boxes, box.id))
29
+ continue;
30
+ const position = layout.boxes[box.id];
31
+ if (!position || !validPosition(position))
32
+ continue;
33
+ snapshots.push({
34
+ id: box.id,
35
+ ...(box.path ? { path: [...box.path] } : {}),
36
+ ...(box.fingerprint !== undefined ? { fingerprint: box.fingerprint } : {}),
37
+ position: { ...position },
38
+ });
39
+ }
40
+ return snapshots.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
41
+ }
42
+ /** Resolve a host field path to its deepest projected box, without string coercion. */
43
+ export function normalizeProcessSelectionPath(projection, path) {
44
+ if (path == null)
45
+ return null;
46
+ let selected;
47
+ for (const box of projection.boxes) {
48
+ if (box.path && box.path.length <= path.length &&
49
+ box.path.every((segment, index) => segment === path[index]) &&
50
+ (!selected || box.path.length > selected.length))
51
+ selected = box.path;
52
+ }
53
+ return selected ? [...selected] : null;
54
+ }
55
+ const branchKinds = new Set(["decision", "choice", "weighted", "weighted-choice", "parallel", "fork"]);
56
+ const parallelKinds = new Set(["parallel", "fork"]);
57
+ /** Generic topology only. Hosts own workflow vocabulary and semantic validation. */
58
+ export function graphChecks(projection) {
59
+ const boxes = new Map(projection.boxes.map(box => [box.id, box]));
60
+ const outgoing = new Map(projection.boxes.map(box => [box.id, []]));
61
+ const incoming = new Map(projection.boxes.map(box => [box.id, []]));
62
+ for (const line of projection.lines) {
63
+ if (!boxes.has(line.from) || !boxes.has(line.to))
64
+ continue;
65
+ outgoing.get(line.from).push(line.to);
66
+ incoming.get(line.to).push(line.from);
67
+ }
68
+ // Iterative strongly connected components keep long host graphs off the call stack.
69
+ const visited = new Set();
70
+ const finished = [];
71
+ for (const id of boxes.keys()) {
72
+ if (visited.has(id))
73
+ continue;
74
+ visited.add(id);
75
+ const stack = [{ id, next: 0 }];
76
+ while (stack.length) {
77
+ const top = stack[stack.length - 1];
78
+ const targets = outgoing.get(top.id);
79
+ if (top.next < targets.length) {
80
+ const target = targets[top.next++];
81
+ if (!visited.has(target)) {
82
+ visited.add(target);
83
+ stack.push({ id: target, next: 0 });
84
+ }
85
+ }
86
+ else {
87
+ finished.push(top.id);
88
+ stack.pop();
89
+ }
90
+ }
91
+ }
92
+ visited.clear();
93
+ const loops = new Set();
94
+ for (const id of finished.reverse()) {
95
+ if (visited.has(id))
96
+ continue;
97
+ const component = [];
98
+ const stack = [id];
99
+ visited.add(id);
100
+ while (stack.length) {
101
+ const current = stack.pop();
102
+ component.push(current);
103
+ for (const source of incoming.get(current)) {
104
+ if (!visited.has(source)) {
105
+ visited.add(source);
106
+ stack.push(source);
107
+ }
108
+ }
109
+ }
110
+ if (component.length > 1 || outgoing.get(id).includes(id)) {
111
+ for (const member of component)
112
+ loops.add(member);
113
+ }
114
+ }
115
+ const reachable = (start) => {
116
+ const distances = new Map([[start, 0]]);
117
+ const queue = [start];
118
+ for (let i = 0; i < queue.length; i++) {
119
+ const id = queue[i];
120
+ for (const target of outgoing.get(id)) {
121
+ if (!distances.has(target)) {
122
+ distances.set(target, distances.get(id) + 1);
123
+ queue.push(target);
124
+ }
125
+ }
126
+ }
127
+ return distances;
128
+ };
129
+ const allPathsMeet = (start, join) => {
130
+ const queue = [start];
131
+ const seen = new Set();
132
+ for (let i = 0; i < queue.length; i++) {
133
+ const id = queue[i];
134
+ if (id === join || seen.has(id))
135
+ continue;
136
+ seen.add(id);
137
+ const targets = outgoing.get(id);
138
+ if (!targets.length || loops.has(id))
139
+ return false;
140
+ queue.push(...targets);
141
+ }
142
+ return true;
143
+ };
144
+ const checks = [];
145
+ for (const box of projection.boxes) {
146
+ const add = (message) => checks.push({
147
+ message, boxId: box.id, ...(box.path ? { path: [...box.path] } : {}),
148
+ });
149
+ if (loops.has(box.id))
150
+ add("This box is part of a loop. Remove a connection to break the loop.");
151
+ const targets = outgoing.get(box.id);
152
+ if (targets.length < 2)
153
+ continue;
154
+ if (!branchKinds.has(box.kind)) {
155
+ add("This step leads to multiple places. Use a Decision, Weighted choice or Parallel to branch.");
156
+ continue;
157
+ }
158
+ const routes = targets.map(reachable);
159
+ const candidates = [...routes[0].keys()].filter(id => id !== box.id && routes.every(route => route.has(id)));
160
+ candidates.sort((a, b) => Math.max(...routes.map(route => route.get(a))) - Math.max(...routes.map(route => route.get(b))) ||
161
+ (a < b ? -1 : a > b ? 1 : 0));
162
+ const join = candidates.find(id => targets.every(target => allPathsMeet(target, id)));
163
+ if (!join) {
164
+ add(parallelKinds.has(box.kind)
165
+ ? "Parallel paths must meet at a Wait for all (join) box."
166
+ : "These branch paths do not meet. Connect every path to a common next box.");
167
+ }
168
+ else if (parallelKinds.has(box.kind) && boxes.get(join).kind !== "join") {
169
+ add("Parallel paths must meet at a Wait for all (join) box.");
170
+ }
171
+ }
172
+ return checks;
173
+ }
@@ -0,0 +1,18 @@
1
+ import type { ProcessLayout, ProcessLine, ProcessProjection } from "./model.js";
2
+ export interface ProcessPoint {
3
+ x: number;
4
+ y: number;
5
+ }
6
+ /** Stable left-to-right layers; cycles break at the smallest remaining ID. */
7
+ export declare function arrangeProcess(projection: ProcessProjection): ProcessLayout;
8
+ /**
9
+ * Authored points are interior waypoints; layout points override projection points.
10
+ * Explicit sides use centered ports with outward leads; unset sides snap to the
11
+ * nearest edge. Returns [] for missing endpoints, blocked ports or impossible
12
+ * waypoints (inside an obstacle), never an obstacle-crossing fallback.
13
+ */
14
+ export declare function routeProcessLine(line: ProcessLine, layout: ProcessLayout, projection: ProcessProjection): ProcessPoint[];
15
+ /** Halfway along the polyline's length, not halfway between its endpoints. */
16
+ export declare function lineMidpoint(points: readonly ProcessPoint[]): ProcessPoint;
17
+ /** Segment-distance hit testing, including zero-length and diagonal segments. */
18
+ export declare function nearProcessLine(point: ProcessPoint, points: readonly ProcessPoint[], tolerance?: number): boolean;
@@ -0,0 +1,307 @@
1
+ import { validateProjection } from "./model.js";
2
+ const GAP_X = 80;
3
+ const GAP_Y = 48;
4
+ const PADDING = 24;
5
+ const HEADER = 64;
6
+ const CLEARANCE = 12;
7
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
8
+ /** Stable left-to-right layers; cycles break at the smallest remaining ID. */
9
+ export function arrangeProcess(projection) {
10
+ validateProjection(projection);
11
+ const boxes = new Map(projection.boxes.map(box => [box.id, box]));
12
+ const children = new Map();
13
+ for (const box of projection.boxes) {
14
+ const siblings = children.get(box.parentId) ?? [];
15
+ siblings.push(box);
16
+ children.set(box.parentId, siblings);
17
+ }
18
+ for (const siblings of children.values())
19
+ siblings.sort((a, b) => compare(a.id, b.id));
20
+ const result = { boxes: Object.fromEntries(projection.boxes.map(box => [box.id, { x: 0, y: 0 }])) };
21
+ const measureGroup = (parentId) => {
22
+ const siblings = children.get(parentId) ?? [];
23
+ const ids = new Set(siblings.map(box => box.id));
24
+ // Collapse descendant endpoints to the sibling frame at this level.
25
+ const representative = (id) => {
26
+ let box = boxes.get(id);
27
+ while (box && !ids.has(box.id))
28
+ box = boxes.get(box.parentId ?? "");
29
+ return box?.id;
30
+ };
31
+ const outgoing = new Map(siblings.map(box => [box.id, new Set()]));
32
+ const incoming = new Map(siblings.map(box => [box.id, 0]));
33
+ for (const line of projection.lines) {
34
+ const from = representative(line.from), to = representative(line.to);
35
+ if (!from || !to || from === to || outgoing.get(from).has(to))
36
+ continue;
37
+ outgoing.get(from).add(to);
38
+ incoming.set(to, incoming.get(to) + 1);
39
+ }
40
+ const remaining = new Set(ids);
41
+ const rank = new Map(siblings.map(box => [box.id, 0]));
42
+ while (remaining.size) {
43
+ const ready = [...remaining].filter(id => incoming.get(id) === 0).sort(compare);
44
+ const id = ready[0] ?? [...remaining].sort(compare)[0];
45
+ remaining.delete(id);
46
+ for (const next of outgoing.get(id)) {
47
+ if (!remaining.has(next))
48
+ continue;
49
+ rank.set(next, Math.max(rank.get(next), rank.get(id) + 1));
50
+ incoming.set(next, incoming.get(next) - 1);
51
+ }
52
+ }
53
+ const sizes = new Map();
54
+ for (const box of siblings) {
55
+ const group = measureGroup(box.id);
56
+ const width = box.frame ? Math.max(320, group.width + PADDING * 2) : 220;
57
+ const height = box.frame ? Math.max(180, group.height + HEADER + PADDING) : 96;
58
+ sizes.set(box.id, { x: 0, y: 0, width, height });
59
+ // Store local child coordinates until the parent's absolute position is known.
60
+ for (const [id, position] of group.positions)
61
+ result.boxes[id] = position;
62
+ }
63
+ const positions = new Map();
64
+ let x = 0, height = 0;
65
+ const ranks = [...new Set(rank.values())].sort((a, b) => a - b);
66
+ for (const layer of ranks) {
67
+ let y = 0, width = 0;
68
+ for (const box of siblings.filter(box => rank.get(box.id) === layer)) {
69
+ const size = sizes.get(box.id);
70
+ positions.set(box.id, { ...size, x, y });
71
+ width = Math.max(width, size.width);
72
+ y += size.height + GAP_Y;
73
+ }
74
+ height = Math.max(height, y - GAP_Y);
75
+ x += width + GAP_X;
76
+ }
77
+ return { width: Math.max(0, x - GAP_X), height, positions };
78
+ };
79
+ const roots = measureGroup();
80
+ const place = (positions, x, y) => {
81
+ for (const [id, local] of positions) {
82
+ const position = { ...local, x: local.x + x, y: local.y + y };
83
+ result.boxes[id] = position;
84
+ const nested = new Map((children.get(id) ?? []).map(child => [child.id, result.boxes[child.id]]));
85
+ place(nested, position.x + PADDING, position.y + HEADER);
86
+ }
87
+ };
88
+ place(roots.positions, 40, 40);
89
+ return result;
90
+ }
91
+ function rectangle(position, frame = false) {
92
+ return { ...position, width: position.width ?? (frame ? 320 : 220), height: position.height ?? (frame ? 180 : 96) };
93
+ }
94
+ const center = (box) => ({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
95
+ const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
96
+ const same = (a, b) => a.x === b.x && a.y === b.y;
97
+ const inside = (p, box) => p.x > box.x && p.x < box.x + box.width && p.y > box.y && p.y < box.y + box.height;
98
+ function pin(box, toward, side) {
99
+ if (side) {
100
+ const middle = center(box);
101
+ switch (side) {
102
+ case "north": return { x: middle.x, y: box.y };
103
+ case "east": return { x: box.x + box.width, y: middle.y };
104
+ case "south": return { x: middle.x, y: box.y + box.height };
105
+ case "west": return { x: box.x, y: middle.y };
106
+ }
107
+ }
108
+ const x = Math.max(box.x, Math.min(toward.x, box.x + box.width));
109
+ const y = Math.max(box.y, Math.min(toward.y, box.y + box.height));
110
+ const candidates = [
111
+ { x: box.x + box.width, y }, { x: box.x, y },
112
+ { x, y: box.y + box.height }, { x, y: box.y },
113
+ ];
114
+ return candidates.reduce((best, point) => distance(point, toward) < distance(best, toward) ? point : best);
115
+ }
116
+ function portLead(point, side) {
117
+ switch (side) {
118
+ case "north": return { x: point.x, y: point.y - CLEARANCE };
119
+ case "east": return { x: point.x + CLEARANCE, y: point.y };
120
+ case "south": return { x: point.x, y: point.y + CLEARANCE };
121
+ case "west": return { x: point.x - CLEARANCE, y: point.y };
122
+ default: return point;
123
+ }
124
+ }
125
+ function blocked(a, b, obstacles) {
126
+ return obstacles.some(box => a.x === b.x
127
+ ? a.x > box.x && a.x < box.x + box.width && Math.max(a.y, b.y) > box.y && Math.min(a.y, b.y) < box.y + box.height
128
+ : a.y > box.y && a.y < box.y + box.height && Math.max(a.x, b.x) > box.x && Math.min(a.x, b.x) < box.x + box.width);
129
+ }
130
+ function simplify(points) {
131
+ const result = [];
132
+ for (const point of points) {
133
+ if (result.length && same(result[result.length - 1], point))
134
+ continue;
135
+ while (result.length > 1) {
136
+ const a = result[result.length - 2], b = result[result.length - 1];
137
+ if (!((a.x === b.x && b.x === point.x) || (a.y === b.y && b.y === point.y)))
138
+ break;
139
+ result.pop();
140
+ }
141
+ result.push(point);
142
+ }
143
+ return result;
144
+ }
145
+ /** Rectilinear visibility grid, with deterministic shortest-path tie breaking. */
146
+ function orthogonal(start, end, obstacles) {
147
+ if (obstacles.some(box => inside(start, box) || inside(end, box)))
148
+ return [];
149
+ if (same(start, end))
150
+ return [{ ...start }];
151
+ if ((start.x === end.x || start.y === end.y) && !blocked(start, end, obstacles))
152
+ return [{ ...start }, { ...end }];
153
+ const xs = [...new Set([start.x, end.x, ...obstacles.flatMap(box => [box.x, box.x + box.width])])].sort((a, b) => a - b);
154
+ const ys = [...new Set([start.y, end.y, ...obstacles.flatMap(box => [box.y, box.y + box.height])])].sort((a, b) => a - b);
155
+ const count = xs.length * ys.length;
156
+ const point = (id) => ({ x: xs[id % xs.length], y: ys[Math.floor(id / xs.length)] });
157
+ const index = (p) => ys.indexOf(p.y) * xs.length + xs.indexOf(p.x);
158
+ const first = index(start), last = index(end);
159
+ const costs = new Float64Array(count).fill(Infinity);
160
+ const previous = new Int32Array(count).fill(-1);
161
+ const visited = new Uint8Array(count);
162
+ const heap = [];
163
+ const less = (a, b) => a.cost < b.cost || (a.cost === b.cost && a.id < b.id);
164
+ const push = (entry) => {
165
+ heap.push(entry);
166
+ let i = heap.length - 1;
167
+ while (i > 0) {
168
+ const parent = Math.floor((i - 1) / 2);
169
+ if (!less(heap[i], heap[parent]))
170
+ break;
171
+ [heap[i], heap[parent]] = [heap[parent], heap[i]];
172
+ i = parent;
173
+ }
174
+ };
175
+ const pop = () => {
176
+ const first = heap[0], tail = heap.pop();
177
+ if (heap.length) {
178
+ heap[0] = tail;
179
+ let i = 0;
180
+ while (i * 2 + 1 < heap.length) {
181
+ let child = i * 2 + 1;
182
+ if (child + 1 < heap.length && less(heap[child + 1], heap[child]))
183
+ child++;
184
+ if (!less(heap[child], heap[i]))
185
+ break;
186
+ [heap[i], heap[child]] = [heap[child], heap[i]];
187
+ i = child;
188
+ }
189
+ }
190
+ return first;
191
+ };
192
+ costs[first] = 0;
193
+ push({ id: first, cost: 0 });
194
+ while (heap.length) {
195
+ const { id } = pop();
196
+ if (visited[id])
197
+ continue;
198
+ visited[id] = 1;
199
+ if (id === last) {
200
+ const path = [];
201
+ for (let cursor = last; cursor !== -1; cursor = previous[cursor])
202
+ path.push(point(cursor));
203
+ return simplify(path.reverse());
204
+ }
205
+ const a = point(id), column = id % xs.length, row = Math.floor(id / xs.length);
206
+ const neighbors = [column > 0 ? id - 1 : -1, column + 1 < xs.length ? id + 1 : -1,
207
+ row > 0 ? id - xs.length : -1, row + 1 < ys.length ? id + xs.length : -1];
208
+ for (const next of neighbors) {
209
+ if (next < 0 || visited[next])
210
+ continue;
211
+ const b = point(next);
212
+ if (blocked(a, b, obstacles))
213
+ continue;
214
+ const cost = costs[id] + distance(a, b);
215
+ if (cost >= costs[next])
216
+ continue;
217
+ costs[next] = cost;
218
+ previous[next] = id;
219
+ push({ id: next, cost });
220
+ }
221
+ }
222
+ return [];
223
+ }
224
+ /**
225
+ * Authored points are interior waypoints; layout points override projection points.
226
+ * Explicit sides use centered ports with outward leads; unset sides snap to the
227
+ * nearest edge. Returns [] for missing endpoints, blocked ports or impossible
228
+ * waypoints (inside an obstacle), never an obstacle-crossing fallback.
229
+ */
230
+ export function routeProcessLine(line, layout, projection) {
231
+ const boxes = new Map(projection.boxes.map(box => [box.id, box]));
232
+ const fromPosition = layout.boxes[line.from], toPosition = layout.boxes[line.to];
233
+ if (!fromPosition || !toPosition)
234
+ return [];
235
+ const from = rectangle(fromPosition, boxes.get(line.from)?.frame);
236
+ const to = rectangle(toPosition, boxes.get(line.to)?.frame);
237
+ const excluded = new Set();
238
+ for (const id of [line.from, line.to]) {
239
+ let parent = boxes.get(id)?.parentId;
240
+ while (parent && !excluded.has(parent)) {
241
+ excluded.add(parent);
242
+ parent = boxes.get(parent)?.parentId;
243
+ }
244
+ }
245
+ const obstacleBoxes = projection.boxes.filter(box => !excluded.has(box.id) && layout.boxes[box.id]);
246
+ const obstacles = obstacleBoxes.map(box => rectangle(layout.boxes[box.id], box.frame));
247
+ const waypoints = (layout.lines?.[line.id] ?? line.points ?? []).map(point => ({ ...point }));
248
+ if (waypoints.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y)))
249
+ return [];
250
+ if (line.from === line.to && !waypoints.length) {
251
+ waypoints.push({ x: from.x + from.width + CLEARANCE * 2, y: from.y + from.height / 2 }, { x: from.x + from.width / 2, y: from.y - CLEARANCE * 2 });
252
+ }
253
+ const start = pin(from, waypoints[0] ?? center(to), line.fromSide);
254
+ const end = pin(to, waypoints.at(-1) ?? center(from), line.toSide);
255
+ const startLead = portLead(start, line.fromSide), endLead = portLead(end, line.toSide);
256
+ // Fixed leads preserve port direction rather than detouring along a box edge.
257
+ if (line.fromSide && blocked(start, startLead, obstacles)
258
+ || line.toSide && blocked(endLead, end, obstacles))
259
+ return [];
260
+ const anchors = [startLead, ...waypoints, endLead];
261
+ const padded = obstacles.map((rect, index) => {
262
+ if (obstacleBoxes[index].id === line.from || obstacleBoxes[index].id === line.to)
263
+ return rect;
264
+ return { x: rect.x - CLEARANCE, y: rect.y - CLEARANCE, width: rect.width + CLEARANCE * 2, height: rect.height + CLEARANCE * 2 };
265
+ });
266
+ const result = line.fromSide ? [start, startLead] : [];
267
+ for (let i = 1; i < anchors.length; i++) {
268
+ // Prefer clearance, but never reject a valid narrow corridor or waypoint.
269
+ let segment = orthogonal(anchors[i - 1], anchors[i], padded);
270
+ if (!segment.length)
271
+ segment = orthogonal(anchors[i - 1], anchors[i], obstacles);
272
+ if (!segment.length)
273
+ return [];
274
+ result.push(...(result.length ? segment.slice(1) : segment));
275
+ }
276
+ if (line.toSide)
277
+ result.push(end);
278
+ return result;
279
+ }
280
+ /** Halfway along the polyline's length, not halfway between its endpoints. */
281
+ export function lineMidpoint(points) {
282
+ if (!points.length)
283
+ return { x: 0, y: 0 };
284
+ let remaining = points.slice(1).reduce((sum, point, i) => sum + distance(points[i], point), 0) / 2;
285
+ for (let i = 1; i < points.length; i++) {
286
+ const a = points[i - 1], b = points[i], length = distance(a, b);
287
+ if (length && remaining <= length) {
288
+ const ratio = remaining / length;
289
+ return { x: a.x + (b.x - a.x) * ratio, y: a.y + (b.y - a.y) * ratio };
290
+ }
291
+ remaining -= length;
292
+ }
293
+ return { ...points[0] };
294
+ }
295
+ /** Segment-distance hit testing, including zero-length and diagonal segments. */
296
+ export function nearProcessLine(point, points, tolerance = 16) {
297
+ if (tolerance < 0 || !points.length)
298
+ return false;
299
+ if (points.length === 1)
300
+ return distance(point, points[0]) <= tolerance;
301
+ return points.slice(1).some((b, i) => {
302
+ const a = points[i], dx = b.x - a.x, dy = b.y - a.y;
303
+ const squared = dx * dx + dy * dy;
304
+ const t = squared ? Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / squared)) : 0;
305
+ return distance(point, { x: a.x + dx * t, y: a.y + dy * t }) <= tolerance;
306
+ });
307
+ }
@@ -1,2 +1,4 @@
1
1
  export * from "./model.js";
2
+ export * from "./geometry.js";
3
+ export * from "./bridge.js";
2
4
  export * from "./process-modeler.js";
@@ -1,2 +1,4 @@
1
1
  export * from "./model.js";
2
+ export * from "./geometry.js";
3
+ export * from "./bridge.js";
2
4
  export * from "./process-modeler.js";