@nowline/layout 0.2.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 (183) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +103 -0
  3. package/dist/band-scale.d.ts +56 -0
  4. package/dist/band-scale.d.ts.map +1 -0
  5. package/dist/band-scale.js +86 -0
  6. package/dist/band-scale.js.map +1 -0
  7. package/dist/calendar.d.ts +79 -0
  8. package/dist/calendar.d.ts.map +1 -0
  9. package/dist/calendar.js +210 -0
  10. package/dist/calendar.js.map +1 -0
  11. package/dist/capacity.d.ts +72 -0
  12. package/dist/capacity.d.ts.map +1 -0
  13. package/dist/capacity.js +163 -0
  14. package/dist/capacity.js.map +1 -0
  15. package/dist/dsl-utils.d.ts +5 -0
  16. package/dist/dsl-utils.d.ts.map +1 -0
  17. package/dist/dsl-utils.js +28 -0
  18. package/dist/dsl-utils.js.map +1 -0
  19. package/dist/edge-routing.d.ts +89 -0
  20. package/dist/edge-routing.d.ts.map +1 -0
  21. package/dist/edge-routing.js +435 -0
  22. package/dist/edge-routing.js.map +1 -0
  23. package/dist/frame-tab-geometry.d.ts +78 -0
  24. package/dist/frame-tab-geometry.d.ts.map +1 -0
  25. package/dist/frame-tab-geometry.js +115 -0
  26. package/dist/frame-tab-geometry.js.map +1 -0
  27. package/dist/header-card-geometry.d.ts +29 -0
  28. package/dist/header-card-geometry.d.ts.map +1 -0
  29. package/dist/header-card-geometry.js +41 -0
  30. package/dist/header-card-geometry.js.map +1 -0
  31. package/dist/i18n.d.ts +48 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +114 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/include-chrome-geometry.d.ts +86 -0
  36. package/dist/include-chrome-geometry.d.ts.map +1 -0
  37. package/dist/include-chrome-geometry.js +104 -0
  38. package/dist/include-chrome-geometry.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +10 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/item-bar-geometry.d.ts +127 -0
  44. package/dist/item-bar-geometry.d.ts.map +1 -0
  45. package/dist/item-bar-geometry.js +173 -0
  46. package/dist/item-bar-geometry.js.map +1 -0
  47. package/dist/lane-utilization.d.ts +90 -0
  48. package/dist/lane-utilization.d.ts.map +1 -0
  49. package/dist/lane-utilization.js +206 -0
  50. package/dist/lane-utilization.js.map +1 -0
  51. package/dist/layout-context.d.ts +143 -0
  52. package/dist/layout-context.d.ts.map +1 -0
  53. package/dist/layout-context.js +8 -0
  54. package/dist/layout-context.js.map +1 -0
  55. package/dist/layout.d.ts +18 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +1213 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/nodes/anchor-node.d.ts +16 -0
  60. package/dist/nodes/anchor-node.d.ts.map +1 -0
  61. package/dist/nodes/anchor-node.js +68 -0
  62. package/dist/nodes/anchor-node.js.map +1 -0
  63. package/dist/nodes/footnote-node.d.ts +10 -0
  64. package/dist/nodes/footnote-node.d.ts.map +1 -0
  65. package/dist/nodes/footnote-node.js +41 -0
  66. package/dist/nodes/footnote-node.js.map +1 -0
  67. package/dist/nodes/group-node.d.ts +29 -0
  68. package/dist/nodes/group-node.d.ts.map +1 -0
  69. package/dist/nodes/group-node.js +187 -0
  70. package/dist/nodes/group-node.js.map +1 -0
  71. package/dist/nodes/include-node.d.ts +16 -0
  72. package/dist/nodes/include-node.d.ts.map +1 -0
  73. package/dist/nodes/include-node.js +117 -0
  74. package/dist/nodes/include-node.js.map +1 -0
  75. package/dist/nodes/item-node.d.ts +51 -0
  76. package/dist/nodes/item-node.d.ts.map +1 -0
  77. package/dist/nodes/item-node.js +108 -0
  78. package/dist/nodes/item-node.js.map +1 -0
  79. package/dist/nodes/marker-geometry.d.ts +22 -0
  80. package/dist/nodes/marker-geometry.d.ts.map +1 -0
  81. package/dist/nodes/marker-geometry.js +38 -0
  82. package/dist/nodes/marker-geometry.js.map +1 -0
  83. package/dist/nodes/milestone-node.d.ts +48 -0
  84. package/dist/nodes/milestone-node.d.ts.map +1 -0
  85. package/dist/nodes/milestone-node.js +210 -0
  86. package/dist/nodes/milestone-node.js.map +1 -0
  87. package/dist/nodes/parallel-node.d.ts +21 -0
  88. package/dist/nodes/parallel-node.d.ts.map +1 -0
  89. package/dist/nodes/parallel-node.js +80 -0
  90. package/dist/nodes/parallel-node.js.map +1 -0
  91. package/dist/nodes/roadmap-node.d.ts +76 -0
  92. package/dist/nodes/roadmap-node.d.ts.map +1 -0
  93. package/dist/nodes/roadmap-node.js +788 -0
  94. package/dist/nodes/roadmap-node.js.map +1 -0
  95. package/dist/nodes/swimlane-node.d.ts +38 -0
  96. package/dist/nodes/swimlane-node.d.ts.map +1 -0
  97. package/dist/nodes/swimlane-node.js +308 -0
  98. package/dist/nodes/swimlane-node.js.map +1 -0
  99. package/dist/renderable.d.ts +44 -0
  100. package/dist/renderable.d.ts.map +1 -0
  101. package/dist/renderable.js +21 -0
  102. package/dist/renderable.js.map +1 -0
  103. package/dist/row-packer.d.ts +125 -0
  104. package/dist/row-packer.d.ts.map +1 -0
  105. package/dist/row-packer.js +169 -0
  106. package/dist/row-packer.js.map +1 -0
  107. package/dist/style-resolution.d.ts +14 -0
  108. package/dist/style-resolution.d.ts.map +1 -0
  109. package/dist/style-resolution.js +191 -0
  110. package/dist/style-resolution.js.map +1 -0
  111. package/dist/themes/dark.d.ts +4 -0
  112. package/dist/themes/dark.d.ts.map +1 -0
  113. package/dist/themes/dark.js +241 -0
  114. package/dist/themes/dark.js.map +1 -0
  115. package/dist/themes/index.d.ts +15 -0
  116. package/dist/themes/index.d.ts.map +1 -0
  117. package/dist/themes/index.js +30 -0
  118. package/dist/themes/index.js.map +1 -0
  119. package/dist/themes/light.d.ts +4 -0
  120. package/dist/themes/light.d.ts.map +1 -0
  121. package/dist/themes/light.js +248 -0
  122. package/dist/themes/light.js.map +1 -0
  123. package/dist/themes/shape.d.ts +194 -0
  124. package/dist/themes/shape.d.ts.map +1 -0
  125. package/dist/themes/shape.js +6 -0
  126. package/dist/themes/shape.js.map +1 -0
  127. package/dist/themes/shared.d.ts +145 -0
  128. package/dist/themes/shared.d.ts.map +1 -0
  129. package/dist/themes/shared.js +310 -0
  130. package/dist/themes/shared.js.map +1 -0
  131. package/dist/time-scale.d.ts +39 -0
  132. package/dist/time-scale.d.ts.map +1 -0
  133. package/dist/time-scale.js +62 -0
  134. package/dist/time-scale.js.map +1 -0
  135. package/dist/types.d.ts +483 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +6 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/view-preset.d.ts +23 -0
  140. package/dist/view-preset.d.ts.map +1 -0
  141. package/dist/view-preset.js +146 -0
  142. package/dist/view-preset.js.map +1 -0
  143. package/dist/working-calendar.d.ts +14 -0
  144. package/dist/working-calendar.d.ts.map +1 -0
  145. package/dist/working-calendar.js +74 -0
  146. package/dist/working-calendar.js.map +1 -0
  147. package/package.json +37 -0
  148. package/src/band-scale.ts +115 -0
  149. package/src/calendar.ts +244 -0
  150. package/src/capacity.ts +191 -0
  151. package/src/dsl-utils.ts +30 -0
  152. package/src/edge-routing.ts +550 -0
  153. package/src/frame-tab-geometry.ts +165 -0
  154. package/src/header-card-geometry.ts +48 -0
  155. package/src/i18n.ts +124 -0
  156. package/src/include-chrome-geometry.ts +156 -0
  157. package/src/index.ts +116 -0
  158. package/src/item-bar-geometry.ts +222 -0
  159. package/src/lane-utilization.ts +259 -0
  160. package/src/layout-context.ts +182 -0
  161. package/src/layout.ts +1446 -0
  162. package/src/nodes/anchor-node.ts +77 -0
  163. package/src/nodes/footnote-node.ts +60 -0
  164. package/src/nodes/group-node.ts +252 -0
  165. package/src/nodes/include-node.ts +168 -0
  166. package/src/nodes/item-node.ts +171 -0
  167. package/src/nodes/marker-geometry.ts +43 -0
  168. package/src/nodes/milestone-node.ts +263 -0
  169. package/src/nodes/parallel-node.ts +101 -0
  170. package/src/nodes/roadmap-node.ts +957 -0
  171. package/src/nodes/swimlane-node.ts +423 -0
  172. package/src/renderable.ts +68 -0
  173. package/src/row-packer.ts +271 -0
  174. package/src/style-resolution.ts +243 -0
  175. package/src/themes/dark.ts +244 -0
  176. package/src/themes/index.ts +36 -0
  177. package/src/themes/light.ts +251 -0
  178. package/src/themes/shape.ts +230 -0
  179. package/src/themes/shared.ts +369 -0
  180. package/src/time-scale.ts +78 -0
  181. package/src/types.ts +607 -0
  182. package/src/view-preset.ts +180 -0
  183. package/src/working-calendar.ts +91 -0
@@ -0,0 +1,171 @@
1
+ // ItemNode — first Renderable entity in the m2.5c port. Owns the
2
+ // geometry of a single item bar: its visual box (insets + height) and
3
+ // its caption x (inside vs. spilled past the bar).
4
+ //
5
+ // Constructed from already-resolved inputs (start/end x, status,
6
+ // remaining, meta text) so this node stays small and pure. The
7
+ // dependency-resolution logic in layout.ts (after/before chains,
8
+ // cursor.x, footnote indexing) remains where it is for now; m2.5c's
9
+ // remaining work is to migrate the rest into sibling node files.
10
+ //
11
+ // `measure(ctx)` returns the bar's intrinsic size. `place(origin,
12
+ // ctx)` returns a `PositionedItem`-shaped fragment with the box, row,
13
+ // and `textX`. The shape is byte-stable with the legacy sequenceItem
14
+ // arithmetic when fed the same inputs.
15
+
16
+ import { ITEM_LINK_ICON_INSET_PX, ITEM_LINK_ICON_TILE_SIZE_PX } from '../item-bar-geometry.js';
17
+ import type {
18
+ IntrinsicSize,
19
+ MeasureContext,
20
+ PlaceContext,
21
+ Point,
22
+ Renderable,
23
+ } from '../renderable.js';
24
+ import { ITEM_INSET_PX, MIN_ITEM_WIDTH } from '../themes/shared.js';
25
+ import type { BoundingBox } from '../types.js';
26
+
27
+ /**
28
+ * Inner padding applied on each side of the title text — the bar's
29
+ * inner-padded text area is `box.width - 2 * TEXT_INSET_PX` wide.
30
+ * Text spills past the bar when either the title or the meta line
31
+ * exceeds that area.
32
+ */
33
+ const TEXT_INSET_PX = 12;
34
+
35
+ /**
36
+ * Gap (px) between the bar's right edge and overflow text. Smaller
37
+ * than `TEXT_INSET_PX` so the text reads as belonging to this bar —
38
+ * adjacent bars are at least `2 * ITEM_INSET_PX = 12` away, so the
39
+ * text still has a clear visual home.
40
+ */
41
+ const TEXT_OUTSIDE_GAP_PX = 4;
42
+
43
+ const TITLE_FONT_SIZE_PX = 13;
44
+ const META_FONT_SIZE_PX = 11;
45
+
46
+ export interface ItemNodeInput {
47
+ id: string;
48
+ title: string;
49
+ /** Logical left x of the column the bar lives in. */
50
+ logicalLeftX: number;
51
+ /** Logical right x of the column the bar lives in. */
52
+ logicalRightX: number;
53
+ /** Caption text shown under the title (e.g. "1w - 50% remaining"). */
54
+ metaText?: string;
55
+ /**
56
+ * Extra horizontal width (px) appended to the meta line for spill
57
+ * detection — covers content the renderer paints after `metaText` but
58
+ * that the layout assembles as a structured trailing element rather
59
+ * than a string (currently: the capacity suffix). The layout adds this
60
+ * to the meta-line's measured width before deciding whether the
61
+ * caption block fits inside the bar.
62
+ *
63
+ * Defaults to 0. Title spill checks ignore this value (the suffix
64
+ * never renders on the title line).
65
+ */
66
+ metaTrailingWidth?: number;
67
+ /**
68
+ * True when the bar shows a link icon in its upper-left corner.
69
+ * Caption text indents past the icon column so the title doesn't
70
+ * collide with the icon.
71
+ */
72
+ hasLinkIcon?: boolean;
73
+ }
74
+
75
+ export interface PlacedItemGeometry {
76
+ id: string;
77
+ box: BoundingBox;
78
+ /**
79
+ * X for the title/meta text. Equal to `box.x + TEXT_INSET_PX`
80
+ * when text fits inside the bar; otherwise positioned just past
81
+ * the bar's right edge so the caption reads as belonging to the
82
+ * item rather than being clipped.
83
+ */
84
+ textX: number;
85
+ /** True when text spills past the bar's right edge. */
86
+ textSpills: boolean;
87
+ }
88
+
89
+ /**
90
+ * Approx. rendered width of `text` at `fontSizePx`. Matches the legacy
91
+ * `sequenceItem` heuristic (intentionally pessimistic at ~0.58 em/char so
92
+ * borderline-fitting captions trigger spill rather than clip).
93
+ */
94
+ function estimateTextWidth(text: string, fontSizePx: number): number {
95
+ return text.length * fontSizePx * 0.58;
96
+ }
97
+
98
+ export class ItemNode implements Renderable<PlacedItemGeometry> {
99
+ constructor(public readonly input: ItemNodeInput) {}
100
+
101
+ get id(): string {
102
+ return this.input.id;
103
+ }
104
+
105
+ measure(ctx: MeasureContext): IntrinsicSize {
106
+ const naturalWidth = Math.max(
107
+ MIN_ITEM_WIDTH,
108
+ this.input.logicalRightX - this.input.logicalLeftX,
109
+ );
110
+ return {
111
+ width: naturalWidth,
112
+ height: ctx.bands.bandwidth(),
113
+ };
114
+ }
115
+
116
+ place(origin: Point, ctx: PlaceContext): PlacedItemGeometry {
117
+ const intrinsic = this.measure(ctx);
118
+ const visualWidth = Math.max(MIN_ITEM_WIDTH, intrinsic.width - 2 * ITEM_INSET_PX);
119
+ const boxX = origin.x + ITEM_INSET_PX;
120
+ const box: BoundingBox = {
121
+ x: boxX,
122
+ y: origin.y,
123
+ width: visualWidth,
124
+ height: intrinsic.height,
125
+ };
126
+
127
+ // The link icon (when present) lives in the bar's upper-left
128
+ // and shares the title's vertical band. The caption indents
129
+ // past the icon so the title doesn't render on top of it.
130
+ const linkColumn = this.input.hasLinkIcon
131
+ ? ITEM_LINK_ICON_INSET_PX +
132
+ ITEM_LINK_ICON_TILE_SIZE_PX +
133
+ LINK_ICON_TO_CAPTION_GAP_PX -
134
+ TEXT_INSET_PX
135
+ : 0;
136
+ const captionLeftInset = TEXT_INSET_PX + Math.max(0, linkColumn);
137
+ const innerWidth = Math.max(0, visualWidth - captionLeftInset - TEXT_INSET_PX);
138
+ const titleStr = this.input.title;
139
+ const titleWidth = titleStr ? estimateTextWidth(titleStr, TITLE_FONT_SIZE_PX) : 0;
140
+ const metaTextWidth = this.input.metaText
141
+ ? estimateTextWidth(this.input.metaText, META_FONT_SIZE_PX)
142
+ : 0;
143
+ const trailingWidth = this.input.metaTrailingWidth ?? 0;
144
+ // Trailing decoration (capacity suffix) renders to the right of
145
+ // metaText. When metaText is empty the suffix sits at the
146
+ // caption's leading edge, so its width is the entire meta-line
147
+ // budget; when both exist the suffix needs a small separator gap
148
+ // (rendered via `<tspan dx>` later) included in trailingWidth.
149
+ const metaWidth = metaTextWidth + trailingWidth;
150
+ const hasMetaContent = this.input.metaText !== undefined || trailingWidth > 0;
151
+ const textSpills =
152
+ (titleStr.length > 0 && titleWidth > innerWidth) ||
153
+ (hasMetaContent && metaWidth > innerWidth);
154
+ const textX = textSpills
155
+ ? boxX + visualWidth + TEXT_OUTSIDE_GAP_PX
156
+ : boxX + captionLeftInset;
157
+
158
+ return {
159
+ id: this.input.id,
160
+ box,
161
+ textX,
162
+ textSpills,
163
+ };
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Horizontal gap (px) between the link-icon tile's right edge and the
169
+ * start of the caption text when both render inside the bar.
170
+ */
171
+ const LINK_ICON_TO_CAPTION_GAP_PX = 4;
@@ -0,0 +1,43 @@
1
+ // Marker-row geometry shared across the marker stack: anchors,
2
+ // milestones, and the roadmap-level row packer all read from here so a
3
+ // font, gap, or row-height change flows from one place.
4
+ //
5
+ // Two row-band types live in this file:
6
+ //
7
+ // - `MARKER_ROW_PITCH_PX` (26 px): height of a single marker row in
8
+ // the timeline header. The marker band stacks N rows tall when
9
+ // anchors and milestones can't fit on a single row without colliding.
10
+ // - The bare `13` (= PITCH / 2) midpoint that used to sprinkle
11
+ // `roadmap-node.ts` is derived from this constant — bumping the
12
+ // pitch automatically re-centers labels.
13
+ //
14
+ // Diamond + label box dimensions are reused by every marker (anchor +
15
+ // milestone), and the `MARKER_BOLD_WIDTH_FACTOR` corrects the
16
+ // pessimistic 0.58 em width estimate for bold milestone labels.
17
+
18
+ /** Height (px) of a single marker row in the timeline header band. */
19
+ export const MARKER_ROW_PITCH_PX = 26;
20
+
21
+ /**
22
+ * Vertical center offset of a marker row from the top of its band.
23
+ * Always half of `MARKER_ROW_PITCH_PX` — exposed as a derived constant
24
+ * so call sites read as "row 0 center" instead of magic `13`.
25
+ */
26
+ export const MARKER_ROW_CENTER_OFFSET_PX = MARKER_ROW_PITCH_PX / 2;
27
+
28
+ /** Half-width of the diamond glyph drawn at every marker's centerX. */
29
+ export const MARKER_DIAMOND_RADIUS_PX = 6;
30
+
31
+ /** Horizontal gap between the diamond's edge and its label box. */
32
+ export const MARKER_LABEL_GAP_PX = 6;
33
+
34
+ /** Height of the label box (one line of marker label text). */
35
+ export const MARKER_LABEL_HEIGHT_PX = 12;
36
+
37
+ /**
38
+ * Width-estimate surcharge for bold sans-serif marker labels (milestone
39
+ * titles). `estimateTextWidth` is intentionally pessimistic at ~0.58
40
+ * em/char so this small surcharge keeps overlap detection on the safe
41
+ * side without overshooting layout space.
42
+ */
43
+ export const MARKER_BOLD_WIDTH_FACTOR = 1.05;
@@ -0,0 +1,263 @@
1
+ // MilestoneNode — Renderable for a single milestone + a
2
+ // `buildMilestones` loop helper. Each milestone sits in the marker row
3
+ // and either pins to a fixed `date:` or floats to the rightmost `after:`
4
+ // predecessor. A non-binding (second-latest) predecessor drives the
5
+ // "slack" arrow when present. Date-pinned milestones whose predecessors
6
+ // would push past the date are flagged `isOverrun`.
7
+ //
8
+ // Marker-row placement (row + label box + left/right side) is decided
9
+ // once by `roadmap-node.ts::packMarkerRow`. Date-pinned entities are
10
+ // pre-packed before swimlanes run; after-only milestones get a
11
+ // PROVISIONAL row=0 placement here at build time which the unified
12
+ // re-pack in `RoadmapNode.place` overwrites with the final tick-order
13
+ // bottom-first slot once every centerX is known.
14
+
15
+ import type { MilestoneDeclaration } from '@nowline/core';
16
+ import { parseDate, propValue, propValues } from '../dsl-utils.js';
17
+ import type { LayoutContext } from '../layout-context.js';
18
+ import { resolveStyle } from '../style-resolution.js';
19
+ import type { BoundingBox, Point, PositionedMilestone } from '../types.js';
20
+ import {
21
+ MARKER_BOLD_WIDTH_FACTOR,
22
+ MARKER_DIAMOND_RADIUS_PX,
23
+ MARKER_LABEL_GAP_PX,
24
+ MARKER_LABEL_HEIGHT_PX,
25
+ } from './marker-geometry.js';
26
+
27
+ /**
28
+ * One predecessor of a milestone with everything the slack-arrow
29
+ * pipeline needs: its source x (visual right edge for items, marker
30
+ * centerX for anchors / other milestones), its attach y (bar bottom
31
+ * strip when text spills, row mid otherwise), and its flow key (used
32
+ * to dedupe so a chained-flow's siblings collapse to the last entry).
33
+ */
34
+ export interface MilestonePredecessor {
35
+ ref: string;
36
+ x: number;
37
+ y: number;
38
+ flowKey: string;
39
+ }
40
+
41
+ /**
42
+ * Resolve each `after:` reference into a `MilestonePredecessor`.
43
+ * Items use their VISUAL right edge as the slack source so the
44
+ * arrow leaves the painted bar instead of landing in the inter-
45
+ * column gutter; markers (anchors / other milestones) fall back
46
+ * to their cut-line centerX. Refs whose target is unknown drop
47
+ * silently — matches the legacy continue path.
48
+ */
49
+ export function collectMilestonePredecessors(
50
+ refs: string[],
51
+ ctx: LayoutContext,
52
+ ): MilestonePredecessor[] {
53
+ const out: MilestonePredecessor[] = [];
54
+ for (const ref of refs) {
55
+ const visualRight = ctx.entityVisualRightX.get(ref);
56
+ const x = visualRight ?? ctx.entityRightEdges.get(ref);
57
+ if (x === undefined) continue;
58
+ const y = ctx.itemSlackAttachY.get(ref) ?? ctx.entityMidpoints.get(ref)?.y ?? 0;
59
+ // Markers don't share a flow with anything, so use their id
60
+ // as a unique flow key — every marker stands on its own.
61
+ const flowKey = ctx.itemFlowKey.get(ref) ?? `marker:${ref}`;
62
+ out.push({ ref, x, y, flowKey });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Keep only the rightmost predecessor per flow key. Two
69
+ * predecessors share a flow when they sit in the same deepest
70
+ * single-track container (swimlane root, sequential group, or one
71
+ * sub-track of a parallel) — file order already encodes their
72
+ * ordering, so only the latest entry contributes a slack arrow.
73
+ * Predecessors in different flows (e.g. two parallel sub-tracks)
74
+ * each survive as their flow's last entry.
75
+ */
76
+ export function lastPredecessorPerFlow(preds: MilestonePredecessor[]): MilestonePredecessor[] {
77
+ const m = new Map<string, MilestonePredecessor>();
78
+ for (const p of preds) {
79
+ const existing = m.get(p.flowKey);
80
+ if (!existing || p.x > existing.x) m.set(p.flowKey, p);
81
+ }
82
+ return Array.from(m.values());
83
+ }
84
+
85
+ function decideLabelBoxForCanvas(
86
+ centerX: number,
87
+ centerY: number,
88
+ radius: number,
89
+ title: string,
90
+ fontSize: number,
91
+ bold: boolean,
92
+ chartLeftX: number,
93
+ chartRightX: number,
94
+ ): { box: BoundingBox; side: 'left' | 'right' } {
95
+ const labelWidth = title.length * fontSize * 0.58 * (bold ? MARKER_BOLD_WIDTH_FACTOR : 1);
96
+ const naturalRightX = centerX + radius + MARKER_LABEL_GAP_PX;
97
+ const naturalLeftX = centerX - radius - MARKER_LABEL_GAP_PX - labelWidth;
98
+ const fitsRight = naturalRightX + labelWidth <= chartRightX;
99
+ const fitsLeft = naturalLeftX >= chartLeftX;
100
+ const side: 'left' | 'right' = fitsRight ? 'right' : fitsLeft ? 'left' : 'right';
101
+ const xLeft = side === 'right' ? naturalRightX : naturalLeftX;
102
+ return {
103
+ box: { x: xLeft, y: centerY - 4, width: labelWidth, height: MARKER_LABEL_HEIGHT_PX },
104
+ side,
105
+ };
106
+ }
107
+
108
+ export class MilestoneNode {
109
+ constructor(
110
+ public readonly id: string,
111
+ public readonly milestone: MilestoneDeclaration,
112
+ ) {}
113
+
114
+ /**
115
+ * Returns null when the milestone is neither date-pinned nor has a
116
+ * resolvable `after:` predecessor (skipped silently — matches the
117
+ * legacy `buildMilestones` continue-paths).
118
+ */
119
+ place(ctx: LayoutContext): PositionedMilestone | null {
120
+ const m = this.milestone;
121
+ const style = resolveStyle('milestone', m.properties, ctx.styleCtx);
122
+ const dateRaw = propValue(m.properties, 'date');
123
+ const afterRaw = propValues(m.properties, 'after');
124
+ const date = parseDate(dateRaw);
125
+ const inRowY = ctx.timeline.markerRow.y;
126
+ const radius = MARKER_DIAMOND_RADIUS_PX;
127
+ const title = m.title ?? this.id;
128
+
129
+ let centerX: number | null = null;
130
+ let centerY: number = inRowY;
131
+ let labelBox: BoundingBox | null = null;
132
+ let labelSide: 'left' | 'right' = 'right';
133
+ let fixed = false;
134
+ let slackArrows: Array<{ x: number; y: number }> | undefined;
135
+ let isOverrun = false;
136
+
137
+ if (date) {
138
+ const x = ctx.scale.forwardWithinDomain(date);
139
+ if (x === null) return null;
140
+ centerX = x;
141
+ fixed = true;
142
+ const placement = ctx.markerRowPlacements.get(this.id);
143
+ if (placement) {
144
+ centerY = placement.centerY;
145
+ labelBox = placement.labelBox;
146
+ labelSide = placement.labelSide;
147
+ }
148
+ // Date-pinned milestones whose `after:` predecessors finish
149
+ // past the pinned date are flagged `isOverrun`; the latest
150
+ // last-of-flow predecessor draws a single overrun arrow
151
+ // pointing back from its visual right edge to the
152
+ // milestone's column. Flow-dedupe avoids stacking redundant
153
+ // arrows from sibling items in one chained flow.
154
+ const preds = collectMilestonePredecessors(afterRaw, ctx);
155
+ const dedupedPreds = lastPredecessorPerFlow(preds);
156
+ let maxPred: MilestonePredecessor | null = null;
157
+ for (const p of dedupedPreds) {
158
+ if (!maxPred || p.x > maxPred.x) maxPred = p;
159
+ }
160
+ if (maxPred && maxPred.x > x) {
161
+ isOverrun = true;
162
+ slackArrows = [
163
+ {
164
+ x: maxPred.x,
165
+ y: maxPred.y > 0 ? maxPred.y : centerY,
166
+ },
167
+ ];
168
+ }
169
+ } else if (afterRaw.length > 0) {
170
+ // Float to the rightmost (binding) predecessor; every
171
+ // last-of-flow predecessor that finishes EARLIER than the
172
+ // binding contributes one slack arrow. Predecessors in the
173
+ // same single-track flow (sequential group, swimlane root,
174
+ // one parallel sub-track) collapse to just their last
175
+ // entry — file order encodes the dependency chain, so only
176
+ // the rightmost matters. Predecessors in different flows
177
+ // (e.g. two parallel sub-tracks) each contribute their own
178
+ // last-of-flow arrow.
179
+ const preds = collectMilestonePredecessors(afterRaw, ctx);
180
+ const dedupedPreds = lastPredecessorPerFlow(preds);
181
+ dedupedPreds.sort((a, b) => b.x - a.x);
182
+ const maxEnd = dedupedPreds[0]?.x ?? ctx.timeline.originX;
183
+ centerX = maxEnd;
184
+ fixed = false;
185
+ // Provisional placement — RoadmapNode runs a unified
186
+ // tick-order pack across all markers (date-pinned anchors,
187
+ // date-pinned milestones, after-only milestones) once
188
+ // everyone's centerX is known. That pass overwrites the
189
+ // rowIndex / centerY / labelBox / labelSide we set here.
190
+ const provisional = decideLabelBoxForCanvas(
191
+ centerX,
192
+ inRowY,
193
+ radius,
194
+ title,
195
+ 10,
196
+ true,
197
+ ctx.timeline.box.x,
198
+ ctx.chartRightX,
199
+ );
200
+ centerY = inRowY;
201
+ labelBox = provisional.box;
202
+ labelSide = provisional.side;
203
+ ctx.markerRowPlacements.set(this.id, {
204
+ rowIndex: 0,
205
+ centerY,
206
+ labelBox,
207
+ labelSide,
208
+ });
209
+ const arrows: Array<{ x: number; y: number }> = [];
210
+ for (let i = 1; i < dedupedPreds.length; i++) {
211
+ const p = dedupedPreds[i];
212
+ if (p.x < maxEnd && p.y > 0) arrows.push({ x: p.x, y: p.y });
213
+ }
214
+ if (arrows.length > 0) slackArrows = arrows;
215
+ }
216
+ if (centerX === null) return null;
217
+ if (!labelBox) {
218
+ const fallback = decideLabelBoxForCanvas(
219
+ centerX,
220
+ centerY,
221
+ radius,
222
+ title,
223
+ 10,
224
+ true,
225
+ ctx.timeline.box.x,
226
+ ctx.chartRightX,
227
+ );
228
+ labelBox = fallback.box;
229
+ labelSide = fallback.side;
230
+ }
231
+
232
+ const center: Point = { x: centerX, y: centerY };
233
+ ctx.entityLeftEdges.set(this.id, center.x);
234
+ ctx.entityRightEdges.set(this.id, center.x);
235
+ ctx.entityMidpoints.set(this.id, center);
236
+ return {
237
+ id: this.id,
238
+ title,
239
+ center,
240
+ radius,
241
+ fixed,
242
+ slackArrows,
243
+ isOverrun,
244
+ style,
245
+ cutTopY: ctx.chartTopY,
246
+ cutBottomY: ctx.swimlaneBottomY,
247
+ labelBox,
248
+ labelSide,
249
+ };
250
+ }
251
+ }
252
+
253
+ export function buildMilestones(
254
+ milestones: Map<string, MilestoneDeclaration>,
255
+ ctx: LayoutContext,
256
+ ): PositionedMilestone[] {
257
+ const out: PositionedMilestone[] = [];
258
+ for (const [id, m] of milestones) {
259
+ const positioned = new MilestoneNode(id, m).place(ctx);
260
+ if (positioned) out.push(positioned);
261
+ }
262
+ return out;
263
+ }
@@ -0,0 +1,101 @@
1
+ // ParallelNode — Renderable for a `parallel { ... }` block. Stacks
2
+ // children top-to-bottom (each child owns a fresh sub-track) and reports
3
+ // the union bounding box. Each child is sequenced via the injected
4
+ // `deps.sequenceOne` callback, which dispatches to the appropriate
5
+ // per-entity Renderable (or, transitionally, the legacy sequencer
6
+ // helpers in `layout.ts`).
7
+
8
+ import type { GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
9
+ import type { LayoutContext, TrackCursor } from '../layout-context.js';
10
+ import { resolveStyle } from '../style-resolution.js';
11
+ import { TRACK_BLOCK_TAIL_GUTTER_PX } from '../themes/shared.js';
12
+ import type { BoundingBox, PositionedParallel, PositionedTrackChild } from '../types.js';
13
+
14
+ export interface ParallelNodeDeps {
15
+ sequenceOne: (
16
+ child: ItemDeclaration | GroupBlock | ParallelBlock,
17
+ cursor: TrackCursor,
18
+ ctx: LayoutContext,
19
+ ) => PositionedTrackChild;
20
+ newCursor: (x: number, y: number) => TrackCursor;
21
+ }
22
+
23
+ export class ParallelNode {
24
+ constructor(
25
+ public readonly node: ParallelBlock,
26
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via `const { deps } = this` destructuring inside methods, which the analyzer does not detect.
27
+ private readonly deps: ParallelNodeDeps,
28
+ ) {}
29
+
30
+ get id(): string {
31
+ return this.node.name ?? '';
32
+ }
33
+
34
+ /**
35
+ * Sequence children into stacked sub-tracks, advance the parent
36
+ * `cursor` past the parallel's right edge plus
37
+ * `TRACK_BLOCK_TAIL_GUTTER_PX` of breathing room, and return a
38
+ * `PositionedParallel`.
39
+ */
40
+ place(cursor: TrackCursor, ctx: LayoutContext): PositionedParallel {
41
+ const { node } = this;
42
+ const { deps } = this;
43
+ const style = resolveStyle('parallel', node.properties, ctx.styleCtx);
44
+ const startX = cursor.x;
45
+ const startY = cursor.y;
46
+ const children: PositionedTrackChild[] = [];
47
+ let maxRight = startX;
48
+ let accumulatedHeight = 0;
49
+
50
+ // Each child of a parallel block lives on its own sub-track,
51
+ // so each child starts a fresh flow segment under the parent's
52
+ // path. Two predecessors that sit on different parallel
53
+ // sub-tracks therefore stay in different flows for milestone
54
+ // slack-arrow dedupe.
55
+ const previousFlowKey = ctx.currentFlowKey;
56
+ const parId = node.name ?? 'p';
57
+
58
+ let childIndex = 0;
59
+ for (const child of node.content) {
60
+ if (child.$type === 'DescriptionDirective') continue;
61
+ ctx.currentFlowKey = `${previousFlowKey}/par:${parId}#${childIndex}`;
62
+ const subCursor = deps.newCursor(startX, startY + accumulatedHeight);
63
+ const positioned = deps.sequenceOne(
64
+ child as ItemDeclaration | GroupBlock,
65
+ subCursor,
66
+ ctx,
67
+ );
68
+ children.push(positioned);
69
+ accumulatedHeight += Math.max(ctx.bandScale.step(), subCursor.height);
70
+ maxRight = Math.max(maxRight, subCursor.maxX);
71
+ childIndex++;
72
+ }
73
+ ctx.currentFlowKey = previousFlowKey;
74
+
75
+ const box: BoundingBox = {
76
+ x: startX,
77
+ y: startY,
78
+ width: maxRight - startX,
79
+ height: accumulatedHeight,
80
+ };
81
+
82
+ cursor.x = maxRight + TRACK_BLOCK_TAIL_GUTTER_PX;
83
+ cursor.maxX = Math.max(cursor.maxX, cursor.x);
84
+ cursor.height = Math.max(cursor.height, accumulatedHeight);
85
+
86
+ const id = node.name;
87
+ if (id) {
88
+ ctx.entityLeftEdges.set(id, box.x);
89
+ ctx.entityRightEdges.set(id, box.x + box.width);
90
+ }
91
+
92
+ return {
93
+ kind: 'parallel',
94
+ id,
95
+ title: node.title ?? node.name,
96
+ box,
97
+ children,
98
+ style,
99
+ };
100
+ }
101
+ }