@loadmap/layout 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Rojas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
- # Temporary Holding Version
1
+ # @loadmap/layout
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Views for [loadmap](https://jaim3r0jas.github.io/loadmap/) models (`defineView`, `node`, `cluster`, `pool`, `edge`) and
4
+ their layout with ELK: boxes, orthogonal edges, labels sized for their widest text so the load never moves the
5
+ diagram and no label covers another. `layout()` takes an ELK engine; by default it imports ELK on first use.
6
+ `@loadmap/react` re-exports the view API and runs ELK in a Web Worker. MIT.
@@ -0,0 +1,200 @@
1
+ import { Dyn, Evaluation, LooseValues, Model } from "@loadmap/core";
2
+ /** Width in px of `text` at `size` px. The browser passes a canvas measurer; Node uses the estimate below. */
3
+ type TextMeasure = (text: string, size: number, weight?: number) => number;
4
+ /** A sans-serif estimate, slightly generous so labels laid out with it never end up narrower than they render. */
5
+ export declare const estimateText: TextMeasure;
6
+ /** Text, or text computed from the model's context (e.g. a rate). */
7
+ type Label = string | ((ctx: LooseValues) => string);
8
+ /** A theme token (accent, alt, ok, warn, drop, muted) or any CSS colour. */
9
+ type FlowColor = 'accent' | 'alt' | 'ok' | 'warn' | 'drop' | 'muted' | (string & {});
10
+ interface NodeDef<S extends string = string> {
11
+ kind: 'node';
12
+ label: Label;
13
+ sub?: Label;
14
+ /** The stage this node shows: dots stop here, and it takes its component's state colour. */
15
+ stage?: S;
16
+ /** A component to colour by, for a node that is not a stage. */
17
+ component?: string;
18
+ shape: 'box' | 'cylinder';
19
+ }
20
+ /**
21
+ * A cluster of members (brokers) holding the partitions of one or several queue stages (topics): partition i of each
22
+ * on member i mod n.
23
+ */
24
+ interface ClusterDef<S extends string = string> {
25
+ kind: 'cluster';
26
+ label: Label;
27
+ sub?: Label;
28
+ stage: S | readonly S[];
29
+ members: Dyn<number>;
30
+ memberLabel?: (i: number) => string;
31
+ }
32
+ /** A workers stage drawn as pods holding a grid of workers. */
33
+ interface PoolDef<S extends string = string> {
34
+ kind: 'pool';
35
+ label: Label;
36
+ sub?: Label;
37
+ stage: S;
38
+ podLabel?: (i: number) => string;
39
+ }
40
+ type AnyNodeDef<S extends string = string> = NodeDef<S> | ClusterDef<S> | PoolDef<S>;
41
+ interface EdgeDef<N extends string = string> {
42
+ from: N;
43
+ to: N;
44
+ label?: Label;
45
+ /** The edge is left out when this returns false. */
46
+ when?: (ctx: LooseValues) => unknown;
47
+ }
48
+ interface FlowView<N extends string = string> {
49
+ /** The node a flow's dots start from. */
50
+ from: N;
51
+ color: FlowColor;
52
+ label?: string;
53
+ }
54
+ interface ViewDef<F extends string, S extends string, N extends string> {
55
+ nodes: { [K in N]: AnyNodeDef<NoInfer<S>>; };
56
+ edges: readonly EdgeDef<NoInfer<N>>[];
57
+ flows: { [K in F]: FlowView<NoInfer<N>>; };
58
+ }
59
+ interface View<F extends string = string, S extends string = string, N extends string = string> {
60
+ model: Model<any, F, any, S>;
61
+ def: ViewDef<F, S, N>;
62
+ }
63
+ export declare function defineView<P, F extends string, C extends string, S extends string, const N extends string>(model: Model<P, F, C, S>, def: ViewDef<F, S, N>): View<F, S, N>;
64
+ export declare function node<const S extends string = never>(o: {
65
+ label: Label;
66
+ sub?: Label;
67
+ stage?: S;
68
+ component?: string;
69
+ shape?: NodeDef['shape'];
70
+ }): NodeDef<S>;
71
+ export declare function cluster<const S extends string>(o: Omit<ClusterDef<S>, 'kind'>): ClusterDef<S>;
72
+ export declare function pool<const S extends string>(o: Omit<PoolDef<S>, 'kind'>): PoolDef<S>;
73
+ export declare function edge<const A extends string, const B extends string>(from: A, to: B, o?: {
74
+ label?: Label;
75
+ when?: EdgeDef['when'];
76
+ }): EdgeDef<A | B>;
77
+ type Direction = 'RIGHT' | 'DOWN';
78
+ interface Box {
79
+ x: number;
80
+ y: number;
81
+ w: number;
82
+ h: number;
83
+ }
84
+ /** Where a piece of text sits; the text itself comes from `labelsAt`, since it changes with the values. */
85
+ interface TextSpot {
86
+ x: number;
87
+ y: number;
88
+ size: number;
89
+ weight: number;
90
+ }
91
+ interface Cell extends Box {
92
+ stage: string;
93
+ flow: string;
94
+ /** The partition's index within its flow. */
95
+ index: number;
96
+ }
97
+ interface SceneNode extends Box {
98
+ id: string;
99
+ kind: AnyNodeDef['kind'];
100
+ shape: 'box' | 'cylinder';
101
+ /** The stages this node shows: one, or several queues for a cluster that holds several topics. */
102
+ stages: string[];
103
+ component?: string;
104
+ title: TextSpot;
105
+ sub?: TextSpot;
106
+ /** Cluster members (brokers) and pods, with their own small labels. */
107
+ members: (Box & {
108
+ label: string;
109
+ spot: TextSpot;
110
+ })[];
111
+ /** Per member, the name of each flow above its partitions. */
112
+ groups: {
113
+ flow: string;
114
+ text: string;
115
+ spot: TextSpot;
116
+ }[];
117
+ cells: Cell[];
118
+ /** Workers, indexed pod × workers per pod + worker, the index the sim's fill uses. */
119
+ slots: (Box & {
120
+ index: number;
121
+ })[];
122
+ }
123
+ interface SceneEdge {
124
+ id: string;
125
+ from: string;
126
+ to: string;
127
+ points: [number, number][];
128
+ /** The box reserved for the label, wide enough for its widest text across the load range. */
129
+ label?: Box;
130
+ }
131
+ interface Scene {
132
+ /** Changes only when something that moves the layout changes; the load alone never does. */
133
+ key: string;
134
+ direction: Direction;
135
+ width: number;
136
+ height: number;
137
+ nodes: SceneNode[];
138
+ edges: SceneEdge[];
139
+ /** The node that shows each stage, and the node each flow starts from. */
140
+ stageNode: Readonly<Record<string, string>>;
141
+ flowFrom: Readonly<Record<string, string>>;
142
+ }
143
+ interface LayoutOptions {
144
+ direction?: Direction;
145
+ measure?: TextMeasure;
146
+ /** Where ELK runs. Default: on this thread, loaded on first use. */
147
+ elk?: ElkEngine;
148
+ }
149
+ /**
150
+ * Lays out an ELK graph: ELK on this thread, or in a worker. Every engine gives the same result for a graph. The graph
151
+ * is typed loosely here so that our typings never pull in elkjs's (which fail a strict check without skipLibCheck).
152
+ */
153
+ interface ElkEngine {
154
+ layout(graph: ElkGraph): Promise<ElkGraph>;
155
+ }
156
+ /** An ELK JSON graph (elkjs's ElkNode). */
157
+ type ElkGraph = {
158
+ id: string;
159
+ } & Record<string, unknown>;
160
+ /** ELK on this thread, imported on first use: it is about 1.6 MB, so it stays out of a page's first load. */
161
+ export declare function localElk(): Promise<ElkEngine>;
162
+ /** The current text of every label. Cheap: call it on every change of values; the layout stays put. */
163
+ export declare function labelsAt(view: View, ev: Evaluation): {
164
+ nodes: Record<string, {
165
+ title: string;
166
+ sub?: string;
167
+ }>;
168
+ edges: Record<string, string>;
169
+ };
170
+ /**
171
+ * Lays the view out for the current values: grids inside clusters and pools are packed here; ELK places the boxes,
172
+ * routes the edges and places the edge labels so that none overlaps another or a node. Results are cached by the
173
+ * structure, so a call after a load change returns the same scene.
174
+ */
175
+ export declare function layout(view: View, ev: Evaluation, o?: LayoutOptions): Promise<Scene>;
176
+ /** The structure key for the current values: equal keys give equal scenes. */
177
+ export declare function structureKey(view: View, ev: Evaluation, o?: LayoutOptions): string;
178
+ type Point = [number, number];
179
+ interface Collision {
180
+ a: string;
181
+ b: string;
182
+ }
183
+ /** Every edge label that overlaps another edge label or a node. An empty list is the layout's promise. */
184
+ export declare function collisions(scene: Scene, slack?: number): Collision[];
185
+ /** Where a dot sits at a place: a partition cell, a worker, or the middle of the stage's node. */
186
+ export declare function anchor(scene: Scene, place: {
187
+ stage: string | null;
188
+ cell?: number;
189
+ }, flow: string): Point;
190
+ /** The path a dot takes between two places: along the edge between their nodes when there is one. */
191
+ export declare function legPath(scene: Scene, from: {
192
+ stage: string | null;
193
+ cell?: number;
194
+ }, to: {
195
+ stage: string | null;
196
+ cell?: number;
197
+ }, flow: string): Point[];
198
+ /** The point a share `t` (0–1) along a polyline, by length. */
199
+ export declare function pointAt(points: readonly Point[], t: number): Point;
200
+ export type { AnyNodeDef, Box, Cell, ClusterDef, Collision, Direction, EdgeDef, ElkEngine, ElkGraph, FlowColor, FlowView, Label, LayoutOptions, NodeDef, Point, PoolDef, Scene, SceneEdge, SceneNode, TextMeasure, TextSpot, View, ViewDef };
package/dist/index.js ADDED
@@ -0,0 +1,574 @@
1
+ import { dyn } from "@loadmap/core";
2
+ // src/geometry.ts
3
+ const overlap = (p, q, slack) => p.x < q.x + q.w - slack && q.x < p.x + p.w - slack && p.y < q.y + q.h - slack && q.y < p.y + p.h - slack;
4
+ /** Every edge label that overlaps another edge label or a node. An empty list is the layout's promise. */
5
+ function collisions(scene, slack = .5) {
6
+ const labels = scene.edges.filter((e) => e.label).map((e) => ({
7
+ id: `label ${e.id} (${e.from}→${e.to})`,
8
+ box: e.label
9
+ }));
10
+ const out = [];
11
+ labels.forEach((l, i) => {
12
+ for (const m of labels.slice(i + 1)) if (overlap(l.box, m.box, slack)) out.push({
13
+ a: l.id,
14
+ b: m.id
15
+ });
16
+ for (const n of scene.nodes) if (overlap(l.box, n, slack)) out.push({
17
+ a: l.id,
18
+ b: `node ${n.id}`
19
+ });
20
+ });
21
+ scene.nodes.forEach((n, i) => {
22
+ for (const m of scene.nodes.slice(i + 1)) if (overlap(n, m, slack)) out.push({
23
+ a: `node ${n.id}`,
24
+ b: `node ${m.id}`
25
+ });
26
+ });
27
+ return out;
28
+ }
29
+ const centre = (b) => [b.x + b.w / 2, b.y + b.h / 2];
30
+ /** Where a dot sits at a place: a partition cell, a worker, or the middle of the stage's node. */
31
+ function anchor(scene, place, flow) {
32
+ const n = nodeOf(scene, place.stage, flow);
33
+ if (!n) return [0, 0];
34
+ if (place.cell != null) {
35
+ const cell = n.cells.find((c) => c.stage === place.stage && c.flow === flow && c.index === place.cell) ?? n.slots[place.cell];
36
+ if (cell) return centre(cell);
37
+ }
38
+ return centre(n);
39
+ }
40
+ /** The path a dot takes between two places: along the edge between their nodes when there is one. */
41
+ function legPath(scene, from, to, flow) {
42
+ const a = anchor(scene, from, flow);
43
+ const b = anchor(scene, to, flow);
44
+ const na = nodeOf(scene, from.stage, flow);
45
+ const nb = nodeOf(scene, to.stage, flow);
46
+ const e = na && nb ? scene.edges.find((x) => x.from === na.id && x.to === nb.id) : void 0;
47
+ return e?.points.length ? [
48
+ a,
49
+ ...e.points,
50
+ b
51
+ ] : [a, b];
52
+ }
53
+ /** The point a share `t` (0–1) along a polyline, by length. */
54
+ function pointAt(points, t) {
55
+ if (points.length < 2) return points[0] ?? [0, 0];
56
+ const lens = points.slice(1).map((p, i) => Math.hypot(p[0] - points[i][0], p[1] - points[i][1]));
57
+ let left = Math.min(1, Math.max(0, t)) * lens.reduce((s, l) => s + l, 0);
58
+ for (let i = 0; i < lens.length; i++) {
59
+ const l = lens[i];
60
+ if (left <= l || i === lens.length - 1) {
61
+ const k = l > 0 ? Math.min(1, left / l) : 1;
62
+ const p = points[i];
63
+ const q = points[i + 1];
64
+ return [p[0] + (q[0] - p[0]) * k, p[1] + (q[1] - p[1]) * k];
65
+ }
66
+ left -= l;
67
+ }
68
+ return points.at(-1);
69
+ }
70
+ function nodeOf(scene, stage, flow) {
71
+ const id = stage == null ? scene.flowFrom[flow] : scene.stageNode[stage];
72
+ return scene.nodes.find((n) => n.id === id);
73
+ }
74
+ // src/text.ts
75
+ const NARROW = /* @__PURE__ */ new Set([..."ijlrtf.,:;|!'\"()[] "]);
76
+ const WIDE = /* @__PURE__ */ new Set([..."mwMW@%"]);
77
+ /** A sans-serif estimate, slightly generous so labels laid out with it never end up narrower than they render. */
78
+ const estimateText = (text, size, weight = 400) => {
79
+ let em = 0;
80
+ for (const ch of text) if (NARROW.has(ch)) em += .32;
81
+ else if (WIDE.has(ch)) em += .86;
82
+ else if (ch >= "A" && ch <= "Z") em += .68;
83
+ else em += .57;
84
+ return Math.ceil(em * size * (weight >= 600 ? 1.06 : 1));
85
+ };
86
+ // src/layout.ts
87
+ let local = null;
88
+ /** ELK on this thread, imported on first use: it is about 1.6 MB, so it stays out of a page's first load. */
89
+ function localElk() {
90
+ local ??= import("elkjs/lib/elk.bundled.js").then((m) => new m.default(), (err) => {
91
+ local = null;
92
+ throw err;
93
+ });
94
+ return local;
95
+ }
96
+ const T_TITLE = {
97
+ size: 13,
98
+ weight: 600
99
+ };
100
+ const T_SUB = {
101
+ size: 11,
102
+ weight: 400
103
+ };
104
+ const T_SMALL = {
105
+ size: 9,
106
+ weight: 400
107
+ };
108
+ const PAD = 8;
109
+ const GAP = 6;
110
+ const CELL = 9;
111
+ const CELL_COLS = 6;
112
+ const SLOT = 10;
113
+ const SLOT_GAP = 3;
114
+ const LABEL_H = 16;
115
+ const scenes = /* @__PURE__ */ new WeakMap();
116
+ const sampleCache = /* @__PURE__ */ new WeakMap();
117
+ /** The current text of every label. Cheap: call it on every change of values; the layout stays put. */
118
+ function labelsAt(view, ev) {
119
+ const ctx = ev.ctx;
120
+ const nodes = {};
121
+ for (const [id, n] of Object.entries(view.def.nodes)) nodes[id] = {
122
+ title: text(n.label, ctx),
123
+ sub: n.sub == null ? void 0 : text(n.sub, ctx)
124
+ };
125
+ const edges = {};
126
+ visibleEdges(view, ctx).forEach(({ e, id }) => {
127
+ if (e.label != null) edges[id] = text(e.label, ctx);
128
+ });
129
+ return {
130
+ nodes,
131
+ edges
132
+ };
133
+ }
134
+ /**
135
+ * Lays the view out for the current values: grids inside clusters and pools are packed here; ELK places the boxes,
136
+ * routes the edges and places the edge labels so that none overlaps another or a node. Results are cached by the
137
+ * structure, so a call after a load change returns the same scene.
138
+ */
139
+ function layout(view, ev, o = {}) {
140
+ const prep = prepare(view, ev, o.direction ?? "RIGHT", o.measure ?? estimateText);
141
+ const cache = scenes.get(view) ?? /* @__PURE__ */ new Map();
142
+ scenes.set(view, cache);
143
+ let hit = cache.get(prep.key);
144
+ if (!hit) {
145
+ hit = run(prep, o.elk).catch((err) => {
146
+ cache.delete(prep.key);
147
+ throw err;
148
+ });
149
+ if (cache.size > 32) cache.delete(cache.keys().next().value);
150
+ cache.set(prep.key, hit);
151
+ }
152
+ return hit;
153
+ }
154
+ /** The structure key for the current values: equal keys give equal scenes. */
155
+ function structureKey(view, ev, o = {}) {
156
+ return prepare(view, ev, o.direction ?? "RIGHT", o.measure ?? estimateText).key;
157
+ }
158
+ function prepare(view, ev, direction, measure) {
159
+ const widest = labelWidths(view, ev, measure);
160
+ const stageNode = {};
161
+ const nodes = Object.entries(view.def.nodes).map(([id, def]) => {
162
+ const own = stagesOf(def);
163
+ for (const st of own) stageNode[st] = id;
164
+ const titleW = widest(def.label, T_TITLE.size, T_TITLE.weight);
165
+ const subW = widest(def.sub, T_SUB.size, T_SUB.weight);
166
+ const resolved = own.map((st) => ev.stages[st]);
167
+ const stage = resolved[0];
168
+ const queues = resolved.filter((r) => r.kind === "queue");
169
+ return {
170
+ id,
171
+ def,
172
+ inner: def.kind === "cluster" && queues.length ? clusterInner(def, queues, ev.ctx, direction, titleW, subW, measure, view) : def.kind === "pool" && stage?.kind === "workers" ? poolInner(def, stage, direction, titleW, subW) : boxInner(titleW, subW, def.sub != null, def.kind === "node" && def.shape === "cylinder" ? 8 : 0)
173
+ };
174
+ });
175
+ const edges = visibleEdges(view, ev.ctx).map(({ e, id }) => ({
176
+ id,
177
+ from: e.from,
178
+ to: e.to,
179
+ labelW: e.label == null ? void 0 : widest(e.label, T_SUB.size, T_SUB.weight) + 8
180
+ }));
181
+ const flowFrom = {};
182
+ for (const [f, fv] of Object.entries(view.def.flows)) flowFrom[f] = fv.from;
183
+ return {
184
+ key: JSON.stringify({
185
+ direction,
186
+ nodes: nodes.map((n) => [
187
+ n.id,
188
+ n.def.kind,
189
+ n.inner
190
+ ]),
191
+ edges,
192
+ flowFrom
193
+ }),
194
+ direction,
195
+ nodes,
196
+ edges,
197
+ stageNode,
198
+ flowFrom
199
+ };
200
+ }
201
+ /**
202
+ * Labels are sized for their widest text over every load the slider can reach, so moving the load never moves the
203
+ * layout. Text width does not grow steadily with a number ("9.9k" is wider than "10k"), hence every step, not a few.
204
+ */
205
+ function labelWidths(view, ev, measure) {
206
+ const { load: _, ...rest } = ev.values;
207
+ const key = JSON.stringify(rest);
208
+ const byView = sampleCache.get(measure) ?? /* @__PURE__ */ new WeakMap();
209
+ sampleCache.set(measure, byView);
210
+ const byValues = byView.get(view) ?? /* @__PURE__ */ new Map();
211
+ byView.set(view, byValues);
212
+ let sampled = byValues.get(key);
213
+ if (!sampled) {
214
+ const { min, max, step } = view.model.load;
215
+ const n = Math.floor((max - min) / step);
216
+ sampled = {
217
+ contexts: (n <= 400 ? [...Array.from({ length: n + 1 }, (_, i) => min + i * step), max] : Array.from({ length: 401 }, (_, i) => min + (max - min) * i / 400)).map((load) => view.model.evaluate({
218
+ ...ev.values,
219
+ load
220
+ }).ctx),
221
+ widths: /* @__PURE__ */ new Map()
222
+ };
223
+ if (byValues.size > 32) byValues.delete(byValues.keys().next().value);
224
+ byValues.set(key, sampled);
225
+ }
226
+ const { contexts, widths } = sampled;
227
+ return (l, size, weight) => {
228
+ if (l == null) return 0;
229
+ if (typeof l === "string") return measure(l, size, weight);
230
+ const byFont = widths.get(l) ?? /* @__PURE__ */ new Map();
231
+ widths.set(l, byFont);
232
+ const font = `${size}/${weight}`;
233
+ let w = byFont.get(font);
234
+ if (w == null) {
235
+ w = Math.max(...contexts.map((ctx) => measure(text(l, ctx), size, weight)));
236
+ byFont.set(font, w);
237
+ }
238
+ return w;
239
+ };
240
+ }
241
+ async function run(prep, elk) {
242
+ const graph = {
243
+ id: "root",
244
+ layoutOptions: {
245
+ "elk.algorithm": "layered",
246
+ "elk.direction": prep.direction,
247
+ "elk.edgeRouting": "ORTHOGONAL",
248
+ "elk.padding": "[top=16,left=16,bottom=16,right=16]",
249
+ "elk.spacing.nodeNode": "28",
250
+ "elk.spacing.edgeNode": "16",
251
+ "elk.spacing.edgeEdge": "12",
252
+ "elk.spacing.edgeLabel": "4",
253
+ "elk.layered.spacing.nodeNodeBetweenLayers": "48",
254
+ "elk.layered.spacing.edgeNodeBetweenLayers": "16",
255
+ "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
256
+ "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
257
+ "elk.layered.edgeLabels.sideSelection": "ALWAYS_UP"
258
+ },
259
+ children: prep.nodes.map((n) => ({
260
+ id: n.id,
261
+ width: n.inner.w,
262
+ height: n.inner.h
263
+ })),
264
+ edges: prep.edges.map((e) => ({
265
+ id: e.id,
266
+ sources: [e.from],
267
+ targets: [e.to],
268
+ labels: e.labelW ? [{
269
+ id: `${e.id}:label`,
270
+ text: e.id,
271
+ width: e.labelW,
272
+ height: LABEL_H,
273
+ layoutOptions: { "elk.edgeLabels.placement": "CENTER" }
274
+ }] : []
275
+ }))
276
+ };
277
+ const out = await (elk ?? await localElk()).layout(graph);
278
+ const placed = new Map((out.children ?? []).map((c) => [c.id, c]));
279
+ const nodes = prep.nodes.map(({ id, def, inner }) => {
280
+ const c = placed.get(id);
281
+ return offset({
282
+ id,
283
+ kind: def.kind,
284
+ shape: def.kind === "node" ? def.shape : "box",
285
+ stages: stagesOf(def),
286
+ component: def.kind === "node" ? def.component : void 0,
287
+ ...inner,
288
+ x: 0,
289
+ y: 0
290
+ }, c.x ?? 0, c.y ?? 0);
291
+ });
292
+ const edges = (out.edges ?? []).map((e) => {
293
+ const p = prep.edges.find((x) => x.id === e.id);
294
+ const points = [];
295
+ for (const s of e.sections ?? []) {
296
+ points.push([s.startPoint.x, s.startPoint.y]);
297
+ for (const b of s.bendPoints ?? []) points.push([b.x, b.y]);
298
+ points.push([s.endPoint.x, s.endPoint.y]);
299
+ }
300
+ const l = e.labels?.[0];
301
+ return {
302
+ id: e.id,
303
+ from: p.from,
304
+ to: p.to,
305
+ points,
306
+ label: l ? {
307
+ x: l.x ?? 0,
308
+ y: l.y ?? 0,
309
+ w: l.width ?? 0,
310
+ h: l.height ?? 0
311
+ } : void 0
312
+ };
313
+ });
314
+ return {
315
+ key: prep.key,
316
+ direction: prep.direction,
317
+ width: Math.ceil(out.width ?? 0),
318
+ height: Math.ceil(out.height ?? 0),
319
+ nodes,
320
+ edges,
321
+ stageNode: prep.stageNode,
322
+ flowFrom: prep.flowFrom
323
+ };
324
+ }
325
+ /** A plain box; `top` makes room for a cylinder's lid. */
326
+ function boxInner(titleW, subW, hasSub, top) {
327
+ const w = Math.max(110, titleW + 24, subW + 24);
328
+ return {
329
+ w,
330
+ h: (hasSub ? 48 : 36) + top,
331
+ title: {
332
+ x: w / 2,
333
+ y: (hasSub ? 20 : 23) + top,
334
+ ...T_TITLE
335
+ },
336
+ sub: hasSub ? {
337
+ x: w / 2,
338
+ y: 36 + top,
339
+ ...T_SUB
340
+ } : void 0,
341
+ members: [],
342
+ groups: [],
343
+ cells: [],
344
+ slots: []
345
+ };
346
+ }
347
+ function header(titleW, subW, hasSub) {
348
+ return {
349
+ h: hasSub ? 40 : 26,
350
+ minW: Math.max(titleW, subW) + 16
351
+ };
352
+ }
353
+ function clusterInner(def, stages, ctx, direction, titleW, subW, measure, view) {
354
+ const m = Math.max(1, dyn(def.members, ctx));
355
+ const hd = header(titleW, subW, def.sub != null);
356
+ const flowName = (f) => view.def.flows[f]?.label ?? f;
357
+ const flows = stages.flatMap((st) => {
358
+ const parts = Object.entries(st.partitions).filter(([, n]) => n > 0);
359
+ return parts.map(([f, n]) => ({
360
+ stage: st.id,
361
+ f,
362
+ n,
363
+ text: stages.length > 1 ? parts.length > 1 ? `${st.label} · ${flowName(f)}` : st.label : flowName(f)
364
+ }));
365
+ });
366
+ const memberW = Math.max(64, ...flows.map((g) => measure(g.text, T_SMALL.size)), measure("member 00", T_SMALL.size)) + 12;
367
+ const rowsOf = (n, k) => Math.ceil(Math.ceil((n - k) / m) / CELL_COLS);
368
+ const memberH = (k) => 14 + flows.reduce((h, g) => h + 12 + rowsOf(g.n, k) * 11, 0) + 4;
369
+ const mh = Math.max(...Array.from({ length: m }, (_, k) => memberH(k)));
370
+ const down = direction === "DOWN";
371
+ const innerW = down ? m * memberW + (m - 1) * GAP : memberW;
372
+ const w = Math.max(innerW + 16, hd.minW);
373
+ const h = hd.h + (down ? mh : m * mh + (m - 1) * GAP) + PAD;
374
+ const x0 = (w - innerW) / 2;
375
+ const members = [];
376
+ const groups = [];
377
+ const cells = [];
378
+ for (let k = 0; k < m; k++) {
379
+ const bx = down ? x0 + k * (memberW + GAP) : x0;
380
+ const by = down ? hd.h : hd.h + k * (mh + GAP);
381
+ members.push({
382
+ x: bx,
383
+ y: by,
384
+ w: memberW,
385
+ h: mh,
386
+ label: def.memberLabel?.(k) ?? `member ${k}`,
387
+ spot: {
388
+ x: bx + GAP,
389
+ y: by + 11,
390
+ ...T_SMALL
391
+ }
392
+ });
393
+ let cy = by + 14;
394
+ for (const { stage, f, n, text } of flows) {
395
+ groups.push({
396
+ flow: f,
397
+ text,
398
+ spot: {
399
+ x: bx + GAP,
400
+ y: cy + 9,
401
+ ...T_SMALL
402
+ }
403
+ });
404
+ cy += 12;
405
+ for (let i = k, j = 0; i < n; i += m, j++) cells.push({
406
+ stage,
407
+ flow: f,
408
+ index: i,
409
+ x: bx + GAP + j % CELL_COLS * 11,
410
+ y: cy + Math.floor(j / CELL_COLS) * 11,
411
+ w: CELL,
412
+ h: CELL
413
+ });
414
+ cy += rowsOf(n, k) * 11;
415
+ }
416
+ }
417
+ return {
418
+ w,
419
+ h,
420
+ title: {
421
+ x: w / 2,
422
+ y: 17,
423
+ ...T_TITLE
424
+ },
425
+ sub: def.sub != null ? {
426
+ x: w / 2,
427
+ y: 32,
428
+ ...T_SUB
429
+ } : void 0,
430
+ members,
431
+ groups,
432
+ cells,
433
+ slots: []
434
+ };
435
+ }
436
+ function poolInner(def, stage, direction, titleW, subW) {
437
+ const { pods, perPod } = stage;
438
+ const hd = header(titleW, subW, def.sub != null);
439
+ const cols = Math.min(perPod, 4);
440
+ const rows = Math.ceil(perPod / cols);
441
+ const podW = Math.max(cols * 13 - SLOT_GAP + 12, 44);
442
+ const podH = 14 + rows * 13 - SLOT_GAP + GAP;
443
+ const podCols = direction === "DOWN" ? Math.min(pods, 3) : Math.ceil(pods / 3);
444
+ const podRows = Math.ceil(pods / podCols);
445
+ const innerW = podCols * podW + (podCols - 1) * GAP;
446
+ const w = Math.max(innerW + 16, hd.minW);
447
+ const h = hd.h + podRows * podH + (podRows - 1) * GAP + PAD;
448
+ const x0 = (w - innerW) / 2;
449
+ const members = [];
450
+ const slots = [];
451
+ for (let p = 0; p < pods; p++) {
452
+ const bx = x0 + p % podCols * (podW + GAP);
453
+ const by = hd.h + Math.floor(p / podCols) * (podH + GAP);
454
+ members.push({
455
+ x: bx,
456
+ y: by,
457
+ w: podW,
458
+ h: podH,
459
+ label: def.podLabel?.(p) ?? `pod ${p}`,
460
+ spot: {
461
+ x: bx + GAP,
462
+ y: by + 11,
463
+ ...T_SMALL
464
+ }
465
+ });
466
+ const gx = bx + (podW - (cols * 13 - SLOT_GAP)) / 2;
467
+ for (let t = 0; t < perPod; t++) slots.push({
468
+ index: p * perPod + t,
469
+ x: gx + t % cols * 13,
470
+ y: by + 14 + Math.floor(t / cols) * 13,
471
+ w: SLOT,
472
+ h: SLOT
473
+ });
474
+ }
475
+ return {
476
+ w,
477
+ h,
478
+ title: {
479
+ x: w / 2,
480
+ y: 17,
481
+ ...T_TITLE
482
+ },
483
+ sub: def.sub != null ? {
484
+ x: w / 2,
485
+ y: 32,
486
+ ...T_SUB
487
+ } : void 0,
488
+ members,
489
+ groups: [],
490
+ cells: [],
491
+ slots
492
+ };
493
+ }
494
+ function offset(n, dx, dy) {
495
+ const box = (b) => ({
496
+ ...b,
497
+ x: b.x + dx,
498
+ y: b.y + dy
499
+ });
500
+ const spot = (s) => ({
501
+ ...s,
502
+ x: s.x + dx,
503
+ y: s.y + dy
504
+ });
505
+ return {
506
+ ...n,
507
+ x: dx,
508
+ y: dy,
509
+ title: spot(n.title),
510
+ sub: n.sub && spot(n.sub),
511
+ members: n.members.map((m) => ({
512
+ ...box(m),
513
+ spot: spot(m.spot)
514
+ })),
515
+ groups: n.groups.map((g) => ({
516
+ ...g,
517
+ spot: spot(g.spot)
518
+ })),
519
+ cells: n.cells.map(box),
520
+ slots: n.slots.map(box)
521
+ };
522
+ }
523
+ function stagesOf(def) {
524
+ const st = def.stage;
525
+ return st == null ? [] : typeof st === "string" ? [st] : [...st];
526
+ }
527
+ function visibleEdges(view, ctx) {
528
+ return view.def.edges.map((e, i) => ({
529
+ e,
530
+ id: `e${i}`
531
+ })).filter(({ e }) => e.when ? Boolean(e.when(ctx)) : true);
532
+ }
533
+ function text(l, ctx) {
534
+ return typeof l === "function" ? l(ctx) : l;
535
+ }
536
+ // src/view.ts
537
+ function defineView(model, def) {
538
+ const nodes = def.nodes;
539
+ for (const e of def.edges) for (const end of [e.from, e.to]) if (!(end in nodes)) throw new Error(`loadmap: edge to unknown node "${end}"`);
540
+ for (const [f, fv] of Object.entries(def.flows)) if (!(fv.from in nodes)) throw new Error(`loadmap: flow "${f}" starts at unknown node "${fv.from}"`);
541
+ const shows = (n, s) => Array.isArray(n.stage) ? n.stage.includes(s) : n.stage === s;
542
+ for (const s of model.stageIds) if (!Object.values(nodes).some((n) => shows(n, s))) throw new Error(`loadmap: no node shows stage "${s}"`);
543
+ return {
544
+ model,
545
+ def
546
+ };
547
+ }
548
+ function node(o) {
549
+ return {
550
+ kind: "node",
551
+ shape: "box",
552
+ ...o
553
+ };
554
+ }
555
+ function cluster(o) {
556
+ return {
557
+ kind: "cluster",
558
+ ...o
559
+ };
560
+ }
561
+ function pool(o) {
562
+ return {
563
+ kind: "pool",
564
+ ...o
565
+ };
566
+ }
567
+ function edge(from, to, o = {}) {
568
+ return {
569
+ from,
570
+ to,
571
+ ...o
572
+ };
573
+ }
574
+ export { anchor, cluster, collisions, defineView, edge, estimateText, labelsAt, layout, legPath, localElk, node, pointAt, pool, structureKey };
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@loadmap/layout",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Views for loadmap models and their ELK layout: nodes, clusters, pools, edges, labels that never collide.",
5
+ "keywords": [
6
+ "loadmap",
7
+ "layout",
8
+ "elk",
9
+ "diagram"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "Jaime Rojas",
13
+ "homepage": "https://jaim3r0jas.github.io/loadmap/",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jaim3r0jas/loadmap.git",
17
+ "directory": "packages/layout"
18
+ },
19
+ "bugs": "https://github.com/jaim3r0jas/loadmap/issues",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "dependencies": {
32
+ "@loadmap/core": "0.1.0",
33
+ "elkjs": "^0.12.1"
34
+ },
35
+ "peerDependencies": {},
36
+ "scripts": {
37
+ "build": "tsdown"
38
+ }
6
39
  }