@transitrix/diagrams 1.8.1 → 1.8.3

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.
@@ -7,6 +7,14 @@
7
7
  */
8
8
 
9
9
  export const TEXT_MARGIN_X = 8;
10
+ /**
11
+ * Minimum reserved vertical padding (px) between the box's top/bottom edge and
12
+ * the nearest line's outer extent. Always enforced — see
13
+ * {@link layoutCenteredEntityText}'s adaptive line-count reduction, which
14
+ * trades name-line count for this padding rather than letting text touch or
15
+ * cross the box border.
16
+ */
17
+ export const TEXT_MARGIN_Y = 4;
10
18
  /** Approximate character width (px) at text-primary size (12px/600). */
11
19
  export const CHAR_W_PRIMARY = 7;
12
20
  /** Approximate character width (px) at text-id size (10px/600). */
@@ -19,10 +27,15 @@ export const LINE_H_PRIMARY = 14;
19
27
  export const LINE_H_SECONDARY = 14;
20
28
  /** Vertical advance between id line centres (text-id). */
21
29
  export const LINE_H_ID = 12;
22
- /** Gap between the name block and the id block in leaf-style layouts. */
30
+ /**
31
+ * Gap between the name block and the id block in leaf-style layouts — used as
32
+ * a direct centre-to-centre distance there, so keep it >= ~11px (half of
33
+ * text-primary's 12px font + half of text-id's 10px font) to avoid the
34
+ * overlap regressed in #419. Do not tighten without re-checking that test.
35
+ */
23
36
  export const NAME_ID_GAP = 14;
24
37
  /** Gap between stacked row groups (name → type → id). */
25
- export const ROW_GROUP_GAP = 9;
38
+ export const ROW_GROUP_GAP = 4;
26
39
 
