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