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