@kazant/flexgraph 1.0.2 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to this project are documented here. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## [1.2.0] — 2026-10-12
6
+
7
+ ### Added
8
+ - Undo / redo (`history`, on by default; `historyLimit` 100): moves (one step per drag, including group drags),
9
+ pins, `relayout`, `updateGraph`, expand / collapse, `importState` and direction / routing changes. Ctrl/Cmd+Z,
10
+ Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y, and ↶ ↷ buttons in the controls. `view.undo()`, `redo`, `canUndo`, `canRedo`,
11
+ `getHistory`, `clearHistory`, `batch(label, fn)` and a `history` event that carries the data now shown.
12
+ - Multi-select (`multiSelect`, on by default): Ctrl/Cmd/Shift + click adds or removes nodes and lines, Shift + drag
13
+ on the background draws a selection box, Ctrl/Cmd+A selects all and Esc clears. Dragging a selected node moves
14
+ the whole selection. `view.select([...], { add, toggle })`, `getSelectedItems`, `getSelectedNodes`,
15
+ `getSelectedEdges`, `selectAll`, `clearSelection`, a `selectionchange` event, `selection` in the `contextmenu`
16
+ event and `ids` in the drag events. `fitFlow()` without a target fits a multi-selection.
17
+ - Expandable nodes: give a node `details` (HTML) or set `renderDetails(node, el)`, and it gets a "Details" strip at
18
+ the bottom. Opening it makes room in the layout while the node keeps its place on screen. `view.expand(id)`,
19
+ `collapse`, `toggleExpand`, `isExpanded`, `onNodeExpand` and an `expand` event.
20
+ - Explore mode for huge hierarchies (`explore: true`): only nodes without a parent are shown at first; clicking a
21
+ node opens its children, clicking it again closes it. Nodes off the open path are dimmed, blurred or hidden
22
+ (`exploreUnfocused`), long child lists end in a "+N more" node (`exploreLimit`), a breadcrumb shows the path and
23
+ badges show child counts. Only the open part of the graph is laid out, so 100 000+ nodes stay fast.
24
+ `view.setExplorePath(path)`, `getExplorePath`, `exploreBack` (also Escape) and an `explore` event.
25
+ - Fan-out bundling (`bundleFanOut`, default 5): many edges leaving one side of a node share one trunk, like a
26
+ tree diagram, instead of each taking its own detour.
27
+
28
+ ### Fixed
29
+ - Text was blurry after zooming in: the graph is now only a compositor layer while it moves, so the browser redraws
30
+ text sharp at the new zoom.
31
+ - Edge labels no longer overlap each other, cover nodes or sit on top of other lines; each label is placed along
32
+ its own line where there is room.
33
+ - Lines are easier to click and right-click: the hit area stays 14 screen pixels wide when zoomed out.
34
+ - Auto-sized nodes were sometimes 1px too narrow, wrapping their label onto two lines.
35
+
36
+ ## [1.1.0] — 2026-10-09
37
+
38
+ ### Added
39
+ - `view.fitFlow(id?)`: zooms and pans so the whole flow through a node or edge (everything upstream and
40
+ downstream) fills the view, as large as possible; defaults to the selection. `view.fitNodes(ids)` does the
41
+ same for any set of nodes. Both include the lines, animate smoothly and stop when the user pans or zooms.
42
+ - Right-click support for custom context menus: `onNodeContextMenu(node, event, { previous, next })`,
43
+ `onEdgeContextMenu`, `onGroupContextMenu`, `onBackgroundContextMenu` and a `contextmenu` event with
44
+ `{ kind, node | edge | group, event }`. FlexGraph has no built-in menu; the browser menu is only suppressed for
45
+ targets the app handles.
46
+
5
47
  ## [1.0.2] — 2026-10-09
6
48
 
7
49
  ### Fixed
