@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,957 @@
1
+ // RoadmapNode — the m2.5c composition root. Walks the per-entity
2
+ // Renderable nodes (ItemNode via sequenceItem, SwimlaneNode, ParallelNode
3
+ // via sequenceOne, AnchorNode/MilestoneNode via build*, footnote/include
4
+ // helpers) to produce the final `PositionedRoadmap` model.
5
+ //
6
+ // RoadmapNode does NOT import the sequencer helpers or the orchestration
7
+ // helpers (`computeDateWindow`, `sizeBesideHeader`, `collectItems`,
8
+ // `buildDependencies`, `buildNowline`) at module-init time. They live in
9
+ // `layout.ts` and are passed in via the `deps` argument. This avoids a
10
+ // runtime cycle while keeping the composition logic in a dedicated file.
11
+
12
+ import type {
13
+ ItemDeclaration,
14
+ NowlineFile,
15
+ ResolveResult,
16
+ SwimlaneDeclaration,
17
+ } from '@nowline/core';
18
+ import { defaultRowBand } from '../band-scale.js';
19
+ import type { CalendarConfig } from '../calendar.js';
20
+ import { daysBetween, resolveCalendar, resolveSizes } from '../calendar.js';
21
+ import { parseDate, propValue, propValues } from '../dsl-utils.js';
22
+ import { resolveLocale } from '../i18n.js';
23
+ import type { LayoutOptions, LayoutResult } from '../layout.js';
24
+ import type { LayoutContext, LayoutHelpers } from '../layout-context.js';
25
+ import { resolveStyle, type StyleContext } from '../style-resolution.js';
26
+ import { type Theme, type ThemeName, themes } from '../themes/index.js';
27
+ import {
28
+ ATTRIBUTION_GLYPH_HEIGHT,
29
+ ATTRIBUTION_GLYPH_WIDTH,
30
+ GUTTER_PX,
31
+ HEADER_ABOVE_HEIGHT_PX,
32
+ NOW_PILL_HEIGHT_PX,
33
+ SPACING_PX,
34
+ TIMELINE_TICK_PANEL_HEIGHT_PX,
35
+ } from '../themes/shared.js';
36
+ import { TimeScale } from '../time-scale.js';
37
+ import type {
38
+ BoundingBox,
39
+ MarkerRowPlacement,
40
+ PositionedDependencyEdge,
41
+ PositionedHeader,
42
+ PositionedIncludeRegion,
43
+ PositionedNowline,
44
+ PositionedRoadmap,
45
+ PositionedSwimlane,
46
+ PositionedTimelineScale,
47
+ SlackCorridor,
48
+ } from '../types.js';
49
+ import type { ViewPreset } from '../view-preset.js';
50
+ import { buildHeaderTicks, resolveScale } from '../view-preset.js';
51
+ import { fromCalendarConfig } from '../working-calendar.js';
52
+ import { buildAnchors } from './anchor-node.js';
53
+ import { buildFootnotes } from './footnote-node.js';
54
+ import { buildIncludeRegions } from './include-node.js';
55
+ import {
56
+ MARKER_BOLD_WIDTH_FACTOR,
57
+ MARKER_DIAMOND_RADIUS_PX,
58
+ MARKER_LABEL_GAP_PX,
59
+ MARKER_LABEL_HEIGHT_PX,
60
+ MARKER_ROW_CENTER_OFFSET_PX,
61
+ MARKER_ROW_PITCH_PX,
62
+ } from './marker-geometry.js';
63
+ import {
64
+ buildMilestones,
65
+ collectMilestonePredecessors,
66
+ lastPredecessorPerFlow,
67
+ } from './milestone-node.js';
68
+ import { SwimlaneNode } from './swimlane-node.js';
69
+
70
+ const HEADER_CARD_TOP_INSET = 4;
71
+
72
+ /**
73
+ * Single mutator for the chart's right-edge extent. Every layout
74
+ * artifact that wants to push the canvas wider passes the absolute X
75
+ * it wants the canvas to contain, with any breathing-room margin
76
+ * (typically `GUTTER_PX`) baked in. Concentrating growth here makes
77
+ * it trivial to grep for every contributor — today: item caption
78
+ * spills; future: anchor/milestone label spills, footnote panels
79
+ * wider than the chart, etc.
80
+ *
81
+ * Initial seed lives at the start of `place(...)` (the natural date
82
+ * window with `GUTTER_PX` insets on each side); everything that
83
+ * becomes known after the swimlane pass calls through here.
84
+ *
85
+ * The now-pill is intentionally NOT a contributor — when the line
86
+ * lands close to either edge, `buildNowline` switches the pill to
87
+ * "flag" mode (squared edge against the line, rounded edge into the
88
+ * chart), so the pill always fits inside the natural canvas.
89
+ */
90
+ function growChartRightX(ctx: LayoutContext, rightX: number): void {
91
+ if (rightX > ctx.chartRightX) ctx.chartRightX = rightX;
92
+ }
93
+
94
+ /** Sized output from the beside-mode header word-wrap pass. */
95
+ export interface SizedHeader {
96
+ titleLines: string[];
97
+ authorLines: string[];
98
+ cardWidth: number;
99
+ cardHeight: number;
100
+ boxWidth: number;
101
+ }
102
+
103
+ /**
104
+ * Bundle of orchestration + sequencer helpers RoadmapNode delegates to.
105
+ * `layout.ts` builds this struct from its private helpers and passes it
106
+ * once when calling `RoadmapNode.place(...)`.
107
+ */
108
+ export interface RoadmapNodeDeps extends LayoutHelpers {
109
+ computeDateWindow: (
110
+ file: NowlineFile,
111
+ ctx: { cal: CalendarConfig; sizes: Map<string, import('../types.js').ResolvedSize> },
112
+ resolved: ResolveResult,
113
+ today: Date | undefined,
114
+ scale: ViewPreset,
115
+ ) => { startDate: Date; endDate: Date };
116
+ sizeBesideHeader: (title: string, author: string | undefined) => SizedHeader;
117
+ collectItems: (swimlanes: SwimlaneDeclaration[]) => Map<string, ItemDeclaration>;
118
+ buildDependencies: (
119
+ items: Map<string, ItemDeclaration>,
120
+ swimlanes: PositionedSwimlane[],
121
+ includes: PositionedIncludeRegion[],
122
+ ctx: LayoutContext,
123
+ ) => PositionedDependencyEdge[];
124
+ buildNowline: (
125
+ today: Date | undefined,
126
+ ctx: LayoutContext,
127
+ locale: string,
128
+ ) => PositionedNowline | null;
129
+ }
130
+
131
+ export class RoadmapNode {
132
+ place(
133
+ file: NowlineFile,
134
+ resolved: ResolveResult,
135
+ options: LayoutOptions,
136
+ deps: RoadmapNodeDeps,
137
+ ): LayoutResult {
138
+ const themeName: ThemeName = options.theme ?? 'light';
139
+ const theme: Theme = themes[themeName];
140
+ const width = options.width ?? 1280;
141
+ const locale = resolveLocale(options.locale, directiveLocale(file));
142
+
143
+ const cal = resolveCalendar(file, resolved.config.calendar);
144
+ const scale = resolveScale(file, resolved.config.scale);
145
+ // Build the resolved-size map once per layout. Sized items look up
146
+ // through this map (instead of the raw `SizeDeclaration`s the
147
+ // include-resolver collected) so item sequencing doesn't pay the
148
+ // literal-to-days conversion every time.
149
+ const sizes = resolveSizes(resolved.content.sizes, cal);
150
+
151
+ const styleCtx: StyleContext = {
152
+ theme,
153
+ styles: resolved.config.styles,
154
+ defaults: resolved.config.defaults,
155
+ labels: resolved.content.labels,
156
+ };
157
+
158
+ // Date window + header geometry. Window is content-aware: when
159
+ // `length:` is omitted we derive the end day from the latest
160
+ // dated/sequenced entity (item, anchor, milestone, today's
161
+ // now-line) instead of defaulting to a 180-day desert.
162
+ const { startDate, endDate } = deps.computeDateWindow(
163
+ file,
164
+ { cal, sizes },
165
+ resolved,
166
+ options.today,
167
+ scale,
168
+ );
169
+
170
+ // Determine header position + timeline placement via `default roadmap`
171
+ // / theme. Raw style props (including `timeline-position` and
172
+ // `minor-grid`) are banned on the roadmap declaration itself, so the
173
+ // values come from the level-2 `default roadmap` line in config.
174
+ const headerStyle = resolveStyle('roadmap', file.roadmapDecl?.properties ?? [], styleCtx);
175
+ const isBeside = headerStyle.headerPosition === 'beside';
176
+ // `top` (default) keeps the legacy single-strip header; `bottom`
177
+ // suppresses the top tick panel (now-pill and marker row stay
178
+ // at the top because anchors / milestones live there); `both`
179
+ // mirrors the strip at the chart bottom too. See specs/dsl.md
180
+ // and specs/rendering.md § Timeline Scale.
181
+ const showTopTickPanel = headerStyle.timelinePosition !== 'bottom';
182
+ const showBottomTickPanel =
183
+ headerStyle.timelinePosition === 'bottom' || headerStyle.timelinePosition === 'both';
184
+
185
+ // Pre-size the beside-mode header card. Width = max line width +
186
+ // padding, clamped to MIN..MAX with word-wrap once the title
187
+ // exceeds MAX. Above-mode keeps the existing fixed-strip
188
+ // geometry (full canvas width, fixed height).
189
+ const titleStr = file.roadmapDecl?.title ?? file.roadmapDecl?.name ?? '';
190
+ const authorStr = propValue(file.roadmapDecl?.properties ?? [], 'author');
191
+ const sizedHeader = deps.sizeBesideHeader(titleStr, authorStr);
192
+
193
+ const headerBox = isBeside
194
+ ? { x: 0, y: 0, width: sizedHeader.boxWidth, height: 0 }
195
+ : { x: 0, y: 0, width: 0, height: HEADER_ABOVE_HEIGHT_PX };
196
+
197
+ const chartLeftX = isBeside ? sizedHeader.boxWidth : 0;
198
+ const chartTopY = isBeside ? 8 : HEADER_ABOVE_HEIGHT_PX + 8;
199
+
200
+ // `options.width` is treated as a *maximum* canvas width. The
201
+ // chart sizes to natural content width (date window × ppd) plus
202
+ // chrome padding, capped at the max — no floor. The two
203
+ // `GUTTER_PX` insets keep the header card and attribution
204
+ // wordmark from butting against the canvas edges.
205
+ const calendar = fromCalendarConfig(cal);
206
+ const ppd = scale.pixelsPerUnit / calendar.daysPerUnit(scale.unit);
207
+ const spanDays = Math.max(1, daysBetween(startDate, endDate));
208
+ const naturalWidth = spanDays * ppd;
209
+ const originX = chartLeftX + GUTTER_PX;
210
+ const totalChartWidth = naturalWidth;
211
+ const desiredCanvas = chartLeftX + GUTTER_PX + totalChartWidth + GUTTER_PX;
212
+ const chartRightX = Math.min(width, desiredCanvas);
213
+
214
+ // Header layout (top → bottom):
215
+ // 1. Now-pill row (16 px) — only when there's a now-line
216
+ // 2. Tick-label panel (24 px) — always
217
+ // 3. Marker row (≥26 px) — sized to the packed row count
218
+ // 4. 8 px gap, then the chart begins
219
+ const willHaveNowline =
220
+ options.today !== undefined && options.today >= startDate && options.today <= endDate;
221
+ const hasMarkerEntities =
222
+ resolved.content.anchors.size + resolved.content.milestones.size > 0;
223
+ const pillRowHeight = willHaveNowline ? NOW_PILL_HEIGHT_PX : 0;
224
+ // When `timeline-position:bottom` is set, the top tick panel
225
+ // collapses to height 0 — the now-pill and marker row stack
226
+ // directly without the date strip between them.
227
+ const tickPanelHeight = showTopTickPanel ? TIMELINE_TICK_PANEL_HEIGHT_PX : 0;
228
+
229
+ // Build the time scale up front — packMarkerRow needs it to
230
+ // resolve date-pinned entity x positions before we can size the
231
+ // marker row band.
232
+ const timeScale = new TimeScale({
233
+ domain: [startDate, endDate],
234
+ range: [originX, originX + naturalWidth],
235
+ calendar,
236
+ });
237
+ // Initial canvas extent = natural date window + canonical
238
+ // gutters. Item caption spills are unknown until the swimlane
239
+ // pass runs and grow the canvas via `growChartRightX`. The
240
+ // now-pill doesn't contribute here — `buildNowline` flips it to
241
+ // flag mode whenever a centered pill would clip an edge.
242
+ const finalChartRightX = Math.max(chartRightX, originX + totalChartWidth + GUTTER_PX);
243
+
244
+ // Resolve each date-pinned anchor and milestone's x. Used both for
245
+ // the marker-row pack and for `after:` resolution downstream — an
246
+ // item with `after:kickoff` would otherwise see an empty
247
+ // entityRightEdges entry and silently fall through to `cursor.x`
248
+ // (= the chart origin). After-only milestones still resolve later
249
+ // once their predecessors are known.
250
+ const datePinnedEntries: Array<MarkerEntity & { date: Date; isMilestone: boolean }> = [];
251
+ for (const [id, anchor] of resolved.content.anchors) {
252
+ const date = parseDate(propValue(anchor.properties, 'date'));
253
+ if (!date) continue;
254
+ const x = timeScale.forwardWithinDomain(date);
255
+ if (x === null) continue;
256
+ datePinnedEntries.push({
257
+ id,
258
+ centerX: x,
259
+ radius: MARKER_DIAMOND_RADIUS_PX,
260
+ title: anchor.title ?? id,
261
+ fontSize: 10,
262
+ bold: false,
263
+ date,
264
+ isMilestone: false,
265
+ });
266
+ }
267
+ for (const [id, milestone] of resolved.content.milestones) {
268
+ const date = parseDate(propValue(milestone.properties, 'date'));
269
+ if (!date) continue;
270
+ const x = timeScale.forwardWithinDomain(date);
271
+ if (x === null) continue;
272
+ datePinnedEntries.push({
273
+ id,
274
+ centerX: x,
275
+ radius: MARKER_DIAMOND_RADIUS_PX,
276
+ title: milestone.title ?? id,
277
+ fontSize: 10,
278
+ bold: true,
279
+ date,
280
+ isMilestone: true,
281
+ });
282
+ }
283
+
284
+ // Pack the date-pinned markers. Each entity gets a row index
285
+ // (0 = in-row baseline, 1 = bumped down by one step, …) and a
286
+ // label box that may be flipped to the LEFT side when the natural
287
+ // right-side label would overflow `finalChartRightX`. Earlier
288
+ // entries claim row 0; later ones drop to row 1+ when their
289
+ // bounding box (diamond + label) overlaps an already-placed
290
+ // entry. The renderer reads `labelBox.x/y` directly.
291
+ const packed = packMarkerRow(
292
+ datePinnedEntries,
293
+ chartLeftX,
294
+ finalChartRightX,
295
+ deps.estimateTextWidth,
296
+ );
297
+ const markerRowsCount = hasMarkerEntities ? Math.max(1, packed.rowCount) : 0;
298
+ const markerRowHeight = markerRowsCount * MARKER_ROW_PITCH_PX;
299
+ const headerRowsHeight = pillRowHeight + tickPanelHeight + markerRowHeight;
300
+ const timelineHeightBudget = headerRowsHeight + 8;
301
+ // In beside-mode the header card's BOTTOM aligns with the bottom
302
+ // of the header rows so it visually anchors to the chart's top.
303
+ // When the card is taller than the natural rows + a 4 px top
304
+ // inset, push the timeline down so the card has room without
305
+ // clipping above the canvas.
306
+ const minHeaderRowsBottomForCard = isBeside
307
+ ? sizedHeader.cardHeight + HEADER_CARD_TOP_INSET
308
+ : 0;
309
+ const timelineY = Math.max(chartTopY, minHeaderRowsBottomForCard - headerRowsHeight);
310
+ const tickPanelY = timelineY + pillRowHeight;
311
+ const markerRowY = tickPanelY + tickPanelHeight;
312
+ const headerRowsBottomY = markerRowY + markerRowHeight;
313
+
314
+ const ticks = buildHeaderTicks(timeScale, scale, calendar, locale);
315
+ const timeline: PositionedTimelineScale = {
316
+ box: { x: originX, y: timelineY, width: naturalWidth, height: 0 },
317
+ ticks,
318
+ pixelsPerDay: ppd,
319
+ originX,
320
+ startDate,
321
+ endDate,
322
+ labelStyle: resolveStyle('roadmap', [], styleCtx),
323
+ pillRowHeight,
324
+ tickPanelY,
325
+ tickPanelHeight,
326
+ markerRow: {
327
+ y: markerRowY + MARKER_ROW_CENTER_OFFSET_PX,
328
+ height: markerRowHeight,
329
+ collisionY: markerRowY - 8,
330
+ },
331
+ // `bottomTickPanelY` / `bottomTickPanelHeight` are filled in
332
+ // AFTER swimlanes + includes are placed (and after the marker
333
+ // re-pack potentially grows the chart), so the panel sits
334
+ // directly above the footnote area.
335
+ minorGrid: headerStyle.minorGrid,
336
+ };
337
+
338
+ // Stitch packed placements together with their final centerY now
339
+ // that markerRowY is known. AnchorNode + MilestoneNode read this
340
+ // map to recover both Y and the resolved label box; after-only
341
+ // milestones (not pre-positioned here) pack against this map at
342
+ // build time so they slot into the same rows where there's room.
343
+ const markerRowPlacements = new Map<string, MarkerRowPlacement>();
344
+ for (const [id, p] of packed.placements) {
345
+ const centerY =
346
+ markerRowY + MARKER_ROW_CENTER_OFFSET_PX + p.rowIndex * MARKER_ROW_PITCH_PX;
347
+ markerRowPlacements.set(id, {
348
+ rowIndex: p.rowIndex,
349
+ centerY,
350
+ labelBox: { ...p.labelBox, y: centerY - 4 },
351
+ labelSide: p.labelSide,
352
+ });
353
+ }
354
+
355
+ const ctx: LayoutContext = {
356
+ cal,
357
+ styleCtx,
358
+ sizes,
359
+ labels: resolved.content.labels,
360
+ teams: resolved.content.teams,
361
+ persons: resolved.content.persons,
362
+ symbols: resolved.config.symbols,
363
+ footnoteIndex: new Map(),
364
+ footnoteHosts: new Map(),
365
+ timeline,
366
+ scale: timeScale,
367
+ calendar,
368
+ bandScale: defaultRowBand(),
369
+ entityLeftEdges: new Map(),
370
+ entityRightEdges: new Map(),
371
+ entityMidpoints: new Map(),
372
+ entityVisualLeftX: new Map(),
373
+ entityVisualRightX: new Map(),
374
+ itemArrowSource: new Map(),
375
+ itemFlowKey: new Map(),
376
+ currentFlowKey: '',
377
+ itemSlackAttachY: new Map(),
378
+ slackCorridors: [],
379
+ markerRowPlacements,
380
+ chartTopY: timelineY + timelineHeightBudget,
381
+ chartBottomY: 0,
382
+ swimlaneBottomY: 0,
383
+ chartRightX: finalChartRightX,
384
+ };
385
+
386
+ // Seed the entity-edge maps from the date-pinned pack so item
387
+ // `after:kickoff` references resolve before swimlanes run. Mid
388
+ // points use the row-aware centerY so dependency arrows from the
389
+ // anchor land on the diamond's actual row.
390
+ for (const e of datePinnedEntries) {
391
+ const placement = markerRowPlacements.get(e.id);
392
+ if (!placement) continue;
393
+ ctx.entityLeftEdges.set(e.id, e.centerX);
394
+ ctx.entityRightEdges.set(e.id, e.centerX);
395
+ ctx.entityMidpoints.set(e.id, { x: e.centerX, y: placement.centerY });
396
+ }
397
+
398
+ // Footnotes index must be built before sequencing items reference
399
+ // them.
400
+ const pre = buildFootnotes(resolved.content.footnotes, ctx, 0);
401
+ ctx.footnoteIndex = pre.index;
402
+ ctx.footnoteHosts = pre.hosts;
403
+
404
+ // Snapshot the entity maps after pre-positioning so we can reset
405
+ // them between the two swimlane passes. Date-pinned anchors and
406
+ // milestones live in this baseline; item-derived entries get
407
+ // re-added on each pass.
408
+ const baselineEntityLeft = new Map(ctx.entityLeftEdges);
409
+ const baselineEntityRight = new Map(ctx.entityRightEdges);
410
+ const baselineEntityMid = new Map(ctx.entityMidpoints);
411
+ // Item-only maps don't carry baseline entries (date-pinned
412
+ // markers don't populate visual edges or arrow sources), so
413
+ // pass-2 reruns reset them to fresh empties.
414
+
415
+ // Build swimlanes (declared order). Inter-band gap comes from
416
+ // the swimlane default style's `spacing` bucket. Default
417
+ // `spacing: none` keeps existing samples byte-stable; bumping to
418
+ // `md` introduces an 8 px gap.
419
+ const laneEntries = [...resolved.content.swimlanes.values()];
420
+ const swimlaneDefaultStyle = resolveStyle('swimlane', [], styleCtx);
421
+ const interBandGapPx =
422
+ SPACING_PX[swimlaneDefaultStyle.spacing as keyof typeof SPACING_PX] ?? 0;
423
+
424
+ const runSwimlaneLoop = (): {
425
+ swimlanes: PositionedSwimlane[];
426
+ nextY: number;
427
+ maxRightX: number;
428
+ } => {
429
+ const out: PositionedSwimlane[] = [];
430
+ let cursorY = ctx.chartTopY;
431
+ let bIndex = 0;
432
+ let maxRightX = ctx.timeline.originX;
433
+ for (const lane of laneEntries) {
434
+ if (bIndex > 0) cursorY += interBandGapPx;
435
+ const { positioned, usedHeight, usedRightX } = new SwimlaneNode(
436
+ { lane, bandIndex: bIndex },
437
+ deps,
438
+ ).place({ x: ctx.timeline.originX, y: cursorY }, ctx);
439
+ out.push(positioned);
440
+ cursorY += usedHeight;
441
+ if (usedRightX > maxRightX) maxRightX = usedRightX;
442
+ bIndex++;
443
+ }
444
+ return { swimlanes: out, nextY: cursorY, maxRightX };
445
+ };
446
+
447
+ // Pass 1 — place items without corridor knowledge.
448
+ let pass = runSwimlaneLoop();
449
+ let swimlanes = pass.swimlanes;
450
+ let y = pass.nextY;
451
+
452
+ // Collect slack-arrow corridors from the milestones (mirrors
453
+ // MilestoneNode.place's pred resolution). When the result is
454
+ // non-empty, an item sat inside an arrow's path on pass 1 — rerun
455
+ // the swimlane loop with corridors known so the row-packer can
456
+ // bump the offending items down to a clear row. Bumping never
457
+ // changes an item's x, so corridors stay valid across the rerun;
458
+ // no fixed-point iteration needed.
459
+ const corridors = collectSlackCorridors(resolved.content.milestones, ctx);
460
+ if (corridors.length > 0) {
461
+ ctx.entityLeftEdges = new Map(baselineEntityLeft);
462
+ ctx.entityRightEdges = new Map(baselineEntityRight);
463
+ ctx.entityMidpoints = new Map(baselineEntityMid);
464
+ // itemSlackAttachY only ever holds item entries (markers
465
+ // never write to it), so a fresh map is the right reset —
466
+ // pass 2's items will repopulate. Same applies to the
467
+ // visual-edge / arrow-source / flow-key maps below.
468
+ ctx.itemSlackAttachY = new Map();
469
+ ctx.entityVisualLeftX = new Map();
470
+ ctx.entityVisualRightX = new Map();
471
+ ctx.itemArrowSource = new Map();
472
+ ctx.itemFlowKey = new Map();
473
+ ctx.currentFlowKey = '';
474
+ ctx.slackCorridors = corridors;
475
+ pass = runSwimlaneLoop();
476
+ swimlanes = pass.swimlanes;
477
+ y = pass.nextY;
478
+ }
479
+
480
+ // Expand the canvas to fit any caption that spilled past its bar
481
+ // (`textSpills`). Otherwise the long captions on narrow charts —
482
+ // e.g. `examples/minimal.svg` and `tests/text-spills-right.svg`
483
+ // — would land outside the SVG's viewBox and clip in browsers.
484
+ // Markers/anchors built below see the expanded width and pick a
485
+ // less aggressive label side. Routed through `growChartRightX`
486
+ // so this contribution sits beside the now-pill reservation
487
+ // (made at init) in the canvas-extent ledger.
488
+ growChartRightX(ctx, pass.maxRightX + GUTTER_PX);
489
+
490
+ // Each swimlane band reads the canvas width once during its
491
+ // place pass, before the spill expansion above. Re-stretch every
492
+ // band so the lane background contains its own spilled captions
493
+ // (text-spills-right's "1w — 50% remaining" extends 22 px past
494
+ // the unstretched lane edge otherwise).
495
+ for (const lane of swimlanes) {
496
+ lane.box.width = ctx.chartRightX;
497
+ }
498
+
499
+ // Include regions under the swimlanes. Reserve the 8 px gap +
500
+ // tab-reserve only when there's at least one isolated region —
501
+ // otherwise the now-line and chart bottom would extend past the
502
+ // last swimlane into empty space.
503
+ const isolated = resolved.content.isolatedRegions;
504
+ let includes: PositionedIncludeRegion[] = [];
505
+ if (isolated.length > 0) {
506
+ const r = buildIncludeRegions(isolated, ctx, y + 8, deps);
507
+ includes = r.regions;
508
+ y = r.endY;
509
+ }
510
+
511
+ ctx.chartBottomY = y;
512
+ ctx.swimlaneBottomY = y;
513
+ timeline.box.height = ctx.chartBottomY - timeline.box.y;
514
+
515
+ // Milestones first so anchors know which xs are occupied (for
516
+ // collision bumps). Date-pinned milestones consult the
517
+ // pre-pack; after-only milestones get a provisional row=0
518
+ // placement which the re-pack below overwrites once their
519
+ // centerX is known.
520
+ const milestones = buildMilestones(resolved.content.milestones, ctx);
521
+
522
+ // Unified marker re-pack. Every marker (date-pinned anchor,
523
+ // date-pinned milestone, after-only milestone) participates
524
+ // with its final centerX — so packMarkerRow can sort by tick
525
+ // and assign rows + label sides bottom-first. This is the
526
+ // single source of truth for `ctx.markerRowPlacements`; the
527
+ // pre-pack only existed to size the marker band before
528
+ // swimlanes ran.
529
+ const allMarkerEntries: MarkerEntity[] = datePinnedEntries.map((e) => ({
530
+ id: e.id,
531
+ centerX: e.centerX,
532
+ radius: e.radius,
533
+ title: e.title,
534
+ fontSize: e.fontSize,
535
+ bold: e.bold,
536
+ }));
537
+ for (const m of milestones) {
538
+ if (m.fixed) continue;
539
+ allMarkerEntries.push({
540
+ id: m.id ?? '',
541
+ centerX: m.center.x,
542
+ radius: m.radius,
543
+ title: m.title,
544
+ fontSize: 10,
545
+ bold: true,
546
+ });
547
+ }
548
+ const repacked = packMarkerRow(
549
+ allMarkerEntries,
550
+ chartLeftX,
551
+ ctx.chartRightX,
552
+ deps.estimateTextWidth,
553
+ );
554
+
555
+ ctx.markerRowPlacements.clear();
556
+ for (const [id, p] of repacked.placements) {
557
+ const centerY =
558
+ markerRowY + MARKER_ROW_CENTER_OFFSET_PX + p.rowIndex * MARKER_ROW_PITCH_PX;
559
+ ctx.markerRowPlacements.set(id, {
560
+ rowIndex: p.rowIndex,
561
+ centerY,
562
+ labelBox: { ...p.labelBox, y: centerY - 4 },
563
+ labelSide: p.labelSide,
564
+ });
565
+ }
566
+
567
+ // Push final placements back onto already-built milestones and
568
+ // every marker's entityMidpoints entry (used by edge routing
569
+ // and slack-arrow geometry).
570
+ for (const m of milestones) {
571
+ const placement = ctx.markerRowPlacements.get(m.id ?? '');
572
+ if (!placement) continue;
573
+ m.center = { x: m.center.x, y: placement.centerY };
574
+ m.labelBox = placement.labelBox;
575
+ m.labelSide = placement.labelSide;
576
+ }
577
+ for (const e of datePinnedEntries) {
578
+ const p = ctx.markerRowPlacements.get(e.id);
579
+ if (!p) continue;
580
+ ctx.entityMidpoints.set(e.id, { x: e.centerX, y: p.centerY });
581
+ }
582
+ for (const m of milestones) {
583
+ if (m.fixed) continue;
584
+ ctx.entityMidpoints.set(m.id ?? '', { x: m.center.x, y: m.center.y });
585
+ }
586
+
587
+ // If the unified pack needs more rows than we sized for, grow
588
+ // the marker band and translate every chart coordinate below
589
+ // it. Anchors, edges, nowline, footnotes, and the attribution
590
+ // mark are built AFTER this block so they pick up the new ctx
591
+ // values without further work.
592
+ const actualRowCount = hasMarkerEntities ? Math.max(1, repacked.rowCount) : 0;
593
+ if (actualRowCount > markerRowsCount) {
594
+ const deltaY = (actualRowCount - markerRowsCount) * MARKER_ROW_PITCH_PX;
595
+ ctx.timeline.markerRow.height = actualRowCount * MARKER_ROW_PITCH_PX;
596
+ ctx.timeline.box.height += deltaY;
597
+ ctx.chartTopY += deltaY;
598
+ ctx.chartBottomY += deltaY;
599
+ ctx.swimlaneBottomY += deltaY;
600
+ for (const lane of swimlanes) shiftSwimlaneY(lane, deltaY);
601
+ for (const inc of includes) shiftIncludeY(inc, deltaY);
602
+ // Item entityMidpoints were captured during swimlane
603
+ // place; markers live in markerRowPlacements with their
604
+ // own centerY that's already final.
605
+ for (const [id, m] of ctx.entityMidpoints) {
606
+ if (ctx.markerRowPlacements.has(id)) continue;
607
+ ctx.entityMidpoints.set(id, { x: m.x, y: m.y + deltaY });
608
+ }
609
+ // itemSlackAttachY was sampled at the same pre-shift Y as
610
+ // the entity midpoints — keep the two in sync.
611
+ for (const [id, y] of ctx.itemSlackAttachY) {
612
+ ctx.itemSlackAttachY.set(id, y + deltaY);
613
+ }
614
+ for (const m of milestones) {
615
+ m.cutTopY = ctx.chartTopY;
616
+ m.cutBottomY = ctx.swimlaneBottomY;
617
+ // Slack arrows were baked with the pre-shift attach Y
618
+ // when buildMilestones ran above. Shift them now so
619
+ // they land on the same row band as the (now-shifted)
620
+ // predecessor bar.
621
+ if (m.slackArrows) {
622
+ for (const arrow of m.slackArrows) arrow.y += deltaY;
623
+ }
624
+ }
625
+ }
626
+
627
+ // Insert the mirrored bottom tick panel directly above the
628
+ // footnote area, BELOW the chart's last swimlane / include
629
+ // region. Done AFTER any marker-row-growth shift so the panel
630
+ // y honors the final chartBottomY. `timeline.box.height` stays
631
+ // anchored to the chart's bottom edge — grid lines therefore
632
+ // stop at the panel's TOP, not at its bottom — while the panel
633
+ // gets its own fill / border / labels in the renderer. Milestone
634
+ // and anchor cut-lines stay anchored to `swimlaneBottomY` so
635
+ // they never invade the bottom date strip; only the now-line
636
+ // (and full-height major grid lines) thread through this panel.
637
+ if (showBottomTickPanel) {
638
+ const gapAbovePanel = 8;
639
+ ctx.timeline.bottomTickPanelY = ctx.chartBottomY + gapAbovePanel;
640
+ ctx.timeline.bottomTickPanelHeight = TIMELINE_TICK_PANEL_HEIGHT_PX;
641
+ ctx.chartBottomY += gapAbovePanel + TIMELINE_TICK_PANEL_HEIGHT_PX;
642
+ }
643
+
644
+ const milestoneXs = new Set<number>(milestones.map((m) => m.center.x));
645
+ const anchors = buildAnchors(resolved.content.anchors, ctx, milestoneXs);
646
+ const itemsMap = deps.collectItems(laneEntries);
647
+ const edges = deps.buildDependencies(itemsMap, swimlanes, includes, ctx);
648
+
649
+ // Now-line (if today is within the window). Stops at the bottom
650
+ // timeline panel when present, otherwise at the last swimlane —
651
+ // see `buildNowline`. Footnote panels below do not extend it.
652
+ const nowline = deps.buildNowline(options.today, ctx, locale);
653
+
654
+ // Finalize footnotes at the bottom.
655
+ const foot = buildFootnotes(resolved.content.footnotes, ctx, ctx.chartBottomY);
656
+ ctx.footnoteIndex = foot.index;
657
+ ctx.footnoteHosts = foot.hosts;
658
+
659
+ // Header (depends on chart height in beside-mode and on the
660
+ // final canvas width in above-mode).
661
+ headerBox.height = headerBox.height || ctx.chartBottomY;
662
+ if (!isBeside) headerBox.width = ctx.chartRightX;
663
+ // Card sub-box for beside-mode (the visible white panel inside
664
+ // headerBox). Card BOTTOM hugs the bottom of the header rows so
665
+ // the title block visually anchors to the chart's top edge
666
+ // regardless of which header rows are present. timelineY was
667
+ // already nudged down above to guarantee the card has at least
668
+ // HEADER_CARD_TOP_INSET clearance from the canvas top.
669
+ const cardBox: BoundingBox = isBeside
670
+ ? {
671
+ x: 6,
672
+ y: headerRowsBottomY - sizedHeader.cardHeight,
673
+ width: sizedHeader.cardWidth,
674
+ height: sizedHeader.cardHeight,
675
+ }
676
+ : { x: 0, y: 0, width: ctx.chartRightX, height: HEADER_ABOVE_HEIGHT_PX };
677
+
678
+ // Attribution wordmark placement. Canvas grows by GUTTER_PX +
679
+ // glyph height + GUTTER_PX so the wordmark sits in a clean bottom
680
+ // margin below all content (last swimlane / footnote panel /
681
+ // include region). Today only the bottom-right slot fires, but
682
+ // the priority order is bottom-right → upper-right → bottom-left
683
+ // — when content density makes bottom-right disruptive in some
684
+ // future case, fall back to upper-right (above the timeline) and
685
+ // then bottom-left (under the header card in beside-mode).
686
+ //
687
+ // When there are no footnotes, `foot.area.box` still carries a 16 px
688
+ // top-of-panel offset; that's a placeholder for the footnote
689
+ // header gap and shouldn't push the attribution down. Use the
690
+ // bare `chartBottomY` in that case so the bottom margin is
691
+ // exactly `GUTTER_PX + glyphHeight + GUTTER_PX`.
692
+ const contentBottomY =
693
+ foot.area.entries.length > 0
694
+ ? foot.area.box.y + foot.area.box.height
695
+ : ctx.chartBottomY;
696
+ const attributionBox: BoundingBox = {
697
+ x: ctx.chartRightX - GUTTER_PX - ATTRIBUTION_GLYPH_WIDTH,
698
+ y: contentBottomY + GUTTER_PX,
699
+ width: ATTRIBUTION_GLYPH_WIDTH,
700
+ height: ATTRIBUTION_GLYPH_HEIGHT,
701
+ };
702
+ const height = attributionBox.y + attributionBox.height + GUTTER_PX;
703
+
704
+ const header: PositionedHeader = {
705
+ box: headerBox,
706
+ position: headerStyle.headerPosition,
707
+ title: titleStr,
708
+ author: authorStr,
709
+ titleLines: sizedHeader.titleLines,
710
+ authorLines: sizedHeader.authorLines,
711
+ cardBox,
712
+ logo: undefined,
713
+ style: headerStyle,
714
+ attributionBox,
715
+ };
716
+
717
+ const model: PositionedRoadmap = {
718
+ width: ctx.chartRightX,
719
+ height,
720
+ theme: themeName,
721
+ palette: theme,
722
+ backgroundColor: theme.surface.page,
723
+ header,
724
+ timeline,
725
+ nowline,
726
+ swimlanes,
727
+ anchors,
728
+ milestones,
729
+ edges,
730
+ footnotes: foot.area,
731
+ includes,
732
+ chartBox: {
733
+ x: chartLeftX,
734
+ y: ctx.chartTopY,
735
+ width: ctx.chartRightX - chartLeftX,
736
+ height: ctx.chartBottomY - ctx.chartTopY,
737
+ },
738
+ };
739
+ return model;
740
+ }
741
+ }
742
+
743
+ /**
744
+ * Collect slack-arrow corridors from the resolved milestones using the
745
+ * current entity-edge maps. The result mirrors what
746
+ * `MilestoneNode.place` would emit on `slackArrows`, so the row-packer
747
+ * and the renderer agree on every arrow's geometry.
748
+ *
749
+ * Each non-binding predecessor (`x < maxEnd`, `y > 0`) becomes one
750
+ * corridor; date-pinned milestones whose latest predecessor overruns
751
+ * the date contribute a single back-pointing corridor between the
752
+ * pinned column and the predecessor's right edge.
753
+ */
754
+ function collectSlackCorridors(
755
+ milestones: Map<string, import('@nowline/core').MilestoneDeclaration>,
756
+ ctx: LayoutContext,
757
+ ): SlackCorridor[] {
758
+ const out: SlackCorridor[] = [];
759
+ for (const [id, m] of milestones) {
760
+ const dateRaw = propValue(m.properties, 'date');
761
+ const afterRaw = propValues(m.properties, 'after');
762
+ const date = parseDate(dateRaw);
763
+ // Reuse MilestoneNode's helpers so the corridor set agrees
764
+ // exactly with the rendered slack-arrow set: same source x
765
+ // (visual edge for items / cut-line for markers), same
766
+ // attach y, and the same flow-dedupe rule that collapses
767
+ // chained predecessors to one arrow per flow.
768
+ const preds = collectMilestonePredecessors(afterRaw, ctx);
769
+ const dedupedPreds = lastPredecessorPerFlow(preds);
770
+
771
+ if (date) {
772
+ const milestoneX = ctx.scale.forwardWithinDomain(date);
773
+ if (milestoneX === null) continue;
774
+ let maxPred = null as ReturnType<typeof collectMilestonePredecessors>[number] | null;
775
+ for (const p of dedupedPreds) {
776
+ if (!maxPred || p.x > maxPred.x) maxPred = p;
777
+ }
778
+ if (maxPred && maxPred.x > milestoneX && maxPred.y > 0) {
779
+ out.push({
780
+ xStart: Math.min(maxPred.x, milestoneX),
781
+ xEnd: Math.max(maxPred.x, milestoneX),
782
+ y: maxPred.y,
783
+ slackPredId: maxPred.ref,
784
+ milestoneId: id,
785
+ });
786
+ }
787
+ continue;
788
+ }
789
+ if (dedupedPreds.length === 0) continue;
790
+ dedupedPreds.sort((a, b) => b.x - a.x);
791
+ const maxEnd = dedupedPreds[0].x;
792
+ for (let i = 1; i < dedupedPreds.length; i++) {
793
+ const p = dedupedPreds[i];
794
+ if (p.x < maxEnd && p.y > 0) {
795
+ out.push({
796
+ xStart: p.x,
797
+ xEnd: maxEnd,
798
+ y: p.y,
799
+ slackPredId: p.ref,
800
+ milestoneId: id,
801
+ });
802
+ }
803
+ }
804
+ }
805
+ return out;
806
+ }
807
+
808
+ interface MarkerEntity {
809
+ id: string;
810
+ centerX: number;
811
+ radius: number;
812
+ title: string;
813
+ fontSize: number;
814
+ bold: boolean;
815
+ }
816
+
817
+ interface PackedPlacement {
818
+ rowIndex: number;
819
+ labelBox: BoundingBox;
820
+ labelSide: 'left' | 'right';
821
+ }
822
+
823
+ /**
824
+ * Translate every Y coordinate inside a swimlane subtree by `dy`. Used
825
+ * when the marker band has to grow after items are already placed —
826
+ * the shift cascades from the swimlane box through every track child,
827
+ * including parallels and groups (which are recursive `children`).
828
+ */
829
+ function shiftSwimlaneY(lane: import('../types.js').PositionedSwimlane, dy: number): void {
830
+ lane.box.y += dy;
831
+ for (const child of lane.children) shiftTrackChildY(child, dy);
832
+ for (const nested of lane.nested) shiftSwimlaneY(nested, dy);
833
+ }
834
+
835
+ function shiftTrackChildY(child: import('../types.js').PositionedTrackChild, dy: number): void {
836
+ child.box.y += dy;
837
+ if (child.kind === 'item') {
838
+ if (child.overflowBox) child.overflowBox.y += dy;
839
+ for (const chip of child.labelChips) chip.box.y += dy;
840
+ return;
841
+ }
842
+ for (const c of child.children) shiftTrackChildY(c, dy);
843
+ }
844
+
845
+ function shiftIncludeY(region: import('../types.js').PositionedIncludeRegion, dy: number): void {
846
+ region.box.y += dy;
847
+ for (const lane of region.nestedSwimlanes) shiftSwimlaneY(lane, dy);
848
+ }
849
+
850
+ /**
851
+ * Pack anchors + milestones into marker rows using a bottom-first
852
+ * tick-order strategy. Markers default to the row CLOSEST to the
853
+ * chart; conflicts push them UP toward the date ticks (the inverse of
854
+ * swimlane items which default to the top and push down).
855
+ *
856
+ * Walk order is left-to-right by `centerX` (tick order, not file
857
+ * declaration). For each marker:
858
+ * 1. Try the bottommost existing row (highest `rowIndex`), right
859
+ * side first then left, working UP through the rows.
860
+ * 2. If neither side fits at any existing row, GROW: prepend a new
861
+ * row at `rowIndex = 0`. Every previously-placed marker has its
862
+ * `rowIndex` incremented by 1 so the bottom corridor stays
863
+ * stable as the band expands upward toward the ticks.
864
+ *
865
+ * Returns row-relative placements (no centerY yet — caller fills that
866
+ * in once `markerRowY` is known). Row indices follow the existing
867
+ * convention: 0 = top of band (closest to ticks), `rowCount - 1` =
868
+ * bottom of band (closest to chart). The "bottom-first" preference
869
+ * lives entirely in the search order and grow direction; geometry
870
+ * stays anchored to `markerRowY` at the top.
871
+ */
872
+ export function packMarkerRow(
873
+ entries: MarkerEntity[],
874
+ chartLeftX: number,
875
+ chartRightX: number,
876
+ estimateTextWidth: (text: string, fontSize: number) => number,
877
+ ): { placements: Map<string, PackedPlacement>; rowCount: number } {
878
+ const sorted = [...entries].sort((a, b) => a.centerX - b.centerX);
879
+ const placements = new Map<string, PackedPlacement>();
880
+ type Span = { left: number; right: number };
881
+ let rowSpans: Span[][] = [];
882
+
883
+ type Side = 'left' | 'right';
884
+ for (const e of sorted) {
885
+ const labelWidth =
886
+ estimateTextWidth(e.title, e.fontSize) * (e.bold ? MARKER_BOLD_WIDTH_FACTOR : 1);
887
+ const naturalRightX = e.centerX + e.radius + MARKER_LABEL_GAP_PX;
888
+ const naturalLeftX = e.centerX - e.radius - MARKER_LABEL_GAP_PX - labelWidth;
889
+ const fitsRightCanvas = naturalRightX + labelWidth <= chartRightX;
890
+ const fitsLeftCanvas = naturalLeftX >= chartLeftX;
891
+ const diamondLeft = e.centerX - e.radius;
892
+ const diamondRight = e.centerX + e.radius;
893
+
894
+ const sideXLeft = (s: Side): number => (s === 'right' ? naturalRightX : naturalLeftX);
895
+ const sideFitsCanvas = (s: Side): boolean =>
896
+ s === 'right' ? fitsRightCanvas : fitsLeftCanvas;
897
+ const collidesAt = (xLeft: number, row: number): boolean => {
898
+ const extLeft = Math.min(diamondLeft, xLeft);
899
+ const extRight = Math.max(diamondRight, xLeft + labelWidth);
900
+ return rowSpans[row].some((s) => s.left < extRight && s.right > extLeft);
901
+ };
902
+
903
+ let placedRow = -1;
904
+ let placedSide: Side = 'right';
905
+ let placedXLeft = naturalRightX;
906
+
907
+ // Bottom-first: try the highest existing row first, working up
908
+ // toward row 0 (closest to the ticks).
909
+ outer: for (let row = rowSpans.length - 1; row >= 0; row--) {
910
+ for (const side of ['right', 'left'] as const) {
911
+ if (!sideFitsCanvas(side)) continue;
912
+ const xLeft = sideXLeft(side);
913
+ if (collidesAt(xLeft, row)) continue;
914
+ placedRow = row;
915
+ placedSide = side;
916
+ placedXLeft = xLeft;
917
+ break outer;
918
+ }
919
+ }
920
+
921
+ if (placedRow === -1) {
922
+ // No existing row fits — prepend a fresh top row and shift
923
+ // every previous placement DOWN by one slot (rowIndex += 1)
924
+ // so the bottom corridor stays stable.
925
+ for (const [id, p] of placements) {
926
+ placements.set(id, { ...p, rowIndex: p.rowIndex + 1 });
927
+ }
928
+ rowSpans = [[], ...rowSpans];
929
+ placedRow = 0;
930
+ placedSide = fitsRightCanvas ? 'right' : 'left';
931
+ placedXLeft = sideXLeft(placedSide);
932
+ }
933
+
934
+ const extLeft = Math.min(diamondLeft, placedXLeft);
935
+ const extRight = Math.max(diamondRight, placedXLeft + labelWidth);
936
+ rowSpans[placedRow].push({ left: extLeft, right: extRight });
937
+ placements.set(e.id, {
938
+ rowIndex: placedRow,
939
+ labelBox: { x: placedXLeft, y: 0, width: labelWidth, height: MARKER_LABEL_HEIGHT_PX },
940
+ labelSide: placedSide,
941
+ });
942
+ }
943
+
944
+ return { placements, rowCount: rowSpans.length };
945
+ }
946
+
947
+ // Read the optional `locale:` property from the file's `nowline` directive.
948
+ // Layout receives an already-parsed AST, so this is a cheap lookup with no
949
+ // validation work — that's the validator's job in `@nowline/core`.
950
+ function directiveLocale(file: NowlineFile): string | undefined {
951
+ const prop = file.directive?.properties.find((p) => stripColon(p.key) === 'locale');
952
+ return prop?.value;
953
+ }
954
+
955
+ function stripColon(key: string): string {
956
+ return key.endsWith(':') ? key.slice(0, -1) : key;
957
+ }