@zoneflow/editor-dom 0.0.15 → 0.0.17

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.
@@ -0,0 +1,59 @@
1
+ import { type Point, type UniverseLayoutModel, type UniverseModel, type Zone, type ZoneId } from "@zoneflow/core";
2
+ /**
3
+ * Per-zone velocity vectors carried between simulation steps.
4
+ */
5
+ export type FloatingVelocities = Record<ZoneId, Point>;
6
+ export type FloatingLayoutOptions = {
7
+ /**
8
+ * Flow direction. Zoneflow flows left→right by default ("x"); "y" lays the
9
+ * flow out top→bottom. Connected zones are spaced along this axis and aligned
10
+ * along the perpendicular one.
11
+ */
12
+ flowAxis?: "x" | "y";
13
+ /** Target spacing (px) between a source and its target along the flow axis. */
14
+ layerGap?: number;
15
+ /** Stiffness of the flow-axis spacing force (the dominant ordering force). */
16
+ flowStrength?: number;
17
+ /**
18
+ * Stiffness of the cross-axis force that aligns a target with its source,
19
+ * keeping each chain reading as a straight flow line.
20
+ */
21
+ alignStrength?: number;
22
+ /** Strength of the inverse-square push that keeps mobile zones apart. */
23
+ repulsionStrength?: number;
24
+ /** Gentle pull toward the mobile cluster's centroid (prevents drift). */
25
+ centeringStrength?: number;
26
+ /** Velocity retention per step (0..1). Lower = settles faster. */
27
+ damping?: number;
28
+ /** Overall multiplier applied to velocity when moving (lower = slower). */
29
+ speed?: number;
30
+ /** Max distance (px) a zone may move in a single step. Keeps motion gentle. */
31
+ maxStep?: number;
32
+ /** Inner padding (px) kept between a child zone and its container's edges. */
33
+ containerPadding?: number;
34
+ /** Decides which zones drift. Defaults to action zones. */
35
+ isMobile?: (zone: Zone) => boolean;
36
+ };
37
+ export type FloatingStepResult = {
38
+ layoutModel: UniverseLayoutModel;
39
+ velocities: FloatingVelocities;
40
+ /** Total kinetic energy (sum of |v|²). Near zero once the layout settles. */
41
+ energy: number;
42
+ };
43
+ /**
44
+ * Advances a force-directed layout by one frame. Pure: given the same inputs
45
+ * it always returns the same output, so the caller (a RAF loop) owns all
46
+ * mutable state via the returned `velocities`.
47
+ *
48
+ * Forces are computed in world space; deltas are written back to each zone's
49
+ * local layout coordinates. Because only action zones are mobile and their
50
+ * container parents stay fixed, a world-space delta equals the local-space
51
+ * delta, so no coordinate conversion is needed on write-back.
52
+ */
53
+ export declare function stepFloatingLayout(params: {
54
+ model: UniverseModel;
55
+ layoutModel: UniverseLayoutModel;
56
+ velocities?: FloatingVelocities;
57
+ pinnedZoneIds?: ReadonlySet<ZoneId>;
58
+ options?: FloatingLayoutOptions;
59
+ }): FloatingStepResult;
@@ -0,0 +1,226 @@
1
+ import { getPaths, resolvePathTarget, updateZoneLayout, } from "@zoneflow/core";
2
+ import { resolveWorldZoneOrigin } from "./zoneGeometry";
3
+ import { roundCoordinate } from "./moveEditorShared";
4
+ const DEFAULTS = {
5
+ flowAxis: "x",
6
+ layerGap: 200,
7
+ flowStrength: 0.03,
8
+ alignStrength: 0.014,
9
+ repulsionStrength: 130000,
10
+ // Very weak — just enough to stop unbounded drift. Stronger values compact
11
+ // the whole graph toward the centroid and fight the left→right spread.
12
+ centeringStrength: 0.0015,
13
+ damping: 0.8,
14
+ speed: 0.9,
15
+ maxStep: 6,
16
+ containerPadding: 12,
17
+ };
18
+ const MIN_DISTANCE = 1;
19
+ function isActionZone(zone) {
20
+ return zone.zoneType === "action";
21
+ }
22
+ /**
23
+ * Advances a force-directed layout by one frame. Pure: given the same inputs
24
+ * it always returns the same output, so the caller (a RAF loop) owns all
25
+ * mutable state via the returned `velocities`.
26
+ *
27
+ * Forces are computed in world space; deltas are written back to each zone's
28
+ * local layout coordinates. Because only action zones are mobile and their
29
+ * container parents stay fixed, a world-space delta equals the local-space
30
+ * delta, so no coordinate conversion is needed on write-back.
31
+ */
32
+ export function stepFloatingLayout(params) {
33
+ const { model, layoutModel, velocities = {}, pinnedZoneIds, options } = params;
34
+ const flowAxis = options?.flowAxis ?? DEFAULTS.flowAxis;
35
+ const layerGap = options?.layerGap ?? DEFAULTS.layerGap;
36
+ const flowStrength = options?.flowStrength ?? DEFAULTS.flowStrength;
37
+ const alignStrength = options?.alignStrength ?? DEFAULTS.alignStrength;
38
+ const repulsionStrength = options?.repulsionStrength ?? DEFAULTS.repulsionStrength;
39
+ const centeringStrength = options?.centeringStrength ?? DEFAULTS.centeringStrength;
40
+ const damping = options?.damping ?? DEFAULTS.damping;
41
+ const speed = options?.speed ?? DEFAULTS.speed;
42
+ const maxStep = options?.maxStep ?? DEFAULTS.maxStep;
43
+ const containerPadding = options?.containerPadding ?? DEFAULTS.containerPadding;
44
+ const isMobile = options?.isMobile ?? isActionZone;
45
+ // 1. Sample world-space centers for every laid-out zone.
46
+ const originCache = new Map();
47
+ const samples = [];
48
+ const sampleById = new Map();
49
+ for (const zone of Object.values(model.zonesById)) {
50
+ const layout = layoutModel.zoneLayoutsById[zone.id];
51
+ if (!layout)
52
+ continue;
53
+ const origin = resolveWorldZoneOrigin({
54
+ model,
55
+ layoutModel,
56
+ zoneId: zone.id,
57
+ cache: originCache,
58
+ });
59
+ const center = {
60
+ x: origin.x + (layout.width ?? 0) / 2,
61
+ y: origin.y + (layout.height ?? 0) / 2,
62
+ };
63
+ const sample = {
64
+ zoneId: zone.id,
65
+ center,
66
+ mobile: isMobile(zone) && !pinnedZoneIds?.has(zone.id),
67
+ };
68
+ samples.push(sample);
69
+ sampleById.set(zone.id, sample);
70
+ }
71
+ const mobileSamples = samples.filter((sample) => sample.mobile);
72
+ if (mobileSamples.length === 0) {
73
+ return { layoutModel, velocities, energy: 0 };
74
+ }
75
+ // Accumulated force per mobile zone.
76
+ const forces = new Map();
77
+ for (const sample of mobileSamples) {
78
+ forces.set(sample.zoneId, { x: 0, y: 0 });
79
+ }
80
+ const addForce = (zoneId, fx, fy) => {
81
+ const force = forces.get(zoneId);
82
+ if (!force)
83
+ return; // not mobile — ignore
84
+ force.x += fx;
85
+ force.y += fy;
86
+ };
87
+ // 2. Flow forces along every path edge (source → target). The flow axis
88
+ // carries a directional spacing force (target sits one `layerGap`
89
+ // downstream of its source); the cross axis carries a gentle alignment
90
+ // force so each chain reads as a straight flow line.
91
+ for (const zone of Object.values(model.zonesById)) {
92
+ const source = sampleById.get(zone.id);
93
+ if (!source)
94
+ continue;
95
+ for (const path of getPaths(zone)) {
96
+ const targetZone = resolvePathTarget(model, path);
97
+ if (!targetZone || targetZone.id === zone.id)
98
+ continue;
99
+ const target = sampleById.get(targetZone.id);
100
+ if (!target)
101
+ continue;
102
+ if (!source.mobile && !target.mobile)
103
+ continue;
104
+ // Flow axis: drive the gap toward +layerGap (target downstream).
105
+ const flowGap = flowAxis === "x"
106
+ ? target.center.x - source.center.x
107
+ : target.center.y - source.center.y;
108
+ const flowForce = (layerGap - flowGap) * flowStrength;
109
+ // Cross axis: pull the target onto the source's line.
110
+ const crossDelta = flowAxis === "x"
111
+ ? target.center.y - source.center.y
112
+ : target.center.x - source.center.x;
113
+ const alignForce = -crossDelta * alignStrength;
114
+ if (flowAxis === "x") {
115
+ addForce(targetZone.id, flowForce, alignForce);
116
+ addForce(zone.id, -flowForce, -alignForce);
117
+ }
118
+ else {
119
+ addForce(targetZone.id, alignForce, flowForce);
120
+ addForce(zone.id, -alignForce, -flowForce);
121
+ }
122
+ }
123
+ }
124
+ // 3. Inverse-square repulsion between every pair of mobile zones.
125
+ for (let i = 0; i < mobileSamples.length; i += 1) {
126
+ for (let j = i + 1; j < mobileSamples.length; j += 1) {
127
+ const a = mobileSamples[i];
128
+ const b = mobileSamples[j];
129
+ let dx = a.center.x - b.center.x;
130
+ let dy = a.center.y - b.center.y;
131
+ let distanceSq = dx * dx + dy * dy;
132
+ if (distanceSq < MIN_DISTANCE) {
133
+ // Perfectly overlapping — nudge deterministically by index so the
134
+ // pair separates without relying on randomness.
135
+ dx = (i - j) || 1;
136
+ dy = 1;
137
+ distanceSq = dx * dx + dy * dy;
138
+ }
139
+ const distance = Math.sqrt(distanceSq);
140
+ const magnitude = repulsionStrength / distanceSq;
141
+ const ux = dx / distance;
142
+ const uy = dy / distance;
143
+ addForce(a.zoneId, ux * magnitude, uy * magnitude);
144
+ addForce(b.zoneId, -ux * magnitude, -uy * magnitude);
145
+ }
146
+ }
147
+ // 4. Centering pull toward the mobile cluster's centroid.
148
+ let centroidX = 0;
149
+ let centroidY = 0;
150
+ for (const sample of mobileSamples) {
151
+ centroidX += sample.center.x;
152
+ centroidY += sample.center.y;
153
+ }
154
+ centroidX /= mobileSamples.length;
155
+ centroidY /= mobileSamples.length;
156
+ for (const sample of mobileSamples) {
157
+ addForce(sample.zoneId, (centroidX - sample.center.x) * centeringStrength, (centroidY - sample.center.y) * centeringStrength);
158
+ }
159
+ // 5. Integrate velocities and write displaced positions back to the layout,
160
+ // clamping each child zone inside its parent container's bounds.
161
+ const nextVelocities = {};
162
+ let nextLayoutModel = layoutModel;
163
+ let energy = 0;
164
+ for (const sample of mobileSamples) {
165
+ const force = forces.get(sample.zoneId) ?? { x: 0, y: 0 };
166
+ const prev = velocities[sample.zoneId] ?? { x: 0, y: 0 };
167
+ let vx = (prev.x + force.x) * damping;
168
+ let vy = (prev.y + force.y) * damping;
169
+ // Clamp the per-step displacement so motion stays slow and gentle.
170
+ const stepDistance = Math.hypot(vx * speed, vy * speed);
171
+ if (stepDistance > maxStep) {
172
+ const scale = maxStep / stepDistance;
173
+ vx *= scale;
174
+ vy *= scale;
175
+ }
176
+ const layout = layoutModel.zoneLayoutsById[sample.zoneId];
177
+ if (!layout) {
178
+ nextVelocities[sample.zoneId] = { x: vx, y: vy };
179
+ energy += vx * vx + vy * vy;
180
+ continue;
181
+ }
182
+ let nextX = layout.x + vx * speed;
183
+ let nextY = layout.y + vy * speed;
184
+ // Containment: keep a child fully within its parent container. Local
185
+ // coordinates are relative to the parent origin, so the valid box is
186
+ // [pad, parentSize - childSize - pad] on each axis. Velocity on a clamped
187
+ // axis is zeroed so the zone rests against the wall without jitter.
188
+ const parentId = model.zonesById[sample.zoneId]?.parentZoneId;
189
+ const parentLayout = parentId
190
+ ? layoutModel.zoneLayoutsById[parentId]
191
+ : undefined;
192
+ if (parentLayout) {
193
+ const childW = layout.width ?? 0;
194
+ const childH = layout.height ?? 0;
195
+ const parentW = parentLayout.width;
196
+ const parentH = parentLayout.height;
197
+ if (parentW !== undefined) {
198
+ const maxX = Math.max(containerPadding, parentW - childW - containerPadding);
199
+ const clampedX = Math.min(Math.max(nextX, containerPadding), maxX);
200
+ if (clampedX !== nextX) {
201
+ nextX = clampedX;
202
+ vx = 0;
203
+ }
204
+ }
205
+ if (parentH !== undefined) {
206
+ const maxY = Math.max(containerPadding, parentH - childH - containerPadding);
207
+ const clampedY = Math.min(Math.max(nextY, containerPadding), maxY);
208
+ if (clampedY !== nextY) {
209
+ nextY = clampedY;
210
+ vy = 0;
211
+ }
212
+ }
213
+ }
214
+ nextVelocities[sample.zoneId] = { x: vx, y: vy };
215
+ energy += vx * vx + vy * vy;
216
+ nextLayoutModel = updateZoneLayout(nextLayoutModel, sample.zoneId, {
217
+ x: roundCoordinate(nextX),
218
+ y: roundCoordinate(nextY),
219
+ });
220
+ }
221
+ return {
222
+ layoutModel: nextLayoutModel,
223
+ velocities: nextVelocities,
224
+ energy,
225
+ };
226
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./zoneMoveEditor";
2
2
  export * from "./pathCreateEditor";
3
3
  export * from "./zOrderEditor";
4
+ export * from "./floatingLayout";
4
5
  export { alignPathsByMode, alignZonesByMode, commitZoneGroupReparentAtCurrentPosition, commitZoneReparentAtCurrentPosition, distributePathsByMode, distributeZonesByMode, resolveGroupPathDragOrigin, resolveGroupZoneDragOrigin, resolveZonePlacementAtWorldRect, resolvePathResizeOrigin, resizePathNodeByScreenDelta, } from "./zoneMoveEditor";
5
6
  export type { PathResizeOrigin } from "./zoneMoveEditor";
6
7
  export { resolvePathOutputAnchorScreenRect, retargetPathFromOutputAnchorDrag, } from "./pathCreateEditor";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./zoneMoveEditor";
2
2
  export * from "./pathCreateEditor";
3
3
  export * from "./zOrderEditor";
4
+ export * from "./floatingLayout";
4
5
  export { alignPathsByMode, alignZonesByMode, commitZoneGroupReparentAtCurrentPosition, commitZoneReparentAtCurrentPosition, distributePathsByMode, distributeZonesByMode, resolveGroupPathDragOrigin, resolveGroupZoneDragOrigin, resolveZonePlacementAtWorldRect, resolvePathResizeOrigin, resizePathNodeByScreenDelta, } from "./zoneMoveEditor";
5
6
  export { resolvePathOutputAnchorScreenRect, retargetPathFromOutputAnchorDrag, } from "./pathCreateEditor";
@@ -1,5 +1,5 @@
1
1
  import { type Path, type PathId, type Point, type UniverseLayoutModel, type UniverseModel, type Zone, type ZoneId } from "@zoneflow/core";
2
- import { type CameraState, type Rect, type RendererFrame } from "@zoneflow/renderer-dom";
2
+ import { type CameraState, type Rect, type RendererFrame, type ResolveZoneShape } from "@zoneflow/renderer-dom";
3
3
  import type { GridSnapOptions } from "./zoneMoveEditor";
4
4
  export type CanConnectPathParams = {
5
5
  mode: "create" | "retarget";
@@ -18,6 +18,7 @@ export declare function resolveZoneAnchorScreenRect(params: {
18
18
  camera: CameraState;
19
19
  zoneId: ZoneId;
20
20
  kind: "inlet" | "outlet";
21
+ resolveZoneShape?: ResolveZoneShape;
21
22
  }): Rect | undefined;
22
23
  export declare function resolveInputAnchorTargetZoneId(params: {
23
24
  model: UniverseModel;
@@ -26,6 +27,7 @@ export declare function resolveInputAnchorTargetZoneId(params: {
26
27
  point: Point;
27
28
  excludeZoneIds?: ZoneId[];
28
29
  canConnect?: (targetZoneId: ZoneId) => boolean;
30
+ resolveZoneShape?: ResolveZoneShape;
29
31
  }): ZoneId | null;
30
32
  export declare function resolvePathOutputAnchorScreenRect(params: {
31
33
  frame: RendererFrame;
@@ -1,4 +1,5 @@
1
1
  import { addPath, createPathId, isZoneInputEnabled, isZoneOutputEnabled, setPathTarget, updatePathLayout, } from "@zoneflow/core";
2
+ import { normalizeZoneShape, } from "@zoneflow/renderer-dom";
2
3
  function typedValues(record) {
3
4
  return Object.values(record);
4
5
  }
@@ -8,6 +9,9 @@ const DEFAULT_PATH_NODE_OFFSET_X = 32;
8
9
  const DEFAULT_PATH_NODE_GAP_Y = 40;
9
10
  const DEFAULT_ANCHOR_WIDTH = 24;
10
11
  const DEFAULT_ANCHOR_ATTACH_DEPTH = 10;
12
+ // Square hit area for vertex-mode anchors (circle/diamond/…), centered on the
13
+ // shape's left/right vertex to match the vertex dot drawn by the renderer.
14
+ const VERTEX_ANCHOR_HIT_SIZE = 28;
11
15
  const DEFAULT_PATH_OUTPUT_HANDLE_WIDTH = 18;
12
16
  const DEFAULT_PATH_OUTPUT_HANDLE_MIN_HEIGHT = 22;
13
17
  const DEFAULT_PATH_OUTPUT_HANDLE_MAX_HEIGHT = 40;
@@ -57,7 +61,20 @@ function projectWorldRectToScreenRect(rect, camera) {
57
61
  };
58
62
  }
59
63
  function resolveZoneAnchorRect(params) {
60
- const { zoneRect, anchor, kind } = params;
64
+ const { zoneRect, anchor, kind, mode = "edge" } = params;
65
+ if (mode === "vertex") {
66
+ // Non-rectangular zones expose their anchor only at the shape vertex, so
67
+ // the grab/drop hit area is a compact square centered on the anchor point
68
+ // instead of the full-height edge band used for rectangular zones.
69
+ // `anchor.point` is already in world coordinates (zone position + offset),
70
+ // so it is used directly — do NOT add zoneRect again.
71
+ return {
72
+ x: anchor.point.x - VERTEX_ANCHOR_HIT_SIZE / 2,
73
+ y: anchor.point.y - VERTEX_ANCHOR_HIT_SIZE / 2,
74
+ width: VERTEX_ANCHOR_HIT_SIZE,
75
+ height: VERTEX_ANCHOR_HIT_SIZE,
76
+ };
77
+ }
61
78
  if (anchor.rect) {
62
79
  return {
63
80
  x: anchor.rect.x,
@@ -82,7 +99,7 @@ export function screenPointToWorldPoint(point, camera) {
82
99
  };
83
100
  }
84
101
  export function resolveZoneAnchorScreenRect(params) {
85
- const { frame, camera, zoneId, kind } = params;
102
+ const { frame, camera, zoneId, kind, resolveZoneShape } = params;
86
103
  const zoneVisual = frame.pipeline.graphLayout.zonesById[zoneId];
87
104
  if (!zoneVisual)
88
105
  return undefined;
@@ -90,15 +107,17 @@ export function resolveZoneAnchorScreenRect(params) {
90
107
  return undefined;
91
108
  if (kind === "outlet" && !isZoneOutputEnabled(zoneVisual.zone))
92
109
  return undefined;
110
+ const mode = normalizeZoneShape(resolveZoneShape?.(zoneVisual.zone)).anchors;
93
111
  const anchorRect = resolveZoneAnchorRect({
94
112
  zoneRect: zoneVisual.rect,
95
113
  anchor: zoneVisual.anchors[kind],
96
114
  kind,
115
+ mode,
97
116
  });
98
117
  return projectWorldRectToScreenRect(anchorRect, camera);
99
118
  }
100
119
  export function resolveInputAnchorTargetZoneId(params) {
101
- const { model, frame, camera, point, excludeZoneIds, canConnect } = params;
120
+ const { model, frame, camera, point, excludeZoneIds, canConnect, resolveZoneShape, } = params;
102
121
  const excluded = new Set(excludeZoneIds ?? []);
103
122
  let bestZoneId = null;
104
123
  let bestArea = Number.POSITIVE_INFINITY;
@@ -117,6 +136,7 @@ export function resolveInputAnchorTargetZoneId(params) {
117
136
  camera,
118
137
  zoneId: zoneVisual.zoneId,
119
138
  kind: "inlet",
139
+ resolveZoneShape,
120
140
  });
121
141
  if (!rect || !containsPoint(rect, point))
122
142
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoneflow/editor-dom",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "license": "MIT",
5
5
  "description": "Low-level editor geometry and interaction helpers for Zoneflow.",
6
6
  "type": "module",
@@ -19,8 +19,8 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@zoneflow/core": "0.0.15",
23
- "@zoneflow/renderer-dom": "0.0.15"
22
+ "@zoneflow/core": "0.0.17",
23
+ "@zoneflow/renderer-dom": "0.0.17"
24
24
  },
25
25
  "scripts": {
26
26
  "build": "tsc -p tsconfig.json",