package/README.md CHANGED
@@ -12,7 +12,13 @@ A flexible relationship graph for the browser, with full control over layout and
12
12
  - **Track assignment** – parallel segments are spread onto separate tracks, ordered so they don't cross.
13
13
  - **Line hops**, rounded corners, arrowheads and ERD markers (crow's foot etc.), edge labels.
14
14
  - **Groups** (compound layout – boxes never overlap), **constraints** (pinned, fixed rank, same rank, left-of).
15
- - **Interaction** – drag (re-routes only affected edges), pin, zoom/pan/pinch, fit, hover highlighting (whole upstream/downstream chain, or direct neighbors with `hoverHighlight: "neighbors"`; a clicked node stays highlighted), selection, save/load state, animated transitions, incremental updates.
15
+ - **Fan-out bundling** – many edges leaving one side of a node share a trunk, like a tree diagram.
16
+ - **Edge labels** placed so they don't overlap each other, nodes or other lines.
17
+ - **Expandable nodes** – a "Details" strip opens your own content; the layout makes room.
18
+ - **Explore mode** for huge hierarchies – start nodes first, click to open children level by level; only what is open is laid out.
19
+ - **Multi-select** – Ctrl/Cmd/Shift + click, Shift + drag selection box, Ctrl+A; drag one selected node to move them all.
20
+ - **Undo / redo** – Ctrl+Z / Ctrl+Shift+Z for moves, pins, relayout, data updates and expand / collapse.
21
+ - **Interaction** – drag (re-routes only affected edges), pin, zoom/pan/pinch (text stays sharp at any zoom), fit, hover highlighting (whole upstream/downstream chain, or direct neighbors with `hoverHighlight: "neighbors"`; a clicked node stays highlighted), selection, save/load state, animated transitions, incremental updates.
16
22
  - Layout engine is DOM-free (Node, SSR, Web Worker).
17
23
 
18
24
  ## Install
@@ -49,6 +55,7 @@ const view = createGraph(document.getElementById("graph"), {
49
55
 
50
56
  view.relayout(); // full auto layout (respects pins)
51
57
  view.updateGraph(newData); // incremental: existing nodes stay put
58
+ view.fitFlow("b"); // zoom so the whole flow through "b" fills the view; no argument = the selection
52
59
  view.getConnections("b"); // { previous: [nodeA], next: [], incoming, outgoing }; { chain: true } for the whole chain
53
60
  view.exportState(); // positions, pins, zoom and the exact edge lines as JSON
54
61
  view.importState(saved); // recreates the saved picture, lines included
@@ -60,7 +67,7 @@ The container needs a size (e.g. `height: 600px`). Nodes without `width`/`height
60
67
 
61
68
  | Concept | Shape |
62
69
  |---|---|
63
- | Node | `{ id, label?, width?, height?, group?, ports?: [{ id, side, offset? }], constraints?: { pinned, x, y, rank }, className?, type?, html? }` |
70
+ | Node | `{ id, label?, width?, height?, group?, ports?: [{ id, side, offset? }], constraints?: { pinned, x, y, rank }, className?, type?, html?, details?, expandable?, expanded? }` |
64
71
  | Edge | `{ id?, source, target, sourcePort?, targetPort?, type?, label?, markerStart?, markerEnd?, minLen?, weight? }` |
65
72
  | Group | `{ id, label? }` |
66
73
  | Constraints | `{ type: "leftOf", a, b }`, `{ type: "sameRank", nodes: [...] }`, `{ type: "rank", node, rank }` |
@@ -69,7 +76,86 @@ Edge `type` sets the CSS class `fg-edge--<type>` and default markers (`has-many`
69
76
 
70
77
  ### Events
71
78
 
72
- `view.on(name, cb)` with `layout`, `select`, `hover`, `nodeclick`, `edgeclick`, `groupclick`, `dragstart`, `drag`, `dragend`, `pin`, `change`, `viewport`, `animationend`.
79
+ `view.on(name, cb)` with `layout`, `history`, `select`, `selectionchange`, `hover`, `nodeclick`, `edgeclick`, `groupclick`, `dragstart`, `drag`, `dragend`, `pin`, `change`, `viewport`, `animationend`, `contextmenu`, `expand`, `explore`.
80
+
81
+ ### Undo / redo
82
+
83
+ Moves (a whole drag is one step), pins, `relayout()`, `updateGraph()`, expand / collapse, `importState()` and direction or routing changes can be undone with Ctrl/Cmd+Z and redone with Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y, or with the ↶ ↷ buttons in the controls. Selection, zoom and explore navigation are not undo steps.
84
+
85
+ ```js
86
+ view.undo(); view.redo(); // Promise<boolean>
87
+ view.canUndo(); view.getHistory(); // { undo: ["move", "update"], redo: [] }
88
+ view.batch("tidy", () => { view.unpinAll(); return view.relayout(); }); // one step
89
+
90
+ // undo can bring back earlier data (a removed node, say): keep your copy in sync
91
+ view.on("history", ({ data, canUndo, canRedo }) => { myData = data; });
92
+ ```
93
+
94
+ The data is kept by reference, so pass `updateGraph` a new object instead of editing the old one in place. Turn it off with `history: false`; `historyLimit` (default 100) caps the steps kept.
95
+
96
+ ### Selecting several items
97
+
98
+ Ctrl/Cmd/Shift + click adds a node or line to the selection (or removes it), Shift + drag on the background draws a selection box, Ctrl/Cmd+A selects every node and Esc clears. Dragging a selected node moves the whole selection. Turn it off with `multiSelect: false`.
99
+
100
+ ```js
101
+ view.select(["a", "b", { kind: "edge", id: "e1" }]); // also { add: true } or { toggle: true }
102
+ view.getSelectedNodes(); // the original node objects
103
+ view.on("selectionchange", ({ nodes, edges }) => { /* … */ });
104
+ view.on("contextmenu", ({ node, selection }) => { /* e.g. "Delete 3 selected" */ });
105
+ ```
106
+
107
+ `select` / `getSelection()` keep working as before and report the primary item (the one selected last).
108
+
109
+ Lines are easy to hit: the click area stays 14 screen pixels wide at any zoom. `onEdgeClick(edge, event)` and `onEdgeContextMenu(edge, event)` work like their node versions.
110
+
111
+ ### Expandable nodes
112
+
113
+ Give a node `details` (trusted HTML), or set `renderDetails` to give every node a "Details" strip at the bottom. Clicking it opens the node; the layout makes room and the node keeps its place on screen. Details are only built while the node is open.
114
+
115
+ ```js
116
+ createGraph(el, data, {
117
+ renderDetails: (node) => `<dl><dt>Owner</dt><dd>${node.data.owner}</dd></dl>`, // or { details: "…" } per node
118
+ onNodeExpand: (node, expanded) => { /* … */ }
119
+ });
120
+ view.expand("api"); view.collapse("api"); view.toggleExpand("api"); view.isExpanded("api");
121
+ ```
122
+
123
+ Opt a node out with `expandable: false`, or start it open with `expanded: true`. The toggle text is `expandText` / `collapseText`.
124
+
125
+ ### Explore mode (huge hierarchies)
126
+
127
+ For trees too big to show at once (say 10 start nodes, each with 1 000 children, each with 1 000 more), `explore: true` shows only the nodes without a parent. Clicking a node opens its children; clicking it again closes it. Only the open part of the graph is laid out and rendered.
128
+
129
+ ```js
130
+ createGraph(el, { nodes, edges }, {
131
+ explore: true,
132
+ exploreUnfocused: "dim", // nodes off the open path: "dim" | "blur" | "hide"
133
+ exploreLimit: 25 // children per node before a "+N more" node (click it for more)
134
+ });
135
+ view.setExplorePath(["region1", "site7"]); // open a path from code; [] = start nodes only
136
+ view.exploreBack(); // close the last node (also Escape)
137
+ view.on("explore", ({ path }) => { /* … */ });
138
+ ```
139
+
140
+ A breadcrumb in the top-left corner shows the open path (`exploreBreadcrumb: false` hides it), and each node shows a badge with its number of children.
141
+
142
+ ### Right-click (context menus)
143
+
144
+ FlexGraph has no built-in menu; it tells you what was right-clicked so you can open your own:
145
+
146
+ ```js
147
+ createGraph(el, data, {
148
+ onNodeContextMenu: (node, event, { previous, next }) => openMyMenu(event.clientX, event.clientY, node),
149
+ onEdgeContextMenu: (edge, event) => { /* … */ },
150
+ onGroupContextMenu: (group, event) => { /* … */ },
151
+ onBackgroundContextMenu: (event) => { /* … */ }
152
+ });
153
+
154
+ // or one listener for everything: kind is "node" | "edge" | "group" | "background"
155
+ view.on("contextmenu", ({ kind, node, edge, group, event }) => { /* … */ });
156
+ ```
157
+
158
+ The browser's own menu is only suppressed for targets you handle. Text fields and links inside nodes keep the normal browser menu.
73
159
 
74
160
  ### Theming
75
161
 
@@ -102,7 +188,7 @@ Written from scratch in plain JavaScript (ES modules, ES2020); not a fork or wra
102
188
  - **Layout:** Sugiyama-style layered layout: DFS cycle removal, longest-path ranking with compaction, barycenter/median crossing minimization (as in Graphviz *dot*) with transposition and sifting, isotonic-regression coordinate assignment.
103
189
  - **Routing:** A* on a sparse orthogonal grid with bend/crossing penalties, track nudging, line hops.
104
190
  - **Browser features:** SVG, CSS custom properties, Pointer Events, `requestAnimationFrame`, optional Web Worker. No network requests, no `eval`.
105
- - **Size:** about 67 kB minified / 25 kB gzipped (ESM), worker 38 kB.
191
+ - **Size:** about 91 kB minified / 33 kB gzipped (ESM), worker 40 kB.
106
192
  - **Tooling:** Node.js 20+, tests with `node --test`.
107
193
 
108
194
  ## Development
package/css/graph.css CHANGED
@@ -58,7 +58,9 @@
58
58
  cursor: grab;
59
59
  }
60
60
  .fg-viewport.fg-panning { cursor: grabbing; }
61
- .fg-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
61
+ .fg-world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
62
+ /* a compositor layer only while panning/zooming: once the view settles text is redrawn sharp at the new zoom */
63
+ .fg-world.fg-moving { will-change: transform; }
62
64
 
63
65
  /* layers */
64
66
  .fg-groups, .fg-labels, .fg-nodes { position: absolute; left: 0; top: 0; }
@@ -84,6 +86,8 @@
84
86
  }
85
87
  .fg-node__label { text-align: center; overflow: hidden; text-overflow: ellipsis; }
86
88
  .fg-node--measuring { width: max-content !important; height: auto !important; max-width: 320px; visibility: hidden; }
89
+ .fg-node--measuring-h { height: auto !important; visibility: hidden; }
90
+ .fg-node--expanded.fg-node--measuring { max-width: 420px; }
87
91
  .fg-node:hover { border-color: var(--fg-accent); }
88
92
  .fg-node.fg-selected { border-color: var(--fg-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--fg-accent) 25%, transparent), var(--fg-node-shadow); }
89
93
  .fg-node--dragging { cursor: grabbing; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); z-index: 2; }
@@ -143,7 +147,8 @@
143
147
  stroke-linecap: round;
144
148
  transition: stroke 0.15s, opacity 0.15s;
145
149
  }
146
- .fg-edge__hit { fill: none; stroke: transparent; stroke-width: 12px; pointer-events: stroke; cursor: pointer; }
150
+ /* the hit area stays 14 screen pixels wide at any zoom, so lines are easy to click when zoomed out */
151
+ .fg-edge__hit { fill: none; stroke: transparent; stroke-width: max(12px, calc(14px / var(--fg-zoom, 1))); pointer-events: stroke; cursor: pointer; }
147
152
  .fg-edge:hover .fg-edge__line, .fg-edge.fg-selected .fg-edge__line { stroke: var(--fg-edge-active); stroke-width: 2.25px; }
148
153
  .fg-edge--dependency .fg-edge__line, .fg-edge--association .fg-edge__line { stroke-dasharray: 6 4; }
149
154
 
@@ -167,6 +172,105 @@
167
172
  cursor: pointer;
168
173
  }
169
174
 
175
+ /* expandable nodes: a full-width "Details" strip at the bottom opens the user's details */
176
+ .fg-node--expandable { flex-direction: column; align-items: stretch; padding: 0; }
177
+ .fg-node__body { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; min-height: 0; padding: 8px 12px; }
178
+ .fg-node__details {
179
+ padding: 8px 12px;
180
+ border-top: 1px solid var(--fg-node-border);
181
+ font-size: 12px;
182
+ text-align: left;
183
+ user-select: text;
184
+ -webkit-user-select: text;
185
+ cursor: auto;
186
+ }
187
+ .fg-node__toggle {
188
+ flex: none;
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ gap: 4px;
193
+ padding: 3px 12px;
194
+ border: 0;
195
+ border-top: 1px solid var(--fg-node-border);
196
+ background: color-mix(in srgb, var(--fg-node-border) 18%, var(--fg-node-bg));
197
+ color: var(--fg-group-text);
198
+ font: 600 11px/16px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
199
+ letter-spacing: 0.02em;
200
+ border-radius: 0 0 calc(var(--fg-node-radius) - 1px) calc(var(--fg-node-radius) - 1px);
201
+ cursor: pointer;
202
+ }
203
+ .fg-node__toggle:hover, .fg-node__toggle:focus-visible { color: var(--fg-accent); background: color-mix(in srgb, var(--fg-accent) 10%, var(--fg-node-bg)); outline: none; }
204
+ .fg-node__toggle svg { transition: transform 0.15s; }
205
+ .fg-node__toggle[aria-expanded="true"] svg { transform: rotate(180deg); }
206
+
207
+ /* explore mode */
208
+ .fg-node__badge {
209
+ position: absolute;
210
+ right: -9px;
211
+ top: 50%;
212
+ min-width: 18px;
213
+ height: 18px;
214
+ margin-top: -9px;
215
+ padding: 0 5px;
216
+ box-sizing: border-box;
217
+ border-radius: 9px;
218
+ border: 1px solid var(--fg-node-border);
219
+ background: var(--fg-node-bg);
220
+ color: var(--fg-group-text);
221
+ font: 600 10px/16px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
222
+ text-align: center;
223
+ pointer-events: none;
224
+ }
225
+ /* the badge sits on the side the children are on */
226
+ .fg-explore[data-fg-direction="TB"] .fg-node__badge { top: auto; bottom: -9px; margin-top: 0; }
227
+ .fg-explore[data-fg-direction="BT"] .fg-node__badge { top: -9px; margin-top: 0; }
228
+ .fg-explore[data-fg-direction="RL"] .fg-node__badge { right: auto; left: -9px; }
229
+ .fg-node__badge--open { background: var(--fg-accent); border-color: var(--fg-accent); color: #fff; }
230
+ .fg-node--more { white-space: nowrap; border-style: dashed; color: var(--fg-group-text); font-weight: 600; box-shadow: none; }
231
+ .fg-edge--more .fg-edge__line { stroke-dasharray: 3 4; }
232
+ .fg-explore .fg-node { animation: fg-appear 0.25s ease-out; }
233
+ @keyframes fg-appear { from { opacity: 0; } }
234
+ .fg-node.fg-unfocused, .fg-edge-label.fg-unfocused { opacity: var(--fg-dim-opacity); }
235
+ .fg-edge.fg-unfocused .fg-edge__line { opacity: var(--fg-dim-opacity); }
236
+ .fg-explore--blur .fg-node.fg-unfocused, .fg-explore--blur .fg-edge-label.fg-unfocused { filter: blur(1.5px); opacity: 0.55; }
237
+ .fg-explore--blur .fg-node.fg-unfocused:hover { filter: none; opacity: 1; }
238
+ .fg-breadcrumb {
239
+ position: absolute;
240
+ left: 12px;
241
+ top: 12px;
242
+ right: 60px;
243
+ z-index: 20;
244
+ display: flex;
245
+ flex-wrap: wrap;
246
+ align-items: center;
247
+ gap: 2px;
248
+ width: max-content;
249
+ max-width: calc(100% - 72px);
250
+ padding: 3px;
251
+ border: 1px solid var(--fg-node-border);
252
+ border-radius: 8px;
253
+ background: var(--fg-node-bg);
254
+ box-shadow: var(--fg-node-shadow);
255
+ font-size: 12px;
256
+ }
257
+ .fg-breadcrumb button {
258
+ max-width: 180px;
259
+ padding: 3px 8px;
260
+ border: 0;
261
+ border-radius: 5px;
262
+ background: none;
263
+ color: var(--fg-group-text);
264
+ font: inherit;
265
+ white-space: nowrap;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ cursor: pointer;
269
+ }
270
+ .fg-breadcrumb button:hover { background: color-mix(in srgb, var(--fg-accent) 10%, transparent); color: var(--fg-accent); }
271
+ .fg-breadcrumb button[aria-current] { color: var(--fg-node-text); font-weight: 600; }
272
+ .fg-breadcrumb__sep { color: var(--fg-group-text); opacity: 0.6; }
273
+
170
274
  /* hover highlighting */
171
275
  .fg-hovering .fg-node:not(.fg-hl),
172
276
  .fg-hovering .fg-edge-label:not(.fg-hl) { opacity: var(--fg-dim-opacity); }
@@ -174,6 +278,16 @@
174
278
  .fg-hovering .fg-edge.fg-hl .fg-edge__line { stroke: var(--fg-edge-active); stroke-width: 2.25px; }
175
279
  .fg-dragging .fg-world { cursor: grabbing; }
176
280
 
281
+ /* multi-select: Shift + drag on the background draws this box */
282
+ .fg-selection-box {
283
+ position: absolute;
284
+ z-index: 15;
285
+ border: 1px solid var(--fg-accent);
286
+ border-radius: 2px;
287
+ background: color-mix(in srgb, var(--fg-accent) 10%, transparent);
288
+ pointer-events: none;
289
+ }
290
+
177
291
  /* controls */
178
292
  .fg-controls {
179
293
  position: absolute;
@@ -195,8 +309,11 @@
195
309
  cursor: pointer;
196
310
  box-shadow: var(--fg-node-shadow);
197
311
  }
198
- .fg-controls button:hover { border-color: var(--fg-accent); color: var(--fg-accent); }
312
+ .fg-controls button:hover:not(:disabled) { border-color: var(--fg-accent); color: var(--fg-accent); }
313
+ .fg-controls button:disabled { opacity: 0.4; cursor: default; }
314
+ .fg-controls__undo { margin-top: 6px; }
199
315
 
200
316
  @media (prefers-reduced-motion: reduce) {
201
- .fg-node, .fg-edge__line { transition: none; }
317
+ .fg-node, .fg-edge__line, .fg-node__toggle svg { transition: none; }
318
+ .fg-explore .fg-node { animation: none; }
202
319
  }