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