@transitrix/diagrams 1.13.1 → 1.13.2

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.
@@ -235,6 +235,7 @@ export function layoutFGCAPreview(inputDoc, options = {}) {
235
235
  const coveredActivities = new Set(changes.flatMap(c => c.activity_ids.map(String)));
236
236
  // Synthesize virtual Change nodes for activities without a real Change.
237
237
  // Each virtual node is 1:1 with one activity (not shared across activities).
238
+ // The layout key embeds the activity id for uniqueness; it is not a display id.
238
239
  const virtualChanges = [];
239
240
  for (const a of doc.activities) {
240
241
  if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
@@ -64,7 +64,8 @@ export interface LayoutCenteredEntityOptions {
64
64
  boxHeight: number;
65
65
  name: string;
66
66
  type?: string;
67
- id: string;
67
+ /** Canonical entity id. Empty or omitted skips the id row (placeholder nodes). */
68
+ id?: string;
68
69
  nameMaxLines?: number;
69
70
  idMaxLines?: number;
70
71
  marginX?: number;
@@ -159,7 +159,15 @@ export function truncateId(id, maxChars) {
159
159
  * to avoid overlap.
160
160
  */
161
161
  const HALF_EXTENT_PRIMARY = 6; // text-primary, 12px font
162
+ const HALF_EXTENT_SECONDARY = 6; // text-secondary, same size as name
162
163
  const HALF_EXTENT_ID = 5; // text-id, 10px font
164
+ function glyphHalfExtent(cls) {
165
+ if (cls === 'text-id')
166
+ return HALF_EXTENT_ID;
167
+ if (cls === 'text-secondary')
168
+ return HALF_EXTENT_SECONDARY;
169
+ return HALF_EXTENT_PRIMARY;
170
+ }
163
171
  /** Place `groups`' lines starting at `startY`, advancing by each line's own height and gap. */
164
172
  function placeLines(groups, startY) {
165
173
  const out = [];
@@ -201,15 +209,18 @@ export function layoutCenteredEntityText(opts) {
201
209
  const maxCharsType = maxCharsForInnerWidth(innerW, CHAR_W_SECONDARY);
202
210
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
203
211
  const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
212
+ const idText = opts.id?.trim() ?? '';
204
213
  const idMaxLines = opts.idMaxLines ?? 1;
205
- const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
214
+ const idLines = idText
215
+ ? (idMaxLines <= 1 ? [truncateId(idText, maxCharsId)] : wrapId(idText, maxCharsId, idMaxLines))
216
+ : [];
206
217
  function buildGroups(nameLines) {
207
218
  const groups = [
208
219
  {
209
220
  cls: 'text-primary',
210
221
  lines: nameLines,
211
222
  lineHeight: LINE_H_PRIMARY,
212
- gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
223
+ gapAfter: typeLine ? ROW_GROUP_GAP : idLines.length > 0 ? NAME_ID_GAP : 0,
213
224
  },
214
225
  ];
215
226
  if (typeLine) {
@@ -217,28 +228,30 @@ export function layoutCenteredEntityText(opts) {
217
228
  cls: 'text-secondary',
218
229
  lines: [typeLine],
219
230
  lineHeight: LINE_H_SECONDARY,
220
- gapAfter: NAME_ID_GAP,
231
+ gapAfter: idLines.length > 0 ? NAME_ID_GAP : 0,
232
+ });
233
+ }
234
+ if (idLines.length > 0) {
235
+ groups.push({
236
+ cls: 'text-id',
237
+ lines: idLines,
238
+ lineHeight: LINE_H_ID,
239
+ gapAfter: 0,
221
240
  });
222
241
  }
223
- groups.push({
224
- cls: 'text-id',
225
- lines: idLines,
226
- lineHeight: LINE_H_ID,
227
- gapAfter: 0,
228
- });
229
242
  return groups;
230
243
  }
231
- // groups[0] is always the name (text-primary) and the last group is always
232
- // the id (text-id), so the edge-padding half-extents below are fixed.
244
+ // groups[0] is always the name (text-primary). The last group is the id when
245
+ // one is present, otherwise type or name; edge padding uses that class.
233
246
  const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 2);
234
247
  const availableHeight = Math.max(0, opts.boxHeight - marginY * 2);
235
248
  let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
236
249
  let groups = buildGroups(nameLines);
237
- let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
250
+ let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
238
251
  for (let cap = requestedNameMaxLines - 1; cap >= 1 && contentSpan > availableHeight; cap--) {
239
252
  nameLines = wrapWords(opts.name, maxCharsName, cap);
240
253
  groups = buildGroups(nameLines);
241
- contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
254
+ contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
242
255
  }
243
256
  const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
244
257
  const firstY = Math.round(opts.boxY + margin + HALF_EXTENT_PRIMARY);
@@ -48,7 +48,9 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
48
48
  .join('\n');
49
49
  const nodeSvg = nodes
50
50
  .map((n) => {
51
- const entityId = n.id.slice(n.id.indexOf('_') + 1);
51
+ // Virtual Change nodes use a layout key such as `vchange_activity_<id>`;
52
+ // slicing at the first `_` would print `activity_<id>` on a Change.
53
+ const entityId = n.virtual ? '' : n.id.slice(n.id.indexOf('_') + 1);
52
54
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
53
55
  const specs = layoutCenteredEntityText({
54
56
  boxX: n.x,
@@ -61,7 +63,9 @@ export function renderFgcaBody(columns, nodes, edges, curvature, entryCurvature,
61
63
  idMaxLines: 1,
62
64
  });
63
65
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
64
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
66
+ const titleContent = n.virtual
67
+ ? escXml(n.label)
68
+ : `${escXml(n.label)} (${escXml(entityId)})`;
65
69
  const typeBadge = n.type && n.col === 'activity'
66
70
  ? `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 10}" text-anchor="end" dominant-baseline="hanging" font-size="10">${escXml(n.type)}</text>`
67
71
  : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transitrix/diagrams",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "type": "module",
5
5
  "description": "Shared rendering, validation, and pure mutations for Transitrix custom diagram formats.",
6
6
  "license": "MIT",
@@ -371,6 +371,7 @@ export function layoutFGCAPreview(
371
371
 
372
372
  // Synthesize virtual Change nodes for activities without a real Change.
373
373
  // Each virtual node is 1:1 with one activity (not shared across activities).
374
+ // The layout key embeds the activity id for uniqueness; it is not a display id.
374
375
  const virtualChanges: Array<{ id: string; activityId: string; goalId: string }> = [];
375
376
  for (const a of doc.activities) {
376
377
  if (a.goal_id != null && !coveredActivities.has(String(a.id))) {
@@ -165,7 +165,8 @@ export interface LayoutCenteredEntityOptions {
165
165
  boxHeight: number;
166
166
  name: string;
167
167
  type?: string;
168
- id: string;
168
+ /** Canonical entity id. Empty or omitted skips the id row (placeholder nodes). */
169
+ id?: string;
169
170
  nameMaxLines?: number;
170
171
  idMaxLines?: number;
171
172
  marginX?: number;
@@ -189,8 +190,15 @@ interface TextRowGroup {
189
190
  * to avoid overlap.
190
191
  */
191
192
  const HALF_EXTENT_PRIMARY = 6; // text-primary, 12px font
193
+ const HALF_EXTENT_SECONDARY = 6; // text-secondary, same size as name
192
194
  const HALF_EXTENT_ID = 5; // text-id, 10px font
193
195
 
196
+ function glyphHalfExtent(cls: string): number {
197
+ if (cls === 'text-id') return HALF_EXTENT_ID;
198
+ if (cls === 'text-secondary') return HALF_EXTENT_SECONDARY;
199
+ return HALF_EXTENT_PRIMARY;
200
+ }
201
+
194
202
  /** Place `groups`' lines starting at `startY`, advancing by each line's own height and gap. */
195
203
  function placeLines(groups: TextRowGroup[], startY: number): TextLineSpec[] {
196
204
  const out: TextLineSpec[] = [];
@@ -234,8 +242,11 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
234
242
  const maxCharsId = maxCharsForInnerWidth(innerW, CHAR_W_ID);
235
243
 
236
244
  const typeLine = opts.type?.trim() ? truncateLine(opts.type, maxCharsType) : undefined;
245
+ const idText = opts.id?.trim() ?? '';
237
246
  const idMaxLines = opts.idMaxLines ?? 1;
238
- const idLines = idMaxLines <= 1 ? [truncateId(opts.id, maxCharsId)] : wrapId(opts.id, maxCharsId, idMaxLines);
247
+ const idLines = idText
248
+ ? (idMaxLines <= 1 ? [truncateId(idText, maxCharsId)] : wrapId(idText, maxCharsId, idMaxLines))
249
+ : [];
239
250
 
240
251
  function buildGroups(nameLines: string[]): TextRowGroup[] {
241
252
  const groups: TextRowGroup[] = [
@@ -243,7 +254,7 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
243
254
  cls: 'text-primary',
244
255
  lines: nameLines,
245
256
  lineHeight: LINE_H_PRIMARY,
246
- gapAfter: typeLine ? ROW_GROUP_GAP : NAME_ID_GAP,
257
+ gapAfter: typeLine ? ROW_GROUP_GAP : idLines.length > 0 ? NAME_ID_GAP : 0,
247
258
  },
248
259
  ];
249
260
  if (typeLine) {
@@ -251,26 +262,28 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
251
262
  cls: 'text-secondary',
252
263
  lines: [typeLine],
253
264
  lineHeight: LINE_H_SECONDARY,
254
- gapAfter: NAME_ID_GAP,
265
+ gapAfter: idLines.length > 0 ? NAME_ID_GAP : 0,
266
+ });
267
+ }
268
+ if (idLines.length > 0) {
269
+ groups.push({
270
+ cls: 'text-id',
271
+ lines: idLines,
272
+ lineHeight: LINE_H_ID,
273
+ gapAfter: 0,
255
274
  });
256
275
  }
257
- groups.push({
258
- cls: 'text-id',
259
- lines: idLines,
260
- lineHeight: LINE_H_ID,
261
- gapAfter: 0,
262
- });
263
276
  return groups;
264
277
  }
265
278
 
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.
279
+ // groups[0] is always the name (text-primary). The last group is the id when
280
+ // one is present, otherwise type or name; edge padding uses that class.
268
281
  const requestedNameMaxLines = Math.max(1, opts.nameMaxLines ?? 2);
269
282
  const availableHeight = Math.max(0, opts.boxHeight - marginY * 2);
270
283
 
271
284
  let nameLines = wrapWords(opts.name, maxCharsName, requestedNameMaxLines);
272
285
  let groups = buildGroups(nameLines);
273
- let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
286
+ let contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
274
287
  for (
275
288
  let cap = requestedNameMaxLines - 1;
276
289
  cap >= 1 && contentSpan > availableHeight;
@@ -278,7 +291,7 @@ export function layoutCenteredEntityText(opts: LayoutCenteredEntityOptions): Tex
278
291
  ) {
279
292
  nameLines = wrapWords(opts.name, maxCharsName, cap);
280
293
  groups = buildGroups(nameLines);
281
- contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + HALF_EXTENT_ID;
294
+ contentSpan = centerSpan(groups) + HALF_EXTENT_PRIMARY + glyphHalfExtent(groups[groups.length - 1].cls);
282
295
  }
283
296
 
284
297
  const margin = Math.max(0, (opts.boxHeight - contentSpan) / 2);
@@ -82,7 +82,9 @@ export function renderFgcaBody(
82
82
 
83
83
  const nodeSvg = nodes
84
84
  .map((n) => {
85
- const entityId = n.id.slice(n.id.indexOf('_') + 1);
85
+ // Virtual Change nodes use a layout key such as `vchange_activity_<id>`;
86
+ // slicing at the first `_` would print `activity_<id>` on a Change.
87
+ const entityId = n.virtual ? '' : n.id.slice(n.id.indexOf('_') + 1);
86
88
  const nodeClass = n.virtual ? 'diagram-node layer-virtual' : `diagram-node layer-${n.col}`;
87
89
  const specs = layoutCenteredEntityText({
88
90
  boxX: n.x,
@@ -95,7 +97,9 @@ export function renderFgcaBody(
95
97
  idMaxLines: 1,
96
98
  });
97
99
  const textSvg = emitCenteredTextSvg(specs, n.x + nodeWidth / 2, escXml);
98
- const titleContent = `${escXml(n.label)} (${escXml(entityId)})`;
100
+ const titleContent = n.virtual
101
+ ? escXml(n.label)
102
+ : `${escXml(n.label)} (${escXml(entityId)})`;
99
103
  const typeBadge = n.type && n.col === 'activity'
100
104
  ? `<text class="text-id" x="${n.x + nodeWidth - 6}" y="${n.y + 10}" text-anchor="end" dominant-baseline="hanging" font-size="10">${escXml(n.type)}</text>`
101
105
  : '';