27
40
  export function maxCharsForInnerWidth(innerWidth: number, charWidth: number): number {
28
41
  return Math.max(4, Math.floor(Math.max(0, innerWidth) / charWidth));
@@ -156,6 +169,7 @@ export interface LayoutCenteredEntityOptions {
156
169
  nameMaxLines?: number;
157
170
  idMaxLines?: number;
158
171
  marginX?: number;
172
+ marginY?: number;
159
173
  }
160
174
 
161
175
  interface TextRowGroup {
@@ -165,69 +179,111 @@ interface TextRowGroup {
165
179
  gapAfter: number;
166
180
  }
167
181
 
168
- function totalVerticalSpan(groups: TextRowGroup[]): number {
169
- let span = 0;
170
- for (let i = 0; i < groups.length; i++) {
171
- const g = groups[i];
172
- if (g.lines.length > 1) span += (g.lines.length - 1) * g.lineHeight;
173
- if (i < groups.length - 1) span += g.gapAfter;
182
+ /**
183
+ * Half-extent (px) of rendered glyphs around their centre y — approximately
184
+ * font-size / 2, the same heuristic the #419 non-overlap regression test
185
+ * uses. Used only to keep the *outermost* line's visual extent (not just its
186
+ * centre) inside the box when computing edge padding; inter-line spacing
187
+ * between adjacent rows is governed separately by the LINE_H_ constants,
188
+ * NAME_ID_GAP and ROW_GROUP_GAP, which already keep centres far enough apart
189
+ * to avoid overlap.
190
+ */
191
+ const HALF_EXTENT_PRIMARY = 6; // text-primary, 12px font
192
+ const HALF_EXTENT_ID = 5; // text-id, 10px font
193
+
194
+ /** Place `groups`' lines starting at `startY`, advancing by each line's own height and gap. */
195
+ function placeLines(groups: TextRowGroup[], startY: number): TextLineSpec[] {
196
+ const out: TextLineSpec[] = [];
197
+ let y = startY;
198
+ for (let gi = 0; gi < groups.length; gi++) {
199
+ const g = groups[gi];
200
+ for (let li = 0; li < g.lines.length; li++) {
201
+ out.push({ cls: g.cls, text: g.lines[li], y });
202
+ y += g.lineHeight;
203
+ }
204
+ if (gi < groups.length - 1) y += g.gapAfter;
174
205
  }
175
- return span;
206
+ return out;
207
+ }
208
+
209
+ /** Centre-to-centre distance from the first to the last placed line. */
210
+ function centerSpan(groups: TextRowGroup[]): number {
211
+ const ys = placeLines(groups, 0).map((s) => s.y);
212
+ return ys[ys.length - 1] - ys[0];
176
213
  }
177
214
 
178
215
  /**
179
216
  * Layout centred entity text (name → optional type → id) inside a fixed box.
180
217
  * Returns absolute SVG y-positions (line centres) for each rendered row.
218
+ *
219
+ * Guarantees, in order of priority:
220
+ * 1. Content never crosses the box's top/bottom edge.
221
+ * 2. At least `marginY` (default {@link TEXT_MARGIN_Y}) of padding is kept
222
+ * above the first line's glyphs and below the last line's glyphs.
223
+ * 3. The name gets as many lines as `nameMaxLines` allows (2 by default)
224
+ * when the box is tall enough; on short/compact boxes (e.g. with a type
225
+ * row present) it degrades to fewer name lines — trading line count,
226
+ * not padding — rather than letting text overflow.
181
227
  */
182
228
  export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): TextLineSpec[] {
183
229
  const marginX = opts.marginX ?? TEXT_MARGIN_X;
230
+ const marginY = opts.marginY ?? TEXT_MARGIN_Y;
184
231
  const innerW = Math.max(0, opts.boxWidth - marginX * 2);
185
- const cx = opts.boxX + opts.boxWidth / 2;
186
232
  const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
187
233
  const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
188
234
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
189
235
 
190
- const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 2);
191
236
  const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
192
237
  const idMaxLines = opts.idMaxLines ?? 1;
193
238
  const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
194
239
 
195
- const groups: TextRowGroup[] = [
196
- {
197
- cls: 'text-primary',
198
- lines: nameLines,
199
- lineHeight: LINE_H_PRIMARY,
200
- gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
201
- },
202
- ];
203
- if (typeLine) {
240
+ function buildGroups(nameLines: string[]): TextRowGroup[] {
241
+ const groups: TextRowGroup[] = [
242
+ {
243
+ cls: 'text-primary',
244
+ lines: nameLines,
245
+ lineHeight: LINE_H_PRIMARY,
246
+ gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
247
+ },
248
+ ];
249
+ if (typeLine) {
250
+ groups.push({
251
+ cls: 'text-secondary',
252
+ lines: [typeLine],
253
+ lineHeight: LINE_H_SECONDARY,
254
+ gapAfter: NAME_ID_GAP,
255
+ });
256
+ }
204
257
  groups.push({
205
- cls: 'text-secondary',
206
- lines: [typeLine],
207
- lineHeight: LINE_H_SECONDARY,
208
- gapAfter: NAME_ID_GAP,
258
+ cls: 'text-id',
259
+ lines: idLines,
260
+ lineHeight: LINE_H_ID,
261
+ gapAfter: 0,
209
262
  });
263
+ return groups;
210
264
  }
211
- groups.push({
212
- cls: 'text-id',
213
- lines: idLines,
214
- lineHeight: LINE_H_ID,
215
- gapAfter: 0,
216
- });
217
-
218
- const span = totalVerticalSpan(groups);
219
- const firstY = Math.round(opts.boxY + (opts.boxHeight - span) / 2);
220
- const out: TextLineSpec[] = [];
221
- let y = firstY;
222
- for (let gi = 0; gi < groups.length; gi++) {
223
- const g = groups[gi];
224
- for (let li = 0; li < g.lines.length; li++) {
225
- out.push({ cls: g.cls, text: g.lines[li], y });
226
- if (li < g.lines.length - 1) y += g.lineHeight;
227
- }
228
- if (gi < groups.length - 1) y += g.gapAfter;
265
+
266
+ // groups[0] is always the name (text-primary) and the last group is always
267
+ // the id (text-id), so the edge-padding half-extents below are fixed.
268
+ const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 2);
269
+ const availableHeight = Math.max(0, opts.boxHeight - marginY * 2);
270
+
271
+ let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
272
+ let groups = buildGroups(nameLines);
273
+ let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
274
+ for (
275
+ let cap = requestedNameMaxLines - 1;
276
+ cap >= 1 && contentSpan > availableHeight;
277
+ cap--
278
+ ) {
279
+ nameLines = wrapWords(opts.name, maxCharsName, cap);
280
+ groups = buildGroups(nameLines);
281
+ contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
229
282
  }
230
- return out;
283
+
284
+ const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
285
+ const firstY = Math.round(opts.boxY + margin + HALF_EXTENT_PRIMARY);
286
+ return placeLines(groups, firstY);
231
287
  }
232
288
 
233
289
  /** Emit centred `<text>` elements from {@link layoutCenteredEntityText} specs. */
@@ -256,25 +312,44 @@ export interface LayoutLeafBlockTextOptions {
256
312
  marginX?: number;
257
313
  }
258
314
 
259
- /** Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred. */
315
+ /**
316
+ * Leaf nested-block layout: name (up to 3 lines) + id (up to 2 lines), centred.
317
+ * Degrades name line count (never below 1) when the box is too short to hold
318
+ * the requested max plus {@link TEXT_MARGIN_Y} padding on both edges, so text
319
+ * never crosses the box border.
320
+ */
260
321
  export function layoutLeafBlockText(opts: LayoutLeafBlockTextOptions): TextLineSpec[] {
261
322
  const marginX = opts.marginX ?? TEXT_MARGIN_X;
262
323
  const innerW = Math.max(0, opts.boxWidth - marginX * 2);
263
324
  const maxCharsName = maxCharsForInnerWidth(innerW, CHAR_W_PRIMARY);
264
325
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
265
- const nameLines = wrapWords(opts.name, maxCharsName, opts.nameMaxLines ?? 3);
266
326
  const idLines = wrapId(opts.id, maxCharsId, 2);
327
+ const idCenterSpan = (idLines.length - 1) * LINE_H_ID;
328
+
329
+ const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 3);
330
+ const availableHeight = Math.max(0, opts.boxHeight - TEXT_MARGIN_Y * 2);
331
+
332
+ let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
333
+ let nameCenterSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
334
+ let contentSpan = nameCenterSpan + NAME_ID_GAP + idCenterSpan + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
335
+ for (
336
+ let cap = requestedNameMaxLines - 1;
337
+ cap >= 1 && contentSpan > availableHeight;
338
+ cap--
339
+ ) {
340
+ nameLines = wrapWords(opts.name, maxCharsName, cap);
341
+ nameCenterSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
342
+ contentSpan = nameCenterSpan + NAME_ID_GAP + idCenterSpan + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
343
+ }
267
344
 
268
- const nameSpan = (nameLines.length - 1) * LINE_H_PRIMARY;
269
- const idSpan = (idLines.length - 1) * LINE_H_ID;
270
- const totalSpan = nameSpan + NAME_ID_GAP + idSpan;
271
- const firstY = Math.round(opts.boxY + (opts.boxHeight - totalSpan) / 2);
345
+ const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
346
+ const firstY = Math.round(opts.boxY + margin + HALF_EXTENT_PRIMARY);
272
347
 
273
348
  const out: TextLineSpec[] = [];
274
349
  for (let i = 0; i < nameLines.length; i++) {
275
350
  out.push({ cls: 'text-primary', text: nameLines[i], y: firstY + i * LINE_H_PRIMARY });
276
351
  }
277
- const idFirstY = firstY + nameSpan + NAME_ID_GAP;
352
+ const idFirstY = firstY + nameCenterSpan + NAME_ID_GAP;
278
353
  for (let i = 0; i < idLines.length; i++) {
279
354
  out.push({ cls: 'text-id', text: idLines[i], y: idFirstY + i * LINE_H_ID });
280
355
  }
@@ -291,6 +366,14 @@ export interface LayoutHeaderBlockTextOptions {
291
366
  marginX?: number;
292
367
  }
293
368
 
369
+ /**
370
+ * Direct centre-to-centre gap between the header's name and id lines.
371
+ * Safely above the ~11px non-overlap floor (half of text-primary's 12px font
372
+ * + half of text-id's 10px font, per the #419 regression) but tighter than
373
+ * {@link NAME_ID_GAP} to fit the compact header strip.
374
+ */
375
+ const HEADER_NAME_ID_GAP = 12;
376
+
294
377
  /** Container header: single-line name + id stacked in the header strip. */
295
378
  export function layoutHeaderBlockText(opts: LayoutHeaderBlockTextOptions): TextLineSpec[] {
296
379
  const marginX = opts.marginX ?? TEXT_MARGIN_X;
@@ -299,11 +382,14 @@ export function layoutHeaderBlockText(opts: LayoutHeaderBlockTextOptions): TextL
299
382
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
300
383
  const nameText = truncateLine(opts.name, maxCharsName);
301
384
  const idText = truncateId(opts.id, maxCharsId);
302
- const headerMid = opts.boxY + opts.headerHeight / 2;
303
- const halfSpan = (LINE_H_PRIMARY + LINE_H_ID) / 4;
385
+
386
+ const contentSpan = HEADER_NAME_ID_GAP + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
387
+ const margin = Math.max(0, (opts.headerHeight - contentSpan) / 2);
388
+ const nameY = Math.round(opts.boxY + margin + HALF_EXTENT_PRIMARY);
389
+ const idY = nameY + HEADER_NAME_ID_GAP;
304
390
  return [
305
- { cls: 'text-primary', text: nameText, y: Math.round(headerMid - halfSpan) },
306
- { cls: 'text-id', text: idText, y: Math.round(headerMid + halfSpan) },
391
+ { cls: 'text-primary', text: nameText, y: nameY },
392
+ { cls: 'text-id', text: idText, y: idY },
307
393
  ];
308
394
  }
309
395