@pixerate/editor-svelte 0.120.0 → 0.122.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.
@@ -17,8 +17,13 @@ export interface CreateCanvasExplosionOptions<TNode extends CanvasNode = CanvasN
17
17
  onSave?: () => void;
18
18
  }
19
19
  /**
20
- * Headless Svelte 5 rune managing canvas node explosion, spatial layout displacement,
21
- * coordinated multi-node animation paths, and reversible collapsing.
20
+ * Imperative explosion coordinator for a mutable graph (whiteboards, diagrams): adds child
21
+ * nodes and edges, animates them out along a trajectory while surrounding nodes slide aside,
22
+ * and collapses back exactly. The layout math is `computeCanvasExplosionLayout` from
23
+ * `@pixerate/editor/canvas`; several nodes can be exploded at once. For a canvas derived from
24
+ * state (`$derived`), use `createCanvasExplosionState` and the layout functions instead.
25
+ *
26
+ * `explodeNode` throws when a child id is already on the canvas or repeated.
22
27
  */
23
28
  export declare function createCanvasExplosion<TNode extends CanvasNode = CanvasNode, TEdge extends CanvasEdge = CanvasEdge>(graph: CanvasGraphTarget<TNode, TEdge>, options?: CreateCanvasExplosionOptions<TNode>): {
24
29
  readonly explodedNodeIds: string[];
@@ -1,9 +1,14 @@
1
1
  import { cubicOut } from 'svelte/easing';
2
- import { calculateDirectionalDisplacement, calculateReflowDisplacement } from './createCanvasDisplacement.js';
2
+ import { computeCanvasExplosionLayout } from '@pixerate/editor/canvas';
3
3
  import { createFanOutTrajectory, linearPositionTrajectory, runMultiNodeTransition } from './createCanvasTrajectory.js';
4
4
  /**
5
- * Headless Svelte 5 rune managing canvas node explosion, spatial layout displacement,
6
- * coordinated multi-node animation paths, and reversible collapsing.
5
+ * Imperative explosion coordinator for a mutable graph (whiteboards, diagrams): adds child
6
+ * nodes and edges, animates them out along a trajectory while surrounding nodes slide aside,
7
+ * and collapses back exactly. The layout math is `computeCanvasExplosionLayout` from
8
+ * `@pixerate/editor/canvas`; several nodes can be exploded at once. For a canvas derived from
9
+ * state (`$derived`), use `createCanvasExplosionState` and the layout functions instead.
10
+ *
11
+ * `explodeNode` throws when a child id is already on the canvas or repeated.
7
12
  */
8
13
  export function createCanvasExplosion(graph, options = {}) {
9
14
  let explodedNodeIds = $state([]);
@@ -36,98 +41,50 @@ export function createCanvasExplosion(graph, options = {}) {
36
41
  return explodedNodeIds.includes(nodeId);
37
42
  }
38
43
  function explodeNode(parentId, explodeOptions) {
39
- if (isExploded(parentId)) {
44
+ // Already exploded, or its explosion is still animating
45
+ if (isExploded(parentId) || snapshots.has(parentId)) {
40
46
  return false;
41
47
  }
42
48
  const parentNode = graph.nodes.find((n) => n.id === parentId);
43
49
  if (!parentNode) {
44
50
  return false;
45
51
  }
46
- const { childNodes, childEdges = [], connectParentToChildren = 'all', displacementStrategy = 'directional', directionalOptions = {}, reflowOptions = {}, trajectory, duration = 500, staggerDelay = 20, easing = cubicOut, onComplete } = explodeOptions;
47
- if (childNodes.length === 0) {
52
+ const { childNodes: requestedChildren, childEdges = [], connectParentToChildren = 'all', displacementStrategy = 'directional', directionalOptions = {}, reflowOptions = {}, trajectory, duration = 500, staggerDelay = 20, easing = cubicOut, onComplete } = explodeOptions;
53
+ if (requestedChildren.length === 0) {
48
54
  return false;
49
55
  }
50
- // 1. Calculate displacement for existing nodes
51
- let displacementMap;
52
- if (displacementStrategy === 'dagre') {
53
- displacementMap = calculateReflowDisplacement({
54
- existingNodes: graph.nodes,
55
- existingEdges: graph.edges,
56
- originNode: parentNode,
57
- childNodes,
58
- childEdges,
59
- getNodesBounds: options.getNodesBounds,
60
- options: {
61
- ...reflowOptions,
62
- direction: reflowOptions.direction ?? 'LR'
63
- }
64
- });
65
- }
66
- else if (displacementStrategy === 'none') {
67
- displacementMap = new Map();
68
- }
69
- else {
70
- displacementMap = calculateDirectionalDisplacement({
71
- existingNodes: graph.nodes,
72
- originNode: parentNode,
73
- childNodes,
74
- getNodesBounds: options.getNodesBounds,
75
- options: directionalOptions
76
- });
77
- }
78
- // 2. Build snapshot of displaced nodes before mutating positions
79
- const displacedNodesSnapshot = Array.from(displacementMap.entries()).map(([id]) => {
80
- const node = graph.nodes.find((n) => n.id === id);
81
- return {
82
- id,
83
- originalPosition: { ...(node?.position ?? { x: 0, y: 0 }) }
84
- };
56
+ // 1-3. Pure layout: displacement of existing nodes, child copies and the edges to add
57
+ // Loose type parameter: SvelteFlow `class` is ClassValue, core `CanvasNode.class` is a string (see GOTCHAS).
58
+ const layout = computeCanvasExplosionLayout({
59
+ existingNodes: graph.nodes,
60
+ existingEdges: graph.edges,
61
+ originNode: parentNode,
62
+ childNodes: requestedChildren,
63
+ childEdges,
64
+ strategy: displacementStrategy,
65
+ options: directionalOptions,
66
+ reflowOptions,
67
+ connectParentToChildren,
68
+ createEdge: graph.createEdge ? (source, target) => graph.createEdge(source, target) : undefined,
69
+ getNodesBounds: options.getNodesBounds
85
70
  });
86
- // 3. Prepare child edges & parent connection edges
87
- const edgesToAdd = [...childEdges];
88
- const defaultCreateEdge = (sourceId, targetId) => {
89
- if (graph.createEdge) {
90
- return graph.createEdge(sourceId, targetId);
91
- }
92
- return {
93
- id: crypto.randomUUID(),
94
- type: 'default',
95
- source: sourceId,
96
- target: targetId
97
- };
98
- };
99
- if (connectParentToChildren === 'all' || connectParentToChildren === true) {
100
- childNodes.forEach((child) => {
101
- const exists = edgesToAdd.some((e) => e.source === parentId && e.target === child.id);
102
- if (!exists) {
103
- edgesToAdd.push(defaultCreateEdge(parentId, child.id));
104
- }
105
- });
106
- }
107
- else if (connectParentToChildren === 'first' && childNodes.length > 0) {
108
- const firstChild = childNodes[0];
109
- const exists = edgesToAdd.some((e) => e.source === parentId && e.target === firstChild.id);
110
- if (!exists) {
111
- edgesToAdd.push(defaultCreateEdge(parentId, firstChild.id));
112
- }
113
- }
71
+ const displacementMap = layout.displacedPositions;
72
+ const childNodes = layout.childNodes;
73
+ const edgesToAdd = layout.childEdges.map((edge) => ({ ...edge }));
114
74
  const snapshot = {
115
75
  parentId,
116
76
  childNodeIds: childNodes.map((n) => n.id),
117
77
  childEdgeIds: edgesToAdd.map((e) => e.id),
118
- displacedNodes: displacedNodesSnapshot,
78
+ displacedNodes: [...layout.originalPositions].map(([id, originalPosition]) => ({ id, originalPosition })),
119
79
  timestamp: Date.now()
120
80
  };
121
81
  snapshots.set(parentId, snapshot);
122
- // 4. Position child nodes initially at origin node coordinate
82
+ // 4. Position child nodes (copies, so the caller's objects stay untouched) at the origin
123
83
  const originPos = { ...parentNode.position };
124
84
  const targetPositions = childNodes.map((child) => ({ ...child.position }));
125
85
  childNodes.forEach((child) => {
126
86
  child.position = { ...originPos };
127
- if (!child.data)
128
- child.data = {};
129
- child.data.scale = 0;
130
- child.data.opacity = 0;
87
+ child.data = { ...(child.data ?? {}), scale: 0, opacity: 0 };
131
88
  const currentStyle = (child.style || '').replace(/opacity:\s*[^;]+;?/g, '').trim();
132
89
  child.style = `${currentStyle ? currentStyle + '; ' : ''}opacity: 0;`;
133
90
  });
@@ -330,7 +287,7 @@ export function createCanvasExplosion(graph, options = {}) {
330
287
  return true;
331
288
  }
332
289
  function toggleExplode(parentId, explodeOptions, collapseOptions) {
333
- if (isExploded(parentId)) {
290
+ if (snapshots.has(parentId)) {
334
291
  return collapseNode(parentId, collapseOptions);
335
292
  }
336
293
  return explodeNode(parentId, explodeOptions);
@@ -0,0 +1,17 @@
1
+ import { type CanvasExplosionState, type CanvasExplosionStateController, type CanvasExplosionStateOptions } from '@pixerate/editor/canvas';
2
+ export type { CanvasExplosionState, CanvasExplosionStateOptions };
3
+ export interface CanvasExplosionStateRune extends CanvasExplosionState {
4
+ /** The framework-free controller underneath (for `canvasExplosionToolTarget` and agent tools). */
5
+ readonly controller: CanvasExplosionStateController;
6
+ }
7
+ /**
8
+ * Headless, declarative explosion state as a Svelte 5 rune: which node is exploded
9
+ * (`activeId`), which is still collapsing (`collapsingId`, so its children can play a collapse
10
+ * animation before unmounting), and the collapse timer. It never touches a graph: derive the
11
+ * canvas from it with `$derived` and `computeCanvasExplosionLayout` /
12
+ * `calculateDirectionalDisplacement`.
13
+ *
14
+ * Created during component initialisation it cleans up its timer when the component unmounts;
15
+ * created elsewhere, call `destroy()` yourself.
16
+ */
17
+ export declare function createCanvasExplosionState(options?: CanvasExplosionStateOptions): CanvasExplosionStateRune;
@@ -0,0 +1,44 @@
1
+ import { createCanvasExplosionStateController } from '@pixerate/editor/canvas';
2
+ /**
3
+ * Headless, declarative explosion state as a Svelte 5 rune: which node is exploded
4
+ * (`activeId`), which is still collapsing (`collapsingId`, so its children can play a collapse
5
+ * animation before unmounting), and the collapse timer. It never touches a graph: derive the
6
+ * canvas from it with `$derived` and `computeCanvasExplosionLayout` /
7
+ * `calculateDirectionalDisplacement`.
8
+ *
9
+ * Created during component initialisation it cleans up its timer when the component unmounts;
10
+ * created elsewhere, call `destroy()` yourself.
11
+ */
12
+ export function createCanvasExplosionState(options = {}) {
13
+ const controller = createCanvasExplosionStateController(options);
14
+ let snapshot = $state(controller.getSnapshot());
15
+ controller.subscribe(() => {
16
+ snapshot = controller.getSnapshot();
17
+ });
18
+ try {
19
+ $effect(() => () => controller.destroy());
20
+ }
21
+ catch {
22
+ // Not inside a component or effect: the caller owns destroy().
23
+ }
24
+ return {
25
+ controller,
26
+ get activeId() {
27
+ return snapshot.activeId;
28
+ },
29
+ get collapsingId() {
30
+ return snapshot.collapsingId;
31
+ },
32
+ get isExplodingOrCollapsing() {
33
+ return snapshot.activeId !== null || snapshot.collapsingId !== null;
34
+ },
35
+ isExploded: (nodeId) => snapshot.activeId === nodeId,
36
+ isCollapsing: (nodeId) => snapshot.collapsingId === nodeId,
37
+ shouldRenderChildren: (nodeId) => snapshot.activeId === nodeId || snapshot.collapsingId === nodeId,
38
+ explode: (nodeId) => controller.explode(nodeId),
39
+ collapse: (nodeId) => controller.collapse(nodeId),
40
+ toggle: (nodeId) => controller.toggle(nodeId),
41
+ finishCollapse: () => controller.finishCollapse(),
42
+ destroy: () => controller.destroy()
43
+ };
44
+ }
@@ -6,7 +6,8 @@ export { createCanvasDocking, type CreateCanvasDockingOptions, type DockStrategy
6
6
  export { createCanvasShortcuts } from './createCanvasShortcuts.js';
7
7
  export { createCanvasInteractions, defaultSvelteFlowPreset, miroCompatiblePreset, restorePanelPointerEvents, isolateCanvasGestures, type CreateCanvasInteractionsOptions } from './createCanvasInteractions.svelte.js';
8
8
  export { linearTrajectory, linearPositionTrajectory, createFanOutTrajectory, createBezierTrajectory, runMultiNodeTransition } from './createCanvasTrajectory.js';
9
- export { computeNodesBoundingBox, calculateDirectionalDisplacement, calculateReflowDisplacement } from './createCanvasDisplacement.js';
9
+ export { computeNodesBoundingBox, nodeSizeOf, calculateDirectionalDisplacement, calculateReflowDisplacement, calculateDagreReflowDisplacement, computeCanvasExplosionLayout, explosionEdgeId, createCanvasExplosionStateController, createCanvasExplosionToolProvider, canvasExplosionToolTarget, type ComputeCanvasExplosionLayoutParams, type CanvasExplosionLayoutResult, type CanvasExplosionStateController, type CanvasExplosionStateSnapshot, type CanvasExplosionToolTarget, type CanvasExplosionToolProviderOptions } from '@pixerate/editor/canvas';
10
+ export { createCanvasExplosionState, type CanvasExplosionState, type CanvasExplosionStateOptions, type CanvasExplosionStateRune } from './createCanvasExplosionState.svelte.js';
10
11
  export { createCanvasExplosion, type CanvasGraphTarget, type CreateCanvasExplosionOptions } from './createCanvasExplosion.svelte.js';
11
12
  export { createCanvasNodeSync, preserveNodeMeasurements, normalizeNodeHandles, snapshotCanvasGraph, type HandleConfig, type NodeMeasurement, type CreateCanvasNodeSyncOptions, type CanvasGraphSnapshotInput } from './createCanvasNodeSync.svelte.js';
12
13
  export { createCanvasMultiDrag, type MultiDragState, type MultiDragFollowerOffset, type CreateCanvasMultiDragOptions, type FollowerTransform } from './createCanvasMultiDrag.svelte.js';
@@ -14,9 +14,11 @@ export { createCanvasShortcuts } from './createCanvasShortcuts.js';
14
14
  export { createCanvasInteractions, defaultSvelteFlowPreset, miroCompatiblePreset, restorePanelPointerEvents, isolateCanvasGestures } from './createCanvasInteractions.svelte.js';
15
15
  // Trajectory calculation & multi-node transitions
16
16
  export { linearTrajectory, linearPositionTrajectory, createFanOutTrajectory, createBezierTrajectory, runMultiNodeTransition } from './createCanvasTrajectory.js';
17
- // Spatial layout displacement
18
- export { computeNodesBoundingBox, calculateDirectionalDisplacement, calculateReflowDisplacement } from './createCanvasDisplacement.js';
19
- // Node explosion & collapse rune
17
+ // Spatial layout displacement & pure explosion layout (core)
18
+ export { computeNodesBoundingBox, nodeSizeOf, calculateDirectionalDisplacement, calculateReflowDisplacement, calculateDagreReflowDisplacement, computeCanvasExplosionLayout, explosionEdgeId, createCanvasExplosionStateController, createCanvasExplosionToolProvider, canvasExplosionToolTarget } from '@pixerate/editor/canvas';
19
+ // Declarative explosion state rune
20
+ export { createCanvasExplosionState } from './createCanvasExplosionState.svelte.js';
21
+ // Imperative explosion coordinator rune
20
22
  export { createCanvasExplosion } from './createCanvasExplosion.svelte.js';
21
23
  // Node synchronization & handle normalization
22
24
  export { createCanvasNodeSync, preserveNodeMeasurements, normalizeNodeHandles, snapshotCanvasGraph } from './createCanvasNodeSync.svelte.js';
@@ -1,4 +1,5 @@
1
1
  import type { Node, Edge, XYPosition, ColorMode } from '@xyflow/svelte';
2
+ import type { DirectionalDisplacementOptions, ExplosionDisplacementStrategy, ReflowDisplacementOptions } from '@pixerate/editor/canvas';
2
3
  /**
3
4
  * Generic node interface representing an element on the canvas.
4
5
  */
@@ -120,26 +121,7 @@ export interface NodeTransition<TNode extends CanvasNode = CanvasNode> {
120
121
  index?: number;
121
122
  total?: number;
122
123
  }
123
- /**
124
- * Displacement axis / direction.
125
- */
126
- export type DisplacementDirection = 'right' | 'left' | 'down' | 'up' | 'vertical';
127
- /**
128
- * Options for directional displacement calculation.
129
- */
130
- export interface DirectionalDisplacementOptions {
131
- direction?: DisplacementDirection;
132
- gap?: number;
133
- spreadSiblingLanes?: boolean;
134
- siblingTolerance?: number;
135
- minY?: number;
136
- }
137
- /**
138
- * Options for Dagre reflow displacement calculation.
139
- */
140
- export interface ReflowDisplacementOptions extends CanvasLayoutOptions {
141
- anchorNodeId: string;
142
- }
124
+ export type { DisplacementDirection, DirectionalDisplacementOptions, ReflowDisplacementOptions, ExplosionDisplacementStrategy } from '@pixerate/editor/canvas';
143
125
  /**
144
126
  * Snapshot of canvas graph state prior to node explosion, enabling exact reversibility.
145
127
  */
@@ -160,8 +142,7 @@ export interface ExplodeNodeOptions<TNode extends CanvasNode = CanvasNode, TEdge
160
142
  childNodes: TNode[];
161
143
  childEdges?: TEdge[];
162
144
  connectParentToChildren?: boolean | 'first' | 'all';
163
- connectChildrenToDownstream?: boolean;
164
- displacementStrategy?: 'directional' | 'dagre' | 'none';
145
+ displacementStrategy?: ExplosionDisplacementStrategy;
165
146
  directionalOptions?: DirectionalDisplacementOptions;
166
147
  reflowOptions?: Partial<ReflowDisplacementOptions>;
167
148
  trajectory?: TrajectoryFunction;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { chartColorInputValue, chartSeriesColor, chartTreeFromSeries, formatChartNumber, type ChartController } from '@pixerate/editor/chart';
3
3
  import ChartCommitInput from './ChartCommitInput.svelte';
4
+ import ChartLinksGrid from './ChartLinksGrid.svelte';
4
5
  import ChartTreeGrid from './ChartTreeGrid.svelte';
5
6
  import { chartStateOf } from './createChartEditor.svelte.js';
6
7
  import { chartButtonClass, chartCellClass, chartIconButtonClass, chartInputClass } from './styles.js';
@@ -32,7 +33,9 @@
32
33
  };
33
34
  </script>
34
35
 
35
- {#if spec.tree.length}
36
+ {#if spec.links.length || spec.type === 'sankey'}
37
+ <ChartLinksGrid {controller} {readOnly} class={className} />
38
+ {:else if spec.tree.length}
36
39
  <ChartTreeGrid {controller} {readOnly} class={className} />
37
40
  {:else}
38
41
  <div class="overflow-auto rounded-md border border-border {className}" data-chart-grid="">
@@ -12,6 +12,7 @@
12
12
  CHART_COLOR_SCHEMES,
13
13
  CHART_CURVES,
14
14
  CHART_RADAR_GRATICULES,
15
+ CHART_SANKEY_LINK_COLORS,
15
16
  CHART_ERROR_BAR_DIRECTIONS,
16
17
  CHART_ERROR_BAR_LABELS,
17
18
  CHART_ERROR_BAR_TYPES,
@@ -423,6 +424,16 @@
423
424
  </div>
424
425
  </div>
425
426
  {/if}
427
+ {#if spec.type === 'sankey'}
428
+ <div class="flex items-center gap-2">
429
+ <label for="{idPrefix}-sankey-color" class="{chartLabelClass} w-20 shrink-0">Flows</label>
430
+ <select id="{idPrefix}-sankey-color" class={chartInputClass} value={spec.sankeyLinkColor} data-option="sankeyLinkColor" onchange={(e) => controller.update({ sankeyLinkColor: e.currentTarget.value as (typeof CHART_SANKEY_LINK_COLORS)[number] })}>
431
+ {#each CHART_SANKEY_LINK_COLORS as c (c)}
432
+ <option value={c}>{c === 'source' ? 'Coloured by source' : c === 'target' ? 'Coloured by target' : 'Grey'}</option>
433
+ {/each}
434
+ </select>
435
+ </div>
436
+ {/if}
426
437
  {#if spec.type === 'sunburst'}
427
438
  <div class="flex items-center gap-2">
428
439
  <label for="{idPrefix}-sunburst-label" class="{chartLabelClass} w-20 shrink-0">Caption</label>
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import { addChartLink, setChartLinkField, type ChartController } from '@pixerate/editor/chart';
3
+ import ChartCommitInput from './ChartCommitInput.svelte';
4
+ import { chartStateOf } from './createChartEditor.svelte.js';
5
+ import { chartButtonClass, chartCellClass, chartIconButtonClass } from './styles.js';
6
+
7
+ interface Props {
8
+ controller: ChartController;
9
+ /** Hide the add / delete buttons. */
10
+ readOnly?: boolean;
11
+ class?: string;
12
+ }
13
+
14
+ let { controller, readOnly = false, class: className = '' }: Props = $props();
15
+
16
+ const view = chartStateOf(() => controller);
17
+ const links = $derived(view.current.spec.links);
18
+ const commit = (next: unknown) => controller.setLinks(next).ok;
19
+ const fields = ['source', 'target'] as const;
20
+ </script>
21
+
22
+ <!-- Flows as a table: source, target and value per row. Edits are checked as a whole (no loops, no repeated pairs). -->
23
+ <div class="overflow-auto rounded-md border border-border {className}" data-chart-grid="" data-chart-links-grid="">
24
+ <table class="w-full border-collapse text-xs">
25
+ <thead class="bg-muted/50">
26
+ <tr>
27
+ <th class="border-b border-r border-border px-2 py-1 text-left font-medium text-muted-foreground">Source</th>
28
+ <th class="border-b border-r border-border px-2 py-1 text-left font-medium text-muted-foreground">Target</th>
29
+ <th class="border-b border-r border-border px-2 py-1 text-right font-medium text-muted-foreground">Value</th>
30
+ {#if !readOnly}<th class="w-8 border-b border-border"></th>{/if}
31
+ </tr>
32
+ </thead>
33
+ <tbody>
34
+ {#each links as link, i (i)}
35
+ <tr data-link-row={i}>
36
+ {#each fields as field (field)}
37
+ <td class="border-b border-r border-border p-0">
38
+ <ChartCommitInput value={link[field]} aria-label="{field === 'source' ? 'Source' : 'Target'} of flow {i + 1}" data-link-cell="{i}:{field}" readonly={readOnly} oncommit={(text) => commit(setChartLinkField(links, i, field, text))} class="{chartCellClass} text-left" />
39
+ </td>
40
+ {/each}
41
+ <td class="border-b border-r border-border p-0">
42
+ <ChartCommitInput value={String(link.value)} inputmode="decimal" aria-label="Value of {link.source} → {link.target}" data-link-cell="{i}:value" readonly={readOnly} oncommit={(text) => commit(setChartLinkField(links, i, 'value', text))} class={chartCellClass} />
43
+ </td>
44
+ {#if !readOnly}
45
+ <td class="border-b border-border px-1">
46
+ <button type="button" class={chartIconButtonClass} title="Delete flow" aria-label="Delete {link.source} → {link.target}" data-action="link-remove" onclick={() => controller.removeLink(i)}>×</button>
47
+ </td>
48
+ {/if}
49
+ </tr>
50
+ {/each}
51
+ </tbody>
52
+ </table>
53
+ {#if !readOnly}
54
+ <div class="flex flex-wrap gap-1 p-1">
55
+ <button type="button" class="{chartButtonClass} h-6 border-dashed" data-action="link-add" onclick={() => commit(addChartLink(links))}>+ Add flow</button>
56
+ <span class="flex-1"></span>
57
+ {#if links.length}
58
+ <button type="button" class="{chartButtonClass} h-6" data-action="links-clear" title="Keep how much passes through each node as plain data" onclick={() => controller.setLinks([])}>Remove flows</button>
59
+ {/if}
60
+ </div>
61
+ {/if}
62
+ </div>
@@ -0,0 +1,10 @@
1
+ import { type ChartController } from '@pixerate/editor/chart';
2
+ interface Props {
3
+ controller: ChartController;
4
+ /** Hide the add / delete buttons. */
5
+ readOnly?: boolean;
6
+ class?: string;
7
+ }
8
+ declare const ChartLinksGrid: import("svelte").Component<Props, {}, "">;
9
+ type ChartLinksGrid = ReturnType<typeof ChartLinksGrid>;
10
+ export default ChartLinksGrid;
@@ -9,6 +9,7 @@ export { default as ChartEditor } from "./ChartEditor.svelte";
9
9
  export { default as ChartCanvas } from "./ChartCanvas.svelte";
10
10
  export { default as ChartDataGrid } from "./ChartDataGrid.svelte";
11
11
  export { default as ChartTreeGrid } from "./ChartTreeGrid.svelte";
12
+ export { default as ChartLinksGrid } from "./ChartLinksGrid.svelte";
12
13
  export { default as ChartInspector } from "./ChartInspector.svelte";
13
14
  export { default as ChartCommitInput } from "./ChartCommitInput.svelte";
14
15
  export { default as ChartMarks } from "./ChartMarks.svelte";
@@ -2,6 +2,7 @@ export { default as ChartEditor } from "./ChartEditor.svelte";
2
2
  export { default as ChartCanvas } from "./ChartCanvas.svelte";
3
3
  export { default as ChartDataGrid } from "./ChartDataGrid.svelte";
4
4
  export { default as ChartTreeGrid } from "./ChartTreeGrid.svelte";
5
+ export { default as ChartLinksGrid } from "./ChartLinksGrid.svelte";
5
6
  export { default as ChartInspector } from "./ChartInspector.svelte";
6
7
  export { default as ChartCommitInput } from "./ChartCommitInput.svelte";
7
8
  export { default as ChartMarks } from "./ChartMarks.svelte";
package/dist/index.d.ts CHANGED
@@ -49,3 +49,4 @@ export * from "./generation/index.js";
49
49
  export * from "./find-replace/index.js";
50
50
  export * from "./tables/index.js";
51
51
  export * from "./chart-node/index.js";
52
+ export * from "./style/index.js";
package/dist/index.js CHANGED
@@ -29,3 +29,4 @@ export * from "./generation/index.js";
29
29
  export * from "./find-replace/index.js";
30
30
  export * from "./tables/index.js";
31
31
  export * from "./chart-node/index.js";
32
+ export * from "./style/index.js";
@@ -1,8 +1,14 @@
1
- import { type EditorDocument, type EditorDocumentFile, type LoadReport } from "@pixerate/editor/persistence";
1
+ import { type EditorAssetGarbageOptions, type EditorDocument, type EditorDocumentFile, type LoadReport } from "@pixerate/editor/persistence";
2
2
  import { type CreateAutosaveOptions, type SvelteAutosave } from "./autosave.svelte.js";
3
3
  export interface CreateEditorAutosaveOptions extends Omit<CreateAutosaveOptions<EditorDocumentFile>, "key" | "getSnapshot" | "serialize" | "deserialize" | "history"> {
4
4
  /** Snapshot key (default the document's `kind`). */
5
5
  key?: string;
6
+ /**
7
+ * For documents with `assets`: after `markClean` or `discard` retire a snapshot, delete the
8
+ * stored assets nothing refers to any more (`collectEditorAssetGarbage`, best effort, keeping
9
+ * the assets the document uses now; default `true`). Needs `storage.keys`; `false` turns it off.
10
+ */
11
+ collectAssetGarbage?: boolean | Omit<EditorAssetGarbageOptions, "dryRun" | "only">;
6
12
  }
7
13
  export interface SvelteEditorAutosave extends Omit<SvelteAutosave<EditorDocumentFile>, "restore"> {
8
14
  /** Loads the recovered snapshot into the document and starts autosaving. */
@@ -14,4 +20,4 @@ export interface SvelteEditorAutosave extends Omit<SvelteAutosave<EditorDocument
14
20
  * `EditorDocumentFile` after each change, and `restore` loads a recovered
15
21
  * snapshot straight into the editor. Status and recovery stay reactive.
16
22
  */
17
- export declare function createEditorAutosave(doc: EditorDocument, options: CreateEditorAutosaveOptions): SvelteEditorAutosave;
23
+ export declare function createEditorAutosave(doc: EditorDocument, { collectAssetGarbage, ...options }: CreateEditorAutosaveOptions): SvelteEditorAutosave;
@@ -1,4 +1,4 @@
1
- import { parseEditorDocument, toEditorDocumentFile, withEditorAssets, } from "@pixerate/editor/persistence";
1
+ import { collectRetiredEditorAssets, parseEditorDocument, toEditorDocumentFile, withEditorAssets, } from "@pixerate/editor/persistence";
2
2
  import { createAutosave } from "./autosave.svelte.js";
3
3
  /**
4
4
  * `createAutosave` for any editor's `EditorDocument` (e.g.
@@ -6,7 +6,9 @@ import { createAutosave } from "./autosave.svelte.js";
6
6
  * `EditorDocumentFile` after each change, and `restore` loads a recovered
7
7
  * snapshot straight into the editor. Status and recovery stay reactive.
8
8
  */
9
- export function createEditorAutosave(doc, options) {
9
+ export function createEditorAutosave(doc, { collectAssetGarbage, ...options }) {
10
+ // Retiring a snapshot can leave assets nothing refers to any more.
11
+ const collect = () => collectRetiredEditorAssets(options.storage, doc, collectAssetGarbage);
10
12
  const autosave = createAutosave({
11
13
  ...options,
12
14
  // Image and paint documents hold asset references: their bytes are saved and read with them.
@@ -30,10 +32,16 @@ export function createEditorAutosave(doc, options) {
30
32
  const snapshot = autosave.restore();
31
33
  return snapshot ? doc.loadDocument(snapshot, { history: "reset" }) : undefined;
32
34
  },
33
- discard: () => autosave.discard(),
35
+ async discard() {
36
+ await autosave.discard();
37
+ await collect();
38
+ },
34
39
  ready: () => autosave.ready(),
35
40
  flushNow: () => autosave.flushNow(),
36
- markClean: () => autosave.markClean(),
41
+ async markClean() {
42
+ await autosave.markClean();
43
+ await collect();
44
+ },
37
45
  notifyChange: () => autosave.notifyChange(),
38
46
  checked: autosave.checked,
39
47
  controller: autosave.controller,
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import type { StyleCheckReport, StyleFinding } from "@pixerate/editor/tools";
3
+
4
+ interface Props {
5
+ /** A check report (`useStyleFindings(...).report`, or a `check_*_style` tool result). Renders nothing while `null`. */
6
+ report: StyleCheckReport | null | undefined;
7
+ /** Called when a finding with a `target` is clicked, e.g. to select that series in the chart. */
8
+ onSelect?: (finding: StyleFinding) => void;
9
+ /** Heading (default "House style"). */
10
+ title?: string;
11
+ class?: string;
12
+ }
13
+
14
+ let { report, onSelect, title = "House style", class: className = "" }: Props = $props();
15
+
16
+ const SEVERITY_CLASS: Record<StyleFinding["severity"], string> = {
17
+ error: "text-destructive",
18
+ warning: "text-amber-600",
19
+ info: "text-muted-foreground",
20
+ };
21
+ </script>
22
+
23
+ {#snippet body(finding: StyleFinding)}
24
+ <span data-style-severity={finding.severity} class="shrink-0 font-semibold uppercase text-[10px] {SEVERITY_CLASS[finding.severity]}">
25
+ {finding.severity}
26
+ </span>
27
+ <span class="flex min-w-0 flex-col">
28
+ <span data-style-message="">{finding.message}</span>
29
+ <span class="text-[10px] text-muted-foreground"><code>{finding.check}</code>{#if finding.layer} · {finding.layer}{/if}</span>
30
+ </span>
31
+ {/snippet}
32
+
33
+ {#if report}
34
+ <section data-style-findings="" aria-label={title} class="flex flex-col gap-1.5 text-xs text-foreground {className}">
35
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
36
+ {#if report.findings.length > 0}
37
+ <ul class="flex flex-col gap-1">
38
+ {#each report.findings as finding, i (`${finding.check}-${i}`)}
39
+ <li data-style-finding={finding.check}>
40
+ {#if finding.target !== undefined && onSelect}
41
+ <button
42
+ type="button"
43
+ class="flex w-full items-start gap-2 rounded border border-border px-2 py-1 text-left hover:bg-muted"
44
+ onclick={() => onSelect?.(finding)}
45
+ >
46
+ {@render body(finding)}
47
+ </button>
48
+ {:else}
49
+ <div class="flex items-start gap-2 rounded border border-border px-2 py-1">{@render body(finding)}</div>
50
+ {/if}
51
+ </li>
52
+ {/each}
53
+ </ul>
54
+ {/if}
55
+ {#if report.findings.length === 0 && report.ran.length > 0}
56
+ <p data-style-clear="" class="text-emerald-600">
57
+ All clear: {report.ran.length} {report.ran.length === 1 ? "check" : "checks"} passed.
58
+ </p>
59
+ {/if}
60
+ {#if report.findings.length === 0 && report.ran.length === 0 && report.skipped.length === 0}
61
+ <p class="text-muted-foreground">No house-style checks apply.</p>
62
+ {/if}
63
+ {#if report.skipped.length > 0}
64
+ <ul data-style-skipped="" aria-label="Skipped checks" class="flex flex-col gap-0.5 text-muted-foreground">
65
+ {#each report.skipped as s (s.check)}
66
+ <li data-style-skipped-check={s.check}>Skipped <code>{s.check}</code>: {s.reason}</li>
67
+ {/each}
68
+ </ul>
69
+ {/if}
70
+ </section>
71
+ {/if}
@@ -0,0 +1,13 @@
1
+ import type { StyleCheckReport, StyleFinding } from "@pixerate/editor/tools";
2
+ interface Props {
3
+ /** A check report (`useStyleFindings(...).report`, or a `check_*_style` tool result). Renders nothing while `null`. */
4
+ report: StyleCheckReport | null | undefined;
5
+ /** Called when a finding with a `target` is clicked, e.g. to select that series in the chart. */
6
+ onSelect?: (finding: StyleFinding) => void;
7
+ /** Heading (default "House style"). */
8
+ title?: string;
9
+ class?: string;
10
+ }
11
+ declare const StyleFindings: import("svelte").Component<Props, {}, "">;
12
+ type StyleFindings = ReturnType<typeof StyleFindings>;
13
+ export default StyleFindings;
@@ -0,0 +1,5 @@
1
+ import type { ComponentProps } from "svelte";
2
+ import type StyleFindingsComponent from "./StyleFindings.svelte";
3
+ export { default as StyleFindings } from "./StyleFindings.svelte";
4
+ export * from "./style.svelte.js";
5
+ export type StyleFindingsProps = ComponentProps<typeof StyleFindingsComponent>;
@@ -0,0 +1,2 @@
1
+ export { default as StyleFindings } from "./StyleFindings.svelte";
2
+ export * from "./style.svelte.js";
@@ -0,0 +1,26 @@
1
+ import type { ResolvedStyle, StyleCheckReport, StyleResolver } from "@pixerate/editor/tools";
2
+ type MaybeGetter<T> = T | (() => T);
3
+ export interface SvelteStyle {
4
+ /** The resolved house style for the domain, or `null` before the first resolve (or without a resolver) (reactive). */
5
+ readonly style: ResolvedStyle | null;
6
+ /** Why the last resolve failed (e.g. an unknown domain), or `null` (reactive). */
7
+ readonly error: string | null;
8
+ }
9
+ export interface SvelteStyleFindings extends SvelteStyle {
10
+ /** The last check report for `state`, or `null` before the first check (or without a resolver) (reactive). */
11
+ readonly report: StyleCheckReport | null;
12
+ }
13
+ /**
14
+ * Svelte 5 rune: the house style for one domain (`chart`, `tokens`, ...), merged across the
15
+ * resolver's layers; re-resolves when a style store or the token source changes. Call it during
16
+ * component initialisation; pass getters to react to changes. Parity with React's `useStyle`.
17
+ */
18
+ export declare function useStyle(styles: MaybeGetter<StyleResolver | null | undefined>, domain: MaybeGetter<string>): SvelteStyle;
19
+ /**
20
+ * Svelte 5 rune: runs the domain's house-style checks against `subject`, the domain's state (e.g. a chart spec),
21
+ * whenever the value a getter returns changes by reference, the domain or resolver changes, or
22
+ * the resolver notifies. Results of older runs that settle late are ignored. Feed `report` to
23
+ * `<StyleFindings>`. Call it during component initialisation. Parity with React's `useStyleFindings`.
24
+ */
25
+ export declare function useStyleFindings(styles: MaybeGetter<StyleResolver | null | undefined>, domain: MaybeGetter<string>, subject: MaybeGetter<unknown>): SvelteStyleFindings;
26
+ export {};
@@ -0,0 +1,89 @@
1
+ const read = (value) => (typeof value === "function" ? value() : value);
2
+ const message = (error) => (error instanceof Error ? error.message : String(error));
3
+ /**
4
+ * Runs `run` in an effect (re-run when anything `inputs` reads changes) and whenever the
5
+ * resolver notifies, delivering only the newest result.
6
+ */
7
+ function resolverTask(inputs, run, deliver) {
8
+ $effect(() => {
9
+ const { styles, input } = inputs();
10
+ if (!styles) {
11
+ deliver({ value: null, error: null });
12
+ return;
13
+ }
14
+ let latest = 0;
15
+ let active = true;
16
+ const go = () => {
17
+ const call = ++latest;
18
+ run(styles, input).then((value) => {
19
+ if (active && call === latest)
20
+ deliver({ value, error: null });
21
+ }, (error) => {
22
+ if (active && call === latest)
23
+ deliver({ value: null, error: message(error) });
24
+ });
25
+ };
26
+ go();
27
+ const unsubscribe = styles.subscribe(go);
28
+ return () => {
29
+ active = false;
30
+ unsubscribe();
31
+ };
32
+ });
33
+ }
34
+ /**
35
+ * Svelte 5 rune: the house style for one domain (`chart`, `tokens`, ...), merged across the
36
+ * resolver's layers; re-resolves when a style store or the token source changes. Call it during
37
+ * component initialisation; pass getters to react to changes. Parity with React's `useStyle`.
38
+ */
39
+ export function useStyle(styles, domain) {
40
+ let style = $state.raw(null);
41
+ let error = $state.raw(null);
42
+ resolverTask(() => ({ styles: read(styles), input: read(domain) }), (s, d) => s.resolve(d), (result) => {
43
+ style = result.value;
44
+ error = result.error;
45
+ });
46
+ return {
47
+ get style() {
48
+ return style;
49
+ },
50
+ get error() {
51
+ return error;
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Svelte 5 rune: runs the domain's house-style checks against `subject`, the domain's state (e.g. a chart spec),
57
+ * whenever the value a getter returns changes by reference, the domain or resolver changes, or
58
+ * the resolver notifies. Results of older runs that settle late are ignored. Feed `report` to
59
+ * `<StyleFindings>`. Call it during component initialisation. Parity with React's `useStyleFindings`.
60
+ */
61
+ export function useStyleFindings(styles, domain, subject) {
62
+ let report = $state.raw(null);
63
+ let style = $state.raw(null);
64
+ let error = $state.raw(null);
65
+ resolverTask(() => ({ styles: read(styles), input: { domain: read(domain), value: read(subject) } }), (s, { domain: d, value }) => s.check(d, value), (result) => {
66
+ if (!result.value) {
67
+ report = null;
68
+ style = null;
69
+ error = result.error;
70
+ }
71
+ else {
72
+ const { style: resolved, ...rest } = result.value;
73
+ report = rest;
74
+ style = resolved;
75
+ error = null;
76
+ }
77
+ });
78
+ return {
79
+ get report() {
80
+ return report;
81
+ },
82
+ get style() {
83
+ return style;
84
+ },
85
+ get error() {
86
+ return error;
87
+ },
88
+ };
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.120.0",
3
+ "version": "0.122.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -168,7 +168,7 @@
168
168
  "@tiptap/extension-bubble-menu": "^2.27.3",
169
169
  "@tiptap/extension-placeholder": "^2.27.3",
170
170
  "@tiptap/starter-kit": "^2.27.3",
171
- "@pixerate/editor": "0.163.0"
171
+ "@pixerate/editor": "0.165.0"
172
172
  },
173
173
  "peerDependencies": {
174
174
  "@dagrejs/dagre": "^1.1.4",
@@ -1,31 +0,0 @@
1
- import type { Rect, XYPosition } from '@xyflow/svelte';
2
- import type { CanvasEdge, CanvasLayoutOptions, CanvasNode, DirectionalDisplacementOptions } from './types.js';
3
- /**
4
- * Calculates a bounding rectangle for a set of nodes if not provided by getNodesBounds.
5
- */
6
- export declare function computeNodesBoundingBox<TNode extends CanvasNode = CanvasNode>(nodes: TNode[], defaultWidth?: number, defaultHeight?: number): Rect;
7
- /**
8
- * Pure directional displacement calculation.
9
- * Shifts downstream or surrounding nodes along an axis to clear space for newly inserted child nodes.
10
- */
11
- export declare function calculateDirectionalDisplacement<TNode extends CanvasNode = CanvasNode>(params: {
12
- existingNodes: TNode[];
13
- originNode: TNode;
14
- childNodes: TNode[];
15
- getNodesBounds?: (nodes: TNode[]) => Rect;
16
- options?: DirectionalDisplacementOptions;
17
- }): Map<string, XYPosition>;
18
- /**
19
- * Pure hierarchical Dagre reflow displacement calculation.
20
- * Calculates new coordinates for surrounding nodes using Dagre hierarchical layout,
21
- * anchoring the origin node at its current position to eliminate layout jumping.
22
- */
23
- export declare function calculateReflowDisplacement<TNode extends CanvasNode = CanvasNode, TEdge extends CanvasEdge = CanvasEdge>(params: {
24
- existingNodes: TNode[];
25
- existingEdges: TEdge[];
26
- originNode: TNode;
27
- childNodes: TNode[];
28
- childEdges?: TEdge[];
29
- getNodesBounds?: (nodes: TNode[]) => Rect;
30
- options?: CanvasLayoutOptions;
31
- }): Map<string, XYPosition>;
@@ -1,170 +0,0 @@
1
- import dagre from '@dagrejs/dagre';
2
- /**
3
- * Calculates a bounding rectangle for a set of nodes if not provided by getNodesBounds.
4
- */
5
- export function computeNodesBoundingBox(nodes, defaultWidth = 200, defaultHeight = 60) {
6
- if (nodes.length === 0) {
7
- return { x: 0, y: 0, width: 0, height: 0 };
8
- }
9
- let minX = Infinity;
10
- let minY = Infinity;
11
- let maxX = -Infinity;
12
- let maxY = -Infinity;
13
- for (const node of nodes) {
14
- const width = node.measured?.width ?? node.width ?? defaultWidth;
15
- const height = node.measured?.height ?? node.height ?? defaultHeight;
16
- minX = Math.min(minX, node.position.x);
17
- minY = Math.min(minY, node.position.y);
18
- maxX = Math.max(maxX, node.position.x + width);
19
- maxY = Math.max(maxY, node.position.y + height);
20
- }
21
- return {
22
- x: minX,
23
- y: minY,
24
- width: maxX - minX,
25
- height: maxY - minY
26
- };
27
- }
28
- /**
29
- * Pure directional displacement calculation.
30
- * Shifts downstream or surrounding nodes along an axis to clear space for newly inserted child nodes.
31
- */
32
- export function calculateDirectionalDisplacement(params) {
33
- const { existingNodes, originNode, childNodes, getNodesBounds, options = {} } = params;
34
- const { direction = 'right', gap = 40, spreadSiblingLanes = false, siblingTolerance = 50, minY } = options;
35
- const displacedMap = new Map();
36
- if (childNodes.length === 0 || existingNodes.length === 0) {
37
- return displacedMap;
38
- }
39
- const childBounds = getNodesBounds
40
- ? getNodesBounds(childNodes)
41
- : computeNodesBoundingBox(childNodes);
42
- const originWidth = originNode.measured?.width ?? originNode.width ?? 200;
43
- const originHeight = originNode.measured?.height ?? originNode.height ?? 60;
44
- // The delta space required along primary axis
45
- const deltaPrimary = (direction === 'right' || direction === 'left')
46
- ? childBounds.width + gap
47
- : childBounds.height + gap;
48
- // Vertical displacement parameters: upward and downward row clearance
49
- const deltaUp = Math.max(0, originNode.position.y - childBounds.y + gap);
50
- const deltaDown = Math.max(0, childBounds.y + childBounds.height - (originNode.position.y + originHeight) + gap);
51
- // Both above and below displace equally so the row expands symmetrically
52
- const equalDelta = Math.max(deltaUp, deltaDown);
53
- for (const node of existingNodes) {
54
- if (node.id === originNode.id)
55
- continue;
56
- let newX = node.position.x;
57
- let newY = node.position.y;
58
- let moved = false;
59
- if (direction === 'right') {
60
- // Downstream nodes strictly to the right of the origin
61
- if (node.position.x >= originNode.position.x + originWidth * 0.5) {
62
- newX += deltaPrimary;
63
- moved = true;
64
- }
65
- else if (spreadSiblingLanes) {
66
- // Sibling nodes occupying the same vertical space as the exploded cluster
67
- const isVerticallyOverlapping = node.position.y + (node.height ?? 60) >= childBounds.y - gap &&
68
- node.position.y <= childBounds.y + childBounds.height + gap;
69
- if (isVerticallyOverlapping && Math.abs(node.position.x - originNode.position.x) <= siblingTolerance) {
70
- const pushDirection = node.position.y >= originNode.position.y ? 1 : -1;
71
- newY += pushDirection * (childBounds.height * 0.5 + gap);
72
- moved = true;
73
- }
74
- }
75
- }
76
- else if (direction === 'left') {
77
- if (node.position.x <= originNode.position.x) {
78
- newX -= deltaPrimary;
79
- moved = true;
80
- }
81
- }
82
- else if (direction === 'down') {
83
- if (node.position.y >= originNode.position.y + originHeight * 0.5) {
84
- newY += deltaPrimary;
85
- moved = true;
86
- }
87
- }
88
- else if (direction === 'up') {
89
- if (node.position.y <= originNode.position.y) {
90
- newY -= deltaPrimary;
91
- moved = true;
92
- }
93
- }
94
- else if (direction === 'vertical') {
95
- // Row grows vertically:
96
- // Nodes strictly above the origin node move up by equalDelta
97
- // Nodes strictly below the origin node move down by equalDelta
98
- // Both above and below displace equally; horizontal position (x) is strictly preserved
99
- if (node.position.y < originNode.position.y) {
100
- if (equalDelta > 0) {
101
- newY -= equalDelta;
102
- moved = true;
103
- }
104
- }
105
- else if (node.position.y >= originNode.position.y + originHeight * 0.5) {
106
- if (equalDelta > 0) {
107
- newY += equalDelta;
108
- moved = true;
109
- }
110
- }
111
- }
112
- if (moved) {
113
- displacedMap.set(node.id, { x: newX, y: newY });
114
- }
115
- }
116
- return displacedMap;
117
- }
118
- /**
119
- * Pure hierarchical Dagre reflow displacement calculation.
120
- * Calculates new coordinates for surrounding nodes using Dagre hierarchical layout,
121
- * anchoring the origin node at its current position to eliminate layout jumping.
122
- */
123
- export function calculateReflowDisplacement(params) {
124
- const { existingNodes, existingEdges, originNode, childNodes, childEdges = [], options = {} } = params;
125
- const { direction = 'LR', ranksep = 80, nodesep = 30, defaultNodeWidth = 200, defaultNodeHeight = 60 } = options;
126
- const displacedMap = new Map();
127
- if (existingNodes.length === 0)
128
- return displacedMap;
129
- const allNodes = [...existingNodes, ...childNodes];
130
- const allEdges = [...existingEdges, ...childEdges];
131
- const dagreGraph = new dagre.graphlib.Graph();
132
- dagreGraph.setDefaultEdgeLabel(() => ({}));
133
- dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep });
134
- const allNodeIds = new Set(allNodes.map((n) => n.id));
135
- allNodes.forEach((node) => {
136
- const nodeWidth = node.measured?.width ?? node.width ?? defaultNodeWidth;
137
- const nodeHeight = node.measured?.height ?? node.height ?? defaultNodeHeight;
138
- dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
139
- });
140
- allEdges.forEach((edge) => {
141
- if (allNodeIds.has(edge.source) && allNodeIds.has(edge.target)) {
142
- dagreGraph.setEdge(edge.source, edge.target);
143
- }
144
- });
145
- dagre.layout(dagreGraph);
146
- const dagreOrigin = dagreGraph.node(originNode.id);
147
- if (!dagreOrigin)
148
- return displacedMap;
149
- const originWidth = originNode.measured?.width ?? originNode.width ?? defaultNodeWidth;
150
- const originHeight = originNode.measured?.height ?? originNode.height ?? defaultNodeHeight;
151
- // Calculate offset so origin node remains anchored at its exact initial coordinates
152
- const anchorOffsetX = originNode.position.x - (dagreOrigin.x - originWidth / 2);
153
- const anchorOffsetY = originNode.position.y - (dagreOrigin.y - originHeight / 2);
154
- existingNodes.forEach((node) => {
155
- if (node.id === originNode.id)
156
- return;
157
- const dagreNode = dagreGraph.node(node.id);
158
- if (!dagreNode)
159
- return;
160
- const nodeWidth = node.measured?.width ?? node.width ?? defaultNodeWidth;
161
- const nodeHeight = node.measured?.height ?? node.height ?? defaultNodeHeight;
162
- const targetX = dagreNode.x - nodeWidth / 2 + anchorOffsetX;
163
- const targetY = dagreNode.y - nodeHeight / 2 + anchorOffsetY;
164
- // Only include if position actually shifted
165
- if (Math.abs(targetX - node.position.x) > 0.5 || Math.abs(targetY - node.position.y) > 0.5) {
166
- displacedMap.set(node.id, { x: targetX, y: targetY });
167
- }
168
- });
169
- return displacedMap;
170
- }