@rokkit/graph 1.8.2 → 1.9.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.
@@ -94,6 +94,8 @@ export type GraphStateConfig = {
94
94
  above?: string;
95
95
  /** `world` / `sunburst` — the measure (a 0..1 share) each box is shaded by (#164). */
96
96
  shadeBy?: string;
97
+ /** `neighborhood` — centre the drawn cards (default) or the focus card (#170). */
98
+ centre?: 'content' | 'focus';
97
99
  };
98
100
  /**
99
101
  * The store. Turns raw `nodes`/`edges`/`fields` into the reactive shape the visuals
@@ -88,6 +88,7 @@ export class GraphState {
88
88
  colorBy: this.config.colorBy,
89
89
  above: this.config.above,
90
90
  shadeBy: this.config.shadeBy,
91
+ centre: this.config.centre,
91
92
  edgeStyle: this.config.edgeStyle,
92
93
  focus: this.config.focus ?? this.selection.value,
93
94
  expanded: this.selection.expanded
@@ -396,7 +397,7 @@ export class GraphState {
396
397
  * stay in the component. Keeping this here is what lets it be tested without a renderer.
397
398
  */
398
399
  get contentSize() {
399
- return contentExtent(this.#result.clusters, this.#result.cards, this.#result.size);
400
+ return contentExtent(this.#result.clusters, this.#result.cards, this.#result.size, this.#result.extent);
400
401
  }
401
402
  get related() {
402
403
  return this.selection.related;
@@ -41,6 +41,11 @@
41
41
  * the root and invisible one click later.
42
42
  */
43
43
  groupTint?: boolean
44
+ /**
45
+ * What the canvas centres (#170): the drawn cards (default), or the focus card — which
46
+ * leaves a blank column when one side of the focus is empty.
47
+ */
48
+ centre?: 'content' | 'focus'
44
49
  controls?: boolean
45
50
  maxDepth?: number
46
51
  legend?: boolean
@@ -66,6 +71,7 @@
66
71
  depth = $bindable(1),
67
72
  edgeStyle = $bindable('curved'),
68
73
  groupTint = true,
74
+ centre = 'content',
69
75
  controls = false,
70
76
  maxDepth = 3,
71
77
  legend = false,
@@ -91,6 +97,7 @@
91
97
  depth,
92
98
  edgeStyle,
93
99
  groupTint,
100
+ centre,
94
101
  value,
95
102
  preset,
96
103
  mode,
@@ -23,6 +23,11 @@ type Props = {
23
23
  * the root and invisible one click later.
24
24
  */
25
25
  groupTint?: boolean;
26
+ /**
27
+ * What the canvas centres (#170): the drawn cards (default), or the focus card — which
28
+ * leaves a blank column when one side of the focus is empty.
29
+ */
30
+ centre?: 'content' | 'focus';
26
31
  controls?: boolean;
27
32
  maxDepth?: number;
28
33
  legend?: boolean;
@@ -13,5 +13,5 @@ type Box = {
13
13
  * clusters', or — for an ungrouped layout (neighborhood), which reports none — the cards', and
14
14
  * the layout size only when both are empty.
15
15
  */
16
- export declare function contentExtent(clusters: Box[], cards: Record<string, Box>, size: Size): Size;
16
+ export declare function contentExtent(clusters: Box[], cards: Record<string, Box>, size: Size, declared?: Size): Size;
17
17
  export {};
@@ -16,7 +16,10 @@ function extentOf(boxes) {
16
16
  * clusters', or — for an ungrouped layout (neighborhood), which reports none — the cards', and
17
17
  * the layout size only when both are empty.
18
18
  */
19
- export function contentExtent(clusters, cards, size) {
19
+ export function contentExtent(clusters, cards, size, declared) {
20
+ // A layout that reserved blank space on purpose says what to frame (#170, `centre: 'focus'`).
21
+ if (declared)
22
+ return declared;
20
23
  let { w, h } = extentOf(clusters);
21
24
  if (!w || !h) {
22
25
  const fromCards = extentOf(Object.values(cards));
@@ -122,10 +122,8 @@ function buildNeighbourCards(model, neighbours, expanded) {
122
122
  */
123
123
  function arrange(built, cards, ctx) {
124
124
  const occupied = built.filter((r) => r.ids.length > 0);
125
- // The same edge room on BOTH sides — a self-loop's bow plus a hairline — so the canvas
126
- // stays symmetric about the focus.
127
- const edge = (ctx.hasLoop ? LOOP_W : 0) + 2;
128
- const { focusX, xFor } = geometry(occupied, edge);
125
+ const loop = ctx.hasLoop ? LOOP_W : 0;
126
+ const { focusX, xFor } = geometry(occupied, ctx.centre, loop);
129
127
  const focusCard = cards[ctx.focus.id];
130
128
  const height = placeRings(occupied, cards, { focusCard, focusX, xFor });
131
129
  const columns = buildColumns(occupied, {
@@ -134,9 +132,11 @@ function arrange(built, cards, ctx) {
134
132
  xFor,
135
133
  dependency: ctx.dependency
136
134
  });
137
- // Symmetric about the focus (#170): as much room right of it as left, so the focus card's
138
- // centre IS the canvas's centre whichever side is empty or deeper.
139
- const width = focusX * 2 + CARD_W;
135
+ // `focus`: symmetric about the focus, so its centre IS the canvas's centre (#170).
136
+ // `content`: ends at the deepest column drawn, so the canvas centres the cards themselves.
137
+ const width = ctx.centre === 'focus'
138
+ ? focusX * 2 + CARD_W
139
+ : xFor('out', furthest(occupied, 'out')) + CARD_W + loop + 4;
140
140
  return { columns, size: { w: width, h: height } };
141
141
  }
142
142
  /**
@@ -168,13 +168,18 @@ function drawableIn(model, cards) {
168
168
  }
169
169
  /**
170
170
  * Where each ring sits. Ring d on the `in` side is d steps left of the focus; on the `out`
171
- * side, d steps right. The focus sits past `edge` and as many columns as the DEEPER side
172
- * needs, so both sides get the same room and the focus is the centre.
171
+ * side, d steps right.
172
+ *
173
+ * `content` (the default): the focus is pushed right only by how deep the inbound side goes,
174
+ * so an empty side costs nothing and the canvas centres the cards that are drawn.
175
+ * `focus`: the focus sits past the DEEPER side's reach, with the same edge room on both sides,
176
+ * so the focus is the centre (#170). Opt-in — with one side empty it leaves a blank column.
173
177
  */
174
- function geometry(occupied, edge) {
178
+ function geometry(occupied, centre, loop) {
175
179
  const columnStep = CARD_W + COL_GAP;
176
- const reach = Math.max(furthest(occupied, 'in'), furthest(occupied, 'out'));
177
- const focusX = edge + reach * columnStep;
180
+ const focusX = centre === 'focus'
181
+ ? loop + 2 + Math.max(furthest(occupied, 'in'), furthest(occupied, 'out')) * columnStep
182
+ : furthest(occupied, 'in') * columnStep;
178
183
  return {
179
184
  focusX,
180
185
  xFor: (side, d) => (side === 'in' ? focusX - d * columnStep : focusX + d * columnStep)
@@ -236,6 +241,14 @@ function buildColumns(occupied, ctx) {
236
241
  ...side('out')
237
242
  ];
238
243
  }
244
+ /**
245
+ * The size, and — when centring on the focus — the extent the canvas must frame: focus-centring
246
+ * reserves blank columns the cards do not reach, and a canvas that framed only the cards would
247
+ * centre them and undo the reservation (#170).
248
+ */
249
+ function framed(size, centre) {
250
+ return centre === 'focus' ? { size, extent: { w: size.w, h: size.h } } : { size };
251
+ }
239
252
  /**
240
253
  * Entity-centric layout. `options.focus` names the node to centre; without it, or with
241
254
  * an id the model does not know, the result is empty.
@@ -259,10 +272,12 @@ export const neighborhood = (model, options) => {
259
272
  focus,
260
273
  expanded: options.expanded
261
274
  });
275
+ const centre = options.centre ?? 'content';
262
276
  const { columns, size } = arrange(built, cards, {
263
277
  focus,
264
278
  hasLoop: selfEdges.length > 0,
265
- dependency: model.edges.some((e) => e.kind === 'dependency')
279
+ dependency: model.edges.some((e) => e.kind === 'dependency'),
280
+ centre
266
281
  });
267
- return { clusters: [], cards, edges: buildEdges(drawableIn(model, cards), cards), size, columns };
282
+ return { ...framed(size, centre), clusters: [], cards, edges: buildEdges(drawableIn(model, cards), cards), columns };
268
283
  };
@@ -4,7 +4,7 @@ import type { LayoutOptions } from './types.js';
4
4
  * missed here would warn about a legitimate option, which is worse than the silence this
5
5
  * exists to fix. `spec/options.spec.ts` compares the two.
6
6
  */
7
- export declare const LAYOUT_OPTION_KEYS: readonly ["density", "arrange", "edgeStyle", "focus", "expanded", "groupBy", "nestBy", "sizeBy", "sizeScale", "depth", "focusPath", "levels", "radialMode", "root", "bundleTension", "showEdges", "layerLabels", "widthBy", "heightBy", "colorBy", "above", "shadeBy"];
7
+ export declare const LAYOUT_OPTION_KEYS: readonly ["density", "arrange", "edgeStyle", "focus", "expanded", "groupBy", "nestBy", "sizeBy", "sizeScale", "depth", "focusPath", "levels", "radialMode", "root", "bundleTension", "showEdges", "layerLabels", "widthBy", "heightBy", "colorBy", "above", "shadeBy", "centre"];
8
8
  /**
9
9
  * Which options have an EFFECT under each built-in layout.
10
10
  *
@@ -25,7 +25,8 @@ export const LAYOUT_OPTION_KEYS = [
25
25
  'heightBy',
26
26
  'colorBy',
27
27
  'above',
28
- 'shadeBy'
28
+ 'shadeBy',
29
+ 'centre'
29
30
  ];
30
31
  /**
31
32
  * Which options have an EFFECT under each built-in layout.
@@ -52,7 +53,7 @@ export const LAYOUT_OPTIONS = {
52
53
  flow: ['density', 'edgeStyle', 'expanded'],
53
54
  // No edgeStyle: a layered edge is always a vertical S, built by the layout itself.
54
55
  layers: ['density', 'expanded', 'showEdges', 'layerLabels'],
55
- neighborhood: ['edgeStyle', 'expanded', 'focus', 'depth'],
56
+ neighborhood: ['edgeStyle', 'expanded', 'focus', 'depth', 'centre'],
56
57
  points: ['edgeStyle', 'sizeBy', 'sizeScale'],
57
58
  // No edgeStyle: the tree's links are elbows the layout draws itself.
58
59
  polymetric: ['widthBy', 'heightBy', 'colorBy'],
@@ -307,6 +307,12 @@ export type LayoutOptions = {
307
307
  * container takes the size-weighted mean of what it holds unless it declares its own.
308
308
  */
309
309
  shadeBy?: string;
310
+ /**
311
+ * `neighborhood` only — what the canvas centres (#170). `content` (default): the cards that
312
+ * are drawn, so an empty side costs nothing. `focus`: the focus card, with the deeper side's
313
+ * reach reserved on both sides — a blank column when one side is empty.
314
+ */
315
+ centre?: 'content' | 'focus';
310
316
  };
311
317
  /** Every edge, or only the ones a view is about. */
312
318
  export type ShowEdges = 'all' | 'violations' | 'hidden';
@@ -318,6 +324,12 @@ export type ChannelScale = {
318
324
  cap: number;
319
325
  };
320
326
  export type LayoutResult = {
327
+ /**
328
+ * What the canvas should frame, when it is not the drawn content — a layout that reserves
329
+ * blank space on purpose (`neighborhood` with `centre: 'focus'`). Absent: the canvas frames
330
+ * the clusters' or cards' extent.
331
+ */
332
+ extent?: Size;
321
333
  /** `polymetric` — what each channel encodes, for the legend. */
322
334
  channels?: {
323
335
  width: ChannelScale;
@@ -38,6 +38,7 @@ export type GraphConfigValues = {
38
38
  colorBy: string | undefined;
39
39
  above: string | undefined;
40
40
  shadeBy: string | undefined;
41
+ centre: 'content' | 'focus' | undefined;
41
42
  };
42
43
  type Key = keyof GraphConfigValues;
43
44
  type Field = {
@@ -120,5 +121,6 @@ export declare class GraphConfig {
120
121
  get colorBy(): string | undefined;
121
122
  get above(): string | undefined;
122
123
  get shadeBy(): string | undefined;
124
+ get centre(): "focus" | "content" | undefined;
123
125
  }
124
126
  export {};
@@ -58,7 +58,8 @@ export const CONFIG_FIELDS = Object.freeze([
58
58
  { key: 'heightBy', raw: true },
59
59
  { key: 'colorBy', raw: true },
60
60
  { key: 'above', raw: true },
61
- { key: 'shadeBy', raw: true }
61
+ { key: 'shadeBy', raw: true },
62
+ { key: 'centre', raw: true }
62
63
  ]);
63
64
  function resolve(config) {
64
65
  const values = {};
@@ -250,4 +251,7 @@ export class GraphConfig {
250
251
  get shadeBy() {
251
252
  return this.#v.shadeBy;
252
253
  }
254
+ get centre() {
255
+ return this.#v.centre;
256
+ }
253
257
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/graph",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "Node-link diagram components — pluggable layouts, ER/schema views",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "check:types": "tsc --noEmit"
47
47
  },
48
48
  "dependencies": {
49
- "@rokkit/core": "1.8.2",
50
- "@rokkit/states": "1.8.2"
49
+ "@rokkit/core": "1.9.0",
50
+ "@rokkit/states": "1.9.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "@rokkit/ui": "1.8.2",
53
+ "@rokkit/ui": "1.9.0",
54
54
  "svelte": "^5.0.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -59,7 +59,7 @@
59
59
  }
60
60
  },
61
61
  "devDependencies": {
62
- "@rokkit/ui": "1.8.2",
62
+ "@rokkit/ui": "1.9.0",
63
63
  "@sveltejs/package": "^2.5.8",
64
64
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
65
65
  "svelte": "^5.55.7",