@diagrammo/dgmo 0.30.0 → 0.31.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.
Files changed (41) hide show
  1. package/README.md +21 -3
  2. package/dist/advanced.cjs +560 -269
  3. package/dist/advanced.d.cts +27 -2
  4. package/dist/advanced.d.ts +27 -2
  5. package/dist/advanced.js +559 -269
  6. package/dist/auto.cjs +558 -270
  7. package/dist/auto.js +93 -93
  8. package/dist/auto.mjs +558 -270
  9. package/dist/cli.cjs +144 -143
  10. package/dist/index.cjs +557 -269
  11. package/dist/index.js +557 -269
  12. package/package.json +1 -1
  13. package/src/advanced.ts +3 -0
  14. package/src/boxes-and-lines/layout-search.ts +214 -0
  15. package/src/boxes-and-lines/layout.ts +4 -0
  16. package/src/boxes-and-lines/parser.ts +78 -0
  17. package/src/boxes-and-lines/renderer.ts +57 -5
  18. package/src/boxes-and-lines/types.ts +9 -0
  19. package/src/c4/renderer.ts +7 -5
  20. package/src/chart-types.ts +2 -2
  21. package/src/class/renderer.ts +4 -2
  22. package/src/cli-banner.ts +107 -0
  23. package/src/cli.ts +13 -0
  24. package/src/colors.ts +22 -0
  25. package/src/er/renderer.ts +4 -2
  26. package/src/graph/flowchart-renderer.ts +4 -2
  27. package/src/graph/state-renderer.ts +4 -2
  28. package/src/infra/renderer.ts +8 -4
  29. package/src/journey-map/parser.ts +15 -1
  30. package/src/journey-map/renderer.ts +1 -1
  31. package/src/kanban/renderer.ts +1 -1
  32. package/src/map/renderer.ts +27 -14
  33. package/src/mindmap/renderer.ts +5 -3
  34. package/src/org/renderer.ts +67 -120
  35. package/src/palettes/color-utils.ts +7 -2
  36. package/src/pert/renderer.ts +13 -8
  37. package/src/raci/renderer.ts +1 -1
  38. package/src/sitemap/renderer.ts +35 -37
  39. package/src/utils/card.ts +183 -0
  40. package/src/utils/tag-groups.ts +10 -10
  41. package/src/utils/visual-conventions.ts +61 -0
package/src/colors.ts CHANGED
@@ -60,6 +60,28 @@ export const RECOGNIZED_COLOR_NAMES = Object.freeze([
60
60
  'white',
61
61
  ] as const);
62
62
 
63
+ /**
64
+ * The canonical order in which the categorical (non-neutral) color names are
65
+ * auto-assigned: tag/group swatches (`autoTagColorCycle`) and data-chart
66
+ * series colors (`getSeriesColors`) both derive their rotation from this list.
67
+ *
68
+ * Seeded with the RGB primaries (`red, green, blue`) for an unmistakable first
69
+ * three, then each subsequent hue is chosen to fill the widest remaining gap on
70
+ * the color wheel — maximizing contrast between adjacent swatches. Neutrals
71
+ * (`gray`/`black`/`white`) are intentionally excluded so auto-picked colors
72
+ * always read as distinct legend swatches.
73
+ */
74
+ export const CATEGORICAL_COLOR_ORDER = Object.freeze([
75
+ 'red',
76
+ 'green',
77
+ 'blue',
78
+ 'yellow',
79
+ 'teal',
80
+ 'purple',
81
+ 'orange',
82
+ 'cyan',
83
+ ] as const);
84
+
63
85
  /**
64
86
  * Returns true iff `name` is one of the 11 recognized DGMO color names.
65
87
  */
@@ -46,8 +46,10 @@ const MAX_SCALE = 3;
46
46
  const TABLE_FONT_SIZE = 13;
47
47
  const COLUMN_FONT_SIZE = 11;
48
48
  const EDGE_LABEL_FONT_SIZE = 11;
49
- const EDGE_STROKE_WIDTH = 1.5;
50
- const NODE_STROKE_WIDTH = 1.5;
49
+ import {
50
+ EDGE_STROKE_WIDTH,
51
+ NODE_STROKE_WIDTH,
52
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
51
53
  const MEMBER_LINE_HEIGHT = 18;
52
54
  const COMPARTMENT_PADDING_Y = 8;
53
55
  const MEMBER_PADDING_X = 10;
@@ -36,8 +36,10 @@ const DIAGRAM_PADDING = 20;
36
36
  const MAX_SCALE = 3;
37
37
  const NODE_FONT_SIZE = 13;
38
38
  const EDGE_LABEL_FONT_SIZE = 11;
39
- const EDGE_STROKE_WIDTH = 1.5;
40
- const NODE_STROKE_WIDTH = 1.5;
39
+ import {
40
+ EDGE_STROKE_WIDTH,
41
+ NODE_STROKE_WIDTH,
42
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
41
43
  const ARROWHEAD_W = 10;
42
44
  const ARROWHEAD_H = 7;
43
45
  const IO_SKEW = 15;
@@ -37,8 +37,10 @@ const MAX_SCALE = 3;
37
37
  const NODE_FONT_SIZE = 13;
38
38
  const EDGE_LABEL_FONT_SIZE = 11;
39
39
  const GROUP_LABEL_FONT_SIZE = 11;
40
- const EDGE_STROKE_WIDTH = 1.5;
41
- const NODE_STROKE_WIDTH = 1.5;
40
+ import {
41
+ EDGE_STROKE_WIDTH,
42
+ NODE_STROKE_WIDTH,
43
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
42
44
  const ARROWHEAD_W = 10;
43
45
  const ARROWHEAD_H = 7;
44
46
  const PSEUDOSTATE_RADIUS = 10;
@@ -57,20 +57,24 @@ import { ScaleContext } from '../utils/scaling';
57
57
  // ============================================================
58
58
 
59
59
  const NODE_FONT_SIZE = 13;
60
+ // Intentional deviation (conventions §1): infra uses denser meta rows
61
+ // (10px font / 14px line height) than the 11/16 default.
60
62
  const META_FONT_SIZE = 10;
61
63
  const META_LINE_HEIGHT = 14;
62
64
  const EDGE_LABEL_FONT_SIZE = 11;
63
65
  const GROUP_LABEL_FONT_SIZE = 14;
64
66
  const NODE_BORDER_RADIUS = 8;
65
- const EDGE_STROKE_WIDTH = 1.5;
66
- const NODE_STROKE_WIDTH = 1.5;
67
+ import {
68
+ EDGE_STROKE_WIDTH,
69
+ NODE_STROKE_WIDTH,
70
+ COLLAPSE_BAR_HEIGHT,
71
+ COLLAPSE_BAR_INSET,
72
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
67
73
  const OVERLOAD_STROKE_WIDTH = 3;
68
74
  const ROLE_DOT_RADIUS = 3;
69
75
  const NODE_HEADER_HEIGHT = 28;
70
76
  const NODE_SEPARATOR_GAP = 4;
71
77
  const NODE_PAD_BOTTOM = 10;
72
- const COLLAPSE_BAR_HEIGHT = 6;
73
- const COLLAPSE_BAR_INSET = 0;
74
78
 
75
79
  const LEGEND_FIXED_GAP = 16; // gap between fixed legend and scaled diagram — local, not shared
76
80
  const SPEED_BADGE_H_PAD = 5; // horizontal padding inside active speed badge
@@ -184,7 +184,21 @@ export function parseJourneyMap(
184
184
  }
185
185
  }
186
186
  } else {
187
- personaName = afterKeyword;
187
+ // Same-line form (pipes removed in 0.18.0): peel a trailing
188
+ // `color: <token>` off the rest-of-line persona name.
189
+ const colorMatch = afterKeyword.match(/^(.+?)\s+color:\s*(\S+)$/i);
190
+ if (colorMatch) {
191
+ personaName = colorMatch[1]!.trim();
192
+ personaColor =
193
+ resolveColorWithDiagnostic(
194
+ colorMatch[2]!,
195
+ lineNumber,
196
+ result.diagnostics,
197
+ palette
198
+ ) ?? undefined;
199
+ } else {
200
+ personaName = afterKeyword;
201
+ }
188
202
  }
189
203
 
190
204
  if (!personaName) {
@@ -52,7 +52,7 @@ export interface JourneyMapInteractiveOptions {
52
52
  // Match kanban styling constants
53
53
  const DIAGRAM_PADDING = 20;
54
54
  const PADDING = DIAGRAM_PADDING;
55
- const CARD_RADIUS = 6;
55
+ import { CARD_RADIUS } from '../utils/visual-conventions'; // shared (Story 111.1)
56
56
  const CARD_PADDING_X = 10;
57
57
  const CARD_PADDING_Y = 6;
58
58
  const CARD_HEADER_HEIGHT = 24;
@@ -55,7 +55,7 @@ const CARD_HEADER_HEIGHT = 24;
55
55
  const CARD_META_LINE_HEIGHT = 14;
56
56
  const CARD_SEPARATOR_GAP = 4;
57
57
  const CARD_GAP = 8;
58
- const CARD_RADIUS = 6;
58
+ import { CARD_RADIUS } from '../utils/visual-conventions'; // shared (Story 111.1)
59
59
  const CARD_PADDING_X = 10;
60
60
  const CARD_PADDING_Y = 6;
61
61
  const CARD_STROKE_WIDTH = 1.5;
@@ -219,6 +219,15 @@ function appendWaterLines(
219
219
  }
220
220
  }
221
221
 
222
+ // Per-render namespace for SVG def ids (clipPaths, masks, filters, markers, the
223
+ // `<use>`-shared coast path). SVG `url(#id)` / `href="#id"` resolve document-
224
+ // globally to the FIRST matching id, so when several maps are inlined on one
225
+ // page (the docs gallery, an MDX page) shared constant ids made every later map
226
+ // reference the first map's defs — its coast `<use>` ghosted through. A
227
+ // monotonic per-render suffix makes every render's ids unique. NOT reset between
228
+ // renders, so re-renders (legend flips) and same-page siblings never collide.
229
+ let mapInstanceCounter = 0;
230
+
222
231
  /** Render a resolved map into `container` (d3-selection appends an `<svg>`). */
223
232
  export function renderMap(
224
233
  container: HTMLDivElement,
@@ -283,6 +292,10 @@ export function renderMap(
283
292
  // arrowhead up to a giant wedge. The size grows gently with the line width —
284
293
  // enough to stay distinct from the stroke — but is firmly capped.
285
294
  const defs = svg.append('defs');
295
+ // Namespace every def id minted below so multiple maps on one page don't share
296
+ // `url(#…)` targets (see mapInstanceCounter).
297
+ const uid = mapInstanceCounter++;
298
+ const nid = (base: string): string => `${base}__m${uid}`;
286
299
  // Dampened: ~8px at the thinnest leg, easing toward a 15px cap as legs widen.
287
300
  const arrowSize = (w: number): number => Math.min(15, 7 + w * 0.95);
288
301
 
@@ -356,8 +369,8 @@ export function renderMap(
356
369
  // sub-pixel + low-contrast so the texture stays faint. Decorative — no data attrs.
357
370
  if (layout.relief.length && layout.reliefHatch) {
358
371
  const h = layout.reliefHatch;
359
- const rangeClipId = 'dgmo-relief-clip';
360
- const landClipId = 'dgmo-relief-land';
372
+ const rangeClipId = nid('dgmo-relief-clip');
373
+ const landClipId = nid('dgmo-relief-land');
361
374
  const rangeClip = defs.append('clipPath').attr('id', rangeClipId);
362
375
  for (const s of layout.relief) rangeClip.append('path').attr('d', s.d);
363
376
  const landClip = defs.append('clipPath').attr('id', landClipId);
@@ -402,7 +415,7 @@ export function renderMap(
402
415
  // §24B.2, ADR-1/3/6.
403
416
  if (layout.coastlineStyle) {
404
417
  const cs = layout.coastlineStyle;
405
- const maskId = 'dgmo-map-water-mask';
418
+ const maskId = nid('dgmo-map-water-mask');
406
419
  const mask = defs
407
420
  .append('mask')
408
421
  .attr('id', maskId)
@@ -472,7 +485,7 @@ export function renderMap(
472
485
  appendWaterLines(
473
486
  gWater,
474
487
  defs,
475
- 'dgmo-map-coast',
488
+ nid('dgmo-map-coast'),
476
489
  // Pass the canvas frame so edges collinear with it (the antimeridian on a
477
490
  // world map, regional clipExtent cuts) don't get ringed as fake coast —
478
491
  // land runs cleanly to the render-area edge.
@@ -565,7 +578,7 @@ export function renderMap(
565
578
  (l) => l.poiId !== undefined && !l.hidden
566
579
  );
567
580
  if (poiLabels.length) {
568
- const patchBlurId = 'dgmo-map-label-patch-blur';
581
+ const patchBlurId = nid('dgmo-map-label-patch-blur');
569
582
  // Soft falloff so the patch dissolves into the surrounding basemap instead of
570
583
  // ending on a hard edge. Tuned at the 11px label font. One shared filter for
571
584
  // every patch group.
@@ -690,7 +703,7 @@ export function renderMap(
690
703
  // Always-on patch for labels that are visible at rest (non-cluster members).
691
704
  buildPatch(
692
705
  poiLabels.filter((l) => l.clusterMember === undefined),
693
- 'dgmo-map-label-patch'
706
+ nid('dgmo-map-label-patch')
694
707
  );
695
708
  // Per-cluster patches, hidden until the fan expands.
696
709
  const byCluster = new Map<string, typeof poiLabels>();
@@ -705,11 +718,11 @@ export function renderMap(
705
718
  // data-cluster-deco attribute instead.
706
719
  let ci = 0;
707
720
  for (const [cid, labs] of byCluster)
708
- buildPatch(labs, `dgmo-map-label-patch-c${ci++}`, cid);
721
+ buildPatch(labs, nid(`dgmo-map-label-patch-c${ci++}`), cid);
709
722
  } else {
710
723
  // Export / `no-cluster-pois`: fan is permanently open → one always-on patch
711
724
  // over every POI label.
712
- buildPatch(poiLabels, 'dgmo-map-label-patch');
725
+ buildPatch(poiLabels, nid('dgmo-map-label-patch'));
713
726
  }
714
727
  }
715
728
 
@@ -734,7 +747,7 @@ export function renderMap(
734
747
  // Neighbour land (Canada beside Alaska) clipped to this box, behind the
735
748
  // state — so a land border reads as land rather than sprouting coast rings.
736
749
  if (box.contextLand) {
737
- const clipId = `dgmo-map-inset-clip-${bi}`;
750
+ const clipId = nid(`dgmo-map-inset-clip-${bi}`);
738
751
  defs.append('clipPath').attr('id', clipId).append('path').attr('d', d);
739
752
  insetG
740
753
  .append('path')
@@ -751,7 +764,7 @@ export function renderMap(
751
764
  // same way. Inside the inset group so it composites over the box fills.
752
765
  if (layout.coastlineStyle) {
753
766
  const cs = layout.coastlineStyle;
754
- const maskId = 'dgmo-map-inset-water-mask';
767
+ const maskId = nid('dgmo-map-inset-water-mask');
755
768
  const mask = defs
756
769
  .append('mask')
757
770
  .attr('id', maskId)
@@ -786,7 +799,7 @@ export function renderMap(
786
799
  .append('path')
787
800
  .attr('d', box.contextLand.d)
788
801
  .attr('fill', 'black')
789
- .attr('clip-path', `url(#dgmo-map-inset-clip-${bi})`);
802
+ .attr('clip-path', `url(#${nid(`dgmo-map-inset-clip-${bi}`)})`);
790
803
  });
791
804
  for (const r of layout.insetRegions)
792
805
  if (r.id !== 'lake')
@@ -798,7 +811,7 @@ export function renderMap(
798
811
  // which side reads as water, but SVG strokes still extend stroke-width/2
799
812
  // past their path, so without this the seaward rings bleed over the box
800
813
  // border. Union of all inset quads = one clipPath shared by the group.
801
- const clipId = 'dgmo-map-inset-water-clip';
814
+ const clipId = nid('dgmo-map-inset-water-clip');
802
815
  const clip = defs.append('clipPath').attr('id', clipId);
803
816
  for (const box of layout.insets) {
804
817
  const d =
@@ -820,7 +833,7 @@ export function renderMap(
820
833
  appendWaterLines(
821
834
  gInsetWater,
822
835
  defs,
823
- 'dgmo-map-inset-coast',
836
+ nid('dgmo-map-inset-coast'),
824
837
  coastlineOuterRings(layout.insetRegions, cs.minExtent),
825
838
  cs,
826
839
  layout.background
@@ -895,7 +908,7 @@ export function renderMap(
895
908
  // stroke is enough for the line widths legs use.
896
909
  wireSync(p, leg.lineNumber);
897
910
  if (leg.arrow) {
898
- const id = `dgmo-map-arrow-${i}`;
911
+ const id = nid(`dgmo-map-arrow-${i}`);
899
912
  const s = arrowSize(leg.width);
900
913
  defs
901
914
  .append('marker')
@@ -35,9 +35,11 @@ const LABEL_LINE_HEIGHT = 18;
35
35
  const DESC_LINE_HEIGHT = 14;
36
36
  const NODE_RADIUS = 6;
37
37
  const ROOT_STROKE_WIDTH = 2.5;
38
- const NODE_STROKE_WIDTH = 1.5;
39
- const EDGE_STROKE_WIDTH = 1.5;
40
- const COLLAPSE_BAR_HEIGHT = 6;
38
+ import {
39
+ NODE_STROKE_WIDTH,
40
+ EDGE_STROKE_WIDTH,
41
+ COLLAPSE_BAR_HEIGHT,
42
+ } from '../utils/visual-conventions'; // shared (Story 111.1)
41
43
 
42
44
  function nodeFill(
43
45
  palette: PaletteColors,
@@ -26,6 +26,7 @@ import {
26
26
  EYE_CLOSED_PATH,
27
27
  } from '../utils/legend-constants';
28
28
  import { renderIntegratedLegend } from '../utils/legend-integration';
29
+ import { renderNodeCard, renderCollapseBar } from '../utils/card';
29
30
  import { measureText } from '../utils/text-measure';
30
31
  import { getMaxLegendReservedHeight } from '../utils/legend-layout';
31
32
  import type { LegendConfig } from '../utils/legend-types';
@@ -38,23 +39,25 @@ const DIAGRAM_PADDING = 20;
38
39
  const MAX_SCALE = 3;
39
40
  import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
40
41
  const TITLE_HEIGHT = 30;
41
- const LABEL_FONT_SIZE = 13;
42
- const META_FONT_SIZE = 11;
43
- const META_LINE_HEIGHT = 16;
44
- const HEADER_HEIGHT = 28;
45
- const SEPARATOR_GAP = 6;
46
- const EDGE_STROKE_WIDTH = 1.5;
47
- const NODE_STROKE_WIDTH = 1.5;
48
- const CARD_RADIUS = 6;
49
- const CONTAINER_RADIUS = 8;
50
- const CONTAINER_LABEL_FONT_SIZE = 13;
51
- const CONTAINER_META_FONT_SIZE = 11;
52
- const CONTAINER_META_LINE_HEIGHT = 16;
53
- const CONTAINER_HEADER_HEIGHT = 28;
54
-
55
- // Collapsed-node accent bar
56
- const COLLAPSE_BAR_HEIGHT = 6;
57
- const COLLAPSE_BAR_INSET = 0;
42
+ // Shared card / group / collapse constants (Story 111.1). org matches every
43
+ // convention default, so it imports the full set.
44
+ import {
45
+ LABEL_FONT_SIZE,
46
+ META_FONT_SIZE,
47
+ META_LINE_HEIGHT,
48
+ HEADER_HEIGHT,
49
+ SEPARATOR_GAP,
50
+ EDGE_STROKE_WIDTH,
51
+ NODE_STROKE_WIDTH,
52
+ CARD_RADIUS,
53
+ CONTAINER_RADIUS,
54
+ CONTAINER_LABEL_FONT_SIZE,
55
+ CONTAINER_META_FONT_SIZE,
56
+ CONTAINER_META_LINE_HEIGHT,
57
+ CONTAINER_HEADER_HEIGHT,
58
+ COLLAPSE_BAR_HEIGHT,
59
+ COLLAPSE_BAR_INSET,
60
+ } from '../utils/visual-conventions';
58
61
 
59
62
  // Ancestor breadcrumb trail (focus mode)
60
63
  const ANCESTOR_DOT_R = 4;
@@ -360,21 +363,16 @@ export function renderOrg(
360
363
  }
361
364
 
362
365
  if (!exportDims && c.hiddenCount && c.hiddenCount > 0) {
363
- const clipId = `clip-${c.nodeId}`;
364
- cG.append('clipPath')
365
- .attr('id', clipId)
366
- .append('rect')
367
- .attr('width', c.width)
368
- .attr('height', c.height)
369
- .attr('rx', sContainerRadius);
370
- cG.append('rect')
371
- .attr('x', sCollapseBarInset)
372
- .attr('y', c.height - sCollapseBarHeight)
373
- .attr('width', c.width - sCollapseBarInset * 2)
374
- .attr('height', sCollapseBarHeight)
375
- .attr('fill', containerStroke(palette, colorOff ? undefined : c.color))
376
- .attr('clip-path', `url(#${clipId})`)
377
- .attr('class', 'org-collapse-bar');
366
+ renderCollapseBar(cG, {
367
+ width: c.width,
368
+ height: c.height,
369
+ barHeight: sCollapseBarHeight,
370
+ inset: sCollapseBarInset,
371
+ rx: sContainerRadius,
372
+ fill: containerStroke(palette, colorOff ? undefined : c.color),
373
+ clipId: `clip-${c.nodeId}`,
374
+ className: 'org-collapse-bar',
375
+ });
378
376
  }
379
377
 
380
378
  // Focus icon (hover-reveal, interactive only) — for non-root containers with children
@@ -489,103 +487,52 @@ export function renderOrg(
489
487
  );
490
488
  const stroke = nodeStroke(palette, colorOff ? undefined : node.color);
491
489
 
492
- const rect = nodeG
493
- .append('rect')
494
- .attr('x', 0)
495
- .attr('y', 0)
496
- .attr('width', node.width)
497
- .attr('height', node.height)
498
- .attr('rx', sCardRadius)
499
- .attr('fill', fill)
500
- .attr('stroke', stroke)
501
- .attr('stroke-width', sNodeStrokeWidth);
502
-
503
- if (node.isContainer) {
504
- rect.attr('stroke-dasharray', '6 3');
505
- }
506
-
507
490
  const labelColor = contrastText(
508
491
  fill,
509
492
  palette.textOnFillLight,
510
493
  palette.textOnFillDark
511
494
  );
512
- nodeG
513
- .append('text')
514
- .attr('x', node.width / 2)
515
- .attr('y', sHeaderHeight / 2 + sLabelFontSize / 2 - 2)
516
- .attr('text-anchor', 'middle')
517
- .attr('fill', labelColor)
518
- .attr('font-size', sLabelFontSize)
519
- .attr('font-weight', 'bold')
520
- .text(node.label);
521
495
 
522
496
  const metaEntries = Object.entries(node.metadata);
523
- if (metaEntries.length > 0) {
524
- nodeG
525
- .append('line')
526
- .attr('x1', 0)
527
- .attr('y1', sHeaderHeight)
528
- .attr('x2', node.width)
529
- .attr('y2', sHeaderHeight)
530
- .attr('stroke', solid ? labelColor : stroke)
531
- .attr('stroke-opacity', 0.3)
532
- .attr('stroke-width', 1);
533
-
534
- const metaDisplayKeys = metaEntries.map(
535
- ([k]) => displayNames.get(k) ?? k
536
- );
537
- const maxKeyWidth = Math.max(
538
- ...metaDisplayKeys.map((k) => measureText(`${k}: `, sMetaFontSize))
539
- );
540
- const valueX = 10 + maxKeyWidth;
541
-
542
- const metaStartY = sHeaderHeight + sSeparatorGap + sMetaFontSize;
543
- for (let i = 0; i < metaEntries.length; i++) {
544
- const [, value] = metaEntries[i]!;
545
- const displayKey = metaDisplayKeys[i]!;
546
- const rowY = metaStartY + i * sMetaLineHeight;
547
-
548
- nodeG
549
- .append('text')
550
- .attr('x', 10)
551
- .attr('y', rowY)
552
- .attr('fill', labelColor)
553
- .attr('font-size', sMetaFontSize)
554
- .text(`${displayKey}: `);
555
-
556
- nodeG
557
- .append('text')
558
- .attr('x', valueX)
559
- .attr('y', rowY)
560
- .attr('fill', labelColor)
561
- .attr('font-size', sMetaFontSize)
562
- .text(value);
563
- }
564
- }
497
+ renderNodeCard(nodeG, {
498
+ width: node.width,
499
+ height: node.height,
500
+ rx: sCardRadius,
501
+ fill,
502
+ stroke,
503
+ strokeWidth: sNodeStrokeWidth,
504
+ ...(node.isContainer && { dashed: true }),
505
+ label: node.label,
506
+ labelColor,
507
+ labelFontSize: sLabelFontSize,
508
+ headerHeight: sHeaderHeight,
509
+ ...(metaEntries.length > 0 && {
510
+ meta: {
511
+ rows: metaEntries.map(
512
+ ([k, value]) => [displayNames.get(k) ?? k, value] as const
513
+ ),
514
+ fontSize: sMetaFontSize,
515
+ lineHeight: sMetaLineHeight,
516
+ separatorGap: sSeparatorGap,
517
+ separatorColor: solid ? labelColor : stroke,
518
+ textColor: labelColor,
519
+ },
520
+ }),
521
+ });
565
522
 
566
523
  if (!exportDims && node.hiddenCount && node.hiddenCount > 0) {
567
- const clipId = `clip-${node.id}`;
568
- nodeG
569
- .append('clipPath')
570
- .attr('id', clipId)
571
- .append('rect')
572
- .attr('width', node.width)
573
- .attr('height', node.height)
574
- .attr('rx', sCardRadius);
575
- nodeG
576
- .append('rect')
577
- .attr('x', sCollapseBarInset)
578
- .attr('y', node.height - sCollapseBarHeight)
579
- .attr('width', node.width - sCollapseBarInset * 2)
580
- .attr('height', sCollapseBarHeight)
581
- .attr(
582
- 'fill',
583
- solid
584
- ? labelColor
585
- : nodeStroke(palette, colorOff ? undefined : node.color)
586
- )
587
- .attr('clip-path', `url(#${clipId})`)
588
- .attr('class', 'org-collapse-bar');
524
+ renderCollapseBar(nodeG, {
525
+ width: node.width,
526
+ height: node.height,
527
+ barHeight: sCollapseBarHeight,
528
+ inset: sCollapseBarInset,
529
+ rx: sCardRadius,
530
+ fill: solid
531
+ ? labelColor
532
+ : nodeStroke(palette, colorOff ? undefined : node.color),
533
+ clipId: `clip-${node.id}`,
534
+ className: 'org-collapse-bar',
535
+ });
589
536
  }
590
537
 
591
538
  // Focus icon (hover-reveal, interactive only) — for non-root nodes with children
@@ -1,3 +1,4 @@
1
+ import { CATEGORICAL_COLOR_ORDER } from '../colors';
1
2
  import type { PaletteColors } from './types';
2
3
 
3
4
  // ============================================================
@@ -318,10 +319,14 @@ export function shapeFill(
318
319
  // Series Colors
319
320
  // ============================================================
320
321
 
321
- /** Derive the 8-color series rotation from a palette's named colors. */
322
+ /**
323
+ * Derive the 8-color series rotation from a palette's named colors, in the
324
+ * shared {@link CATEGORICAL_COLOR_ORDER} (RGB-seeded, max-contrast). Tag
325
+ * swatches and chart series colors thus share one canonical rotation.
326
+ */
322
327
  export function getSeriesColors(palette: PaletteColors): string[] {
323
328
  const c = palette.colors;
324
- return [c.blue, c.green, c.yellow, c.orange, c.purple, c.red, c.teal, c.cyan];
329
+ return CATEGORICAL_COLOR_ORDER.map((name) => c[name]!);
325
330
  }
326
331
 
327
332
  /**
@@ -87,7 +87,16 @@ const NODE_CELL_FONT_SIZE = 11;
87
87
  // row holds the name and is taller than the corner cells so the name
88
88
  // reads as the primary label (mirroring textbook proportions).
89
89
  const NODE_RADIUS = 6;
90
- const NODE_STROKE_WIDTH = 1.5;
90
+ // Shared card / group / collapse constants (Story 111.1). The explanatory
91
+ // comments below stay with the renderer; the values now live in one module.
92
+ import {
93
+ NODE_STROKE_WIDTH,
94
+ EDGE_STROKE_WIDTH,
95
+ CONTAINER_RADIUS,
96
+ CONTAINER_LABEL_FONT_SIZE,
97
+ CONTAINER_HEADER_HEIGHT,
98
+ COLLAPSE_BAR_HEIGHT,
99
+ } from '../utils/visual-conventions';
91
100
 
92
101
  // Analysis-block chrome (Summary / Activity Risk / Completion / Field
93
102
  // labels). These sit BELOW the diagram and shouldn't compete with it
@@ -113,18 +122,14 @@ const NODE_BOTTOM_ROW_HEIGHT = 26;
113
122
  // stroke and a matching red arrowhead because the critical path is the
114
123
  // central concept of a PERT chart, and a binary `data-critical` attr
115
124
  // alone left it visually invisible to readers.
116
- const EDGE_STROKE_WIDTH = 1.5;
117
125
  const ARROWHEAD_W = 10;
118
126
  const ARROWHEAD_H = 7;
119
127
  // Group-rect treatment per §2: neutral surface fill on textMuted stroke,
120
128
  // solid border, rx=8, top-center 13pt 'bold' label inside a reserved
121
129
  // 28px header band — exactly matching org's container recipe.
122
- const CONTAINER_RADIUS = 8;
123
- const CONTAINER_LABEL_FONT_SIZE = 13;
124
- const CONTAINER_HEADER_HEIGHT = 28;
125
- // Collapse-bar height — see conventions doc §3 Pattern A/B (matches
126
- // org's `COLLAPSE_BAR_HEIGHT`). Universal "this is collapsed" signal.
127
- const COLLAPSE_BAR_HEIGHT = 6;
130
+ // CONTAINER_RADIUS/LABEL_FONT_SIZE/HEADER_HEIGHT + COLLAPSE_BAR_HEIGHT now
131
+ // imported from utils/visual-conventions (Story 111.1). Group-rect treatment
132
+ // per §2; collapse-bar height matches org per §3 Pattern A/B.
128
133
  // Always-on fade applied to bottom-20% (by duration) activity nodes
129
134
  // so the eye is drawn to the longer, schedule-dominating work first.
130
135
  // Less aggressive than FADE_OPACITY because these cards still need to
@@ -267,7 +267,7 @@ const TINT_PCT = 25;
267
267
  * kanban `CARD_RADIUS = 6`). Keeps RACI markers visually consistent
268
268
  * with nodes elsewhere in the diagram language.
269
269
  */
270
- const NODE_STROKE_WIDTH = 1.5;
270
+ import { NODE_STROKE_WIDTH } from '../utils/visual-conventions'; // shared (Story 111.1)
271
271
  const NODE_RADIUS = 6;
272
272
 
273
273
  /**