@nowline/layout 0.8.5 → 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 (339) 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/band-scale.d.ts.map +1 -1
  6. package/dist/calendar-resolver.d.ts +27 -0
  7. package/dist/calendar-resolver.d.ts.map +1 -0
  8. package/dist/calendar-resolver.js +132 -0
  9. package/dist/calendar-resolver.js.map +1 -0
  10. package/dist/calendar.d.ts +0 -19
  11. package/dist/calendar.d.ts.map +1 -1
  12. package/dist/calendar.js +4 -44
  13. package/dist/calendar.js.map +1 -1
  14. package/dist/canvas-width.d.ts +23 -0
  15. package/dist/canvas-width.d.ts.map +1 -0
  16. package/dist/canvas-width.js +26 -0
  17. package/dist/canvas-width.js.map +1 -0
  18. package/dist/capacity.d.ts +28 -0
  19. package/dist/capacity.d.ts.map +1 -1
  20. package/dist/capacity.js +64 -29
  21. package/dist/capacity.js.map +1 -1
  22. package/dist/chart-translation.d.ts +22 -0
  23. package/dist/chart-translation.d.ts.map +1 -0
  24. package/dist/chart-translation.js +83 -0
  25. package/dist/chart-translation.js.map +1 -0
  26. package/dist/container-header-geometry.d.ts +64 -0
  27. package/dist/container-header-geometry.d.ts.map +1 -0
  28. package/dist/container-header-geometry.js +89 -0
  29. package/dist/container-header-geometry.js.map +1 -0
  30. package/dist/dsl-utils.d.ts +3 -4
  31. package/dist/dsl-utils.d.ts.map +1 -1
  32. package/dist/dsl-utils.js +8 -25
  33. package/dist/dsl-utils.js.map +1 -1
  34. package/dist/edge-routing.d.ts +69 -78
  35. package/dist/edge-routing.d.ts.map +1 -1
  36. package/dist/edge-routing.js +271 -401
  37. package/dist/edge-routing.js.map +1 -1
  38. package/dist/frame-tab-geometry.d.ts +24 -14
  39. package/dist/frame-tab-geometry.d.ts.map +1 -1
  40. package/dist/frame-tab-geometry.js +69 -23
  41. package/dist/frame-tab-geometry.js.map +1 -1
  42. package/dist/header-card-geometry.d.ts +12 -0
  43. package/dist/header-card-geometry.d.ts.map +1 -1
  44. package/dist/header-card-geometry.js +14 -2
  45. package/dist/header-card-geometry.js.map +1 -1
  46. package/dist/header-logo.d.ts +28 -0
  47. package/dist/header-logo.d.ts.map +1 -0
  48. package/dist/header-logo.js +51 -0
  49. package/dist/header-logo.js.map +1 -0
  50. package/dist/i18n.d.ts +11 -0
  51. package/dist/i18n.d.ts.map +1 -1
  52. package/dist/i18n.js +11 -1
  53. package/dist/i18n.js.map +1 -1
  54. package/dist/index.d.ts +20 -8
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +17 -7
  57. package/dist/index.js.map +1 -1
  58. package/dist/inline-date-pin-geometry.d.ts +19 -11
  59. package/dist/inline-date-pin-geometry.d.ts.map +1 -1
  60. package/dist/inline-date-pin-geometry.js +45 -46
  61. package/dist/inline-date-pin-geometry.js.map +1 -1
  62. package/dist/item-bar-geometry.d.ts +192 -4
  63. package/dist/item-bar-geometry.d.ts.map +1 -1
  64. package/dist/item-bar-geometry.js +258 -5
  65. package/dist/item-bar-geometry.js.map +1 -1
  66. package/dist/item-port-geometry.d.ts +13 -0
  67. package/dist/item-port-geometry.d.ts.map +1 -0
  68. package/dist/item-port-geometry.js +63 -0
  69. package/dist/item-port-geometry.js.map +1 -0
  70. package/dist/layout-context.d.ts +115 -51
  71. package/dist/layout-context.d.ts.map +1 -1
  72. package/dist/layout-context.js +96 -5
  73. package/dist/layout-context.js.map +1 -1
  74. package/dist/layout-insights.d.ts.map +1 -1
  75. package/dist/layout-insights.js +73 -1
  76. package/dist/layout-insights.js.map +1 -1
  77. package/dist/layout.d.ts +35 -0
  78. package/dist/layout.d.ts.map +1 -1
  79. package/dist/layout.js +18 -1315
  80. package/dist/layout.js.map +1 -1
  81. package/dist/nodes/anchor-node.d.ts.map +1 -1
  82. package/dist/nodes/anchor-node.js +11 -4
  83. package/dist/nodes/anchor-node.js.map +1 -1
  84. package/dist/nodes/content-extent.d.ts +24 -0
  85. package/dist/nodes/content-extent.d.ts.map +1 -0
  86. package/dist/nodes/content-extent.js +100 -0
  87. package/dist/nodes/content-extent.js.map +1 -0
  88. package/dist/nodes/dependency-edges.d.ts +6 -0
  89. package/dist/nodes/dependency-edges.d.ts.map +1 -0
  90. package/dist/nodes/dependency-edges.js +148 -0
  91. package/dist/nodes/dependency-edges.js.map +1 -0
  92. package/dist/nodes/footnote-node.d.ts +24 -3
  93. package/dist/nodes/footnote-node.d.ts.map +1 -1
  94. package/dist/nodes/footnote-node.js +129 -25
  95. package/dist/nodes/footnote-node.js.map +1 -1
  96. package/dist/nodes/group-node.d.ts +10 -21
  97. package/dist/nodes/group-node.d.ts.map +1 -1
  98. package/dist/nodes/group-node.js +79 -154
  99. package/dist/nodes/group-node.js.map +1 -1
  100. package/dist/nodes/header-node.d.ts +56 -0
  101. package/dist/nodes/header-node.d.ts.map +1 -0
  102. package/dist/nodes/header-node.js +163 -0
  103. package/dist/nodes/header-node.js.map +1 -0
  104. package/dist/nodes/include-node.d.ts +22 -12
  105. package/dist/nodes/include-node.d.ts.map +1 -1
  106. package/dist/nodes/include-node.js +95 -62
  107. package/dist/nodes/include-node.js.map +1 -1
  108. package/dist/nodes/item-measure.d.ts +135 -0
  109. package/dist/nodes/item-measure.d.ts.map +1 -0
  110. package/dist/nodes/item-measure.js +514 -0
  111. package/dist/nodes/item-measure.js.map +1 -0
  112. package/dist/nodes/item-node.d.ts +162 -9
  113. package/dist/nodes/item-node.d.ts.map +1 -1
  114. package/dist/nodes/item-node.js +256 -69
  115. package/dist/nodes/item-node.js.map +1 -1
  116. package/dist/nodes/marker-band.d.ts +118 -0
  117. package/dist/nodes/marker-band.d.ts.map +1 -0
  118. package/dist/nodes/marker-band.js +303 -0
  119. package/dist/nodes/marker-band.js.map +1 -0
  120. package/dist/nodes/marker-geometry.js +1 -1
  121. package/dist/nodes/marker-geometry.js.map +1 -1
  122. package/dist/nodes/milestone-node.d.ts +112 -15
  123. package/dist/nodes/milestone-node.d.ts.map +1 -1
  124. package/dist/nodes/milestone-node.js +246 -95
  125. package/dist/nodes/milestone-node.js.map +1 -1
  126. package/dist/nodes/nowline-node.d.ts +8 -0
  127. package/dist/nodes/nowline-node.d.ts.map +1 -0
  128. package/dist/nodes/nowline-node.js +74 -0
  129. package/dist/nodes/nowline-node.js.map +1 -0
  130. package/dist/nodes/parallel-node.d.ts +7 -12
  131. package/dist/nodes/parallel-node.d.ts.map +1 -1
  132. package/dist/nodes/parallel-node.js +66 -32
  133. package/dist/nodes/parallel-node.js.map +1 -1
  134. package/dist/nodes/region-markers.d.ts +41 -0
  135. package/dist/nodes/region-markers.d.ts.map +1 -0
  136. package/dist/nodes/region-markers.js +130 -0
  137. package/dist/nodes/region-markers.js.map +1 -0
  138. package/dist/nodes/roadmap-node.d.ts +9 -72
  139. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  140. package/dist/nodes/roadmap-node.js +38 -788
  141. package/dist/nodes/roadmap-node.js.map +1 -1
  142. package/dist/nodes/row-track.d.ts +33 -0
  143. package/dist/nodes/row-track.d.ts.map +1 -0
  144. package/dist/nodes/row-track.js +170 -0
  145. package/dist/nodes/row-track.js.map +1 -0
  146. package/dist/nodes/sequence-item.d.ts +25 -0
  147. package/dist/nodes/sequence-item.d.ts.map +1 -0
  148. package/dist/nodes/sequence-item.js +396 -0
  149. package/dist/nodes/sequence-item.js.map +1 -0
  150. package/dist/nodes/sequence-one.d.ts +5 -0
  151. package/dist/nodes/sequence-one.d.ts.map +1 -0
  152. package/dist/nodes/sequence-one.js +26 -0
  153. package/dist/nodes/sequence-one.js.map +1 -0
  154. package/dist/nodes/swimlane-node.d.ts +5 -17
  155. package/dist/nodes/swimlane-node.d.ts.map +1 -1
  156. package/dist/nodes/swimlane-node.js +62 -147
  157. package/dist/nodes/swimlane-node.js.map +1 -1
  158. package/dist/nodes/wave-node.d.ts +52 -0
  159. package/dist/nodes/wave-node.d.ts.map +1 -0
  160. package/dist/nodes/wave-node.js +483 -0
  161. package/dist/nodes/wave-node.js.map +1 -0
  162. package/dist/non-working-display.d.ts +14 -0
  163. package/dist/non-working-display.d.ts.map +1 -0
  164. package/dist/non-working-display.js +28 -0
  165. package/dist/non-working-display.js.map +1 -0
  166. package/dist/phases/date-window.d.ts +48 -0
  167. package/dist/phases/date-window.d.ts.map +1 -0
  168. package/dist/phases/date-window.js +130 -0
  169. package/dist/phases/date-window.js.map +1 -0
  170. package/dist/phases/finalize.d.ts +198 -0
  171. package/dist/phases/finalize.d.ts.map +1 -0
  172. package/dist/phases/finalize.js +1058 -0
  173. package/dist/phases/finalize.js.map +1 -0
  174. package/dist/phases/measure.d.ts +77 -0
  175. package/dist/phases/measure.d.ts.map +1 -0
  176. package/dist/phases/measure.js +222 -0
  177. package/dist/phases/measure.js.map +1 -0
  178. package/dist/phases/place.d.ts +28 -0
  179. package/dist/phases/place.d.ts.map +1 -0
  180. package/dist/phases/place.js +225 -0
  181. package/dist/phases/place.js.map +1 -0
  182. package/dist/renderable.d.ts.map +1 -1
  183. package/dist/renderable.js +23 -3
  184. package/dist/renderable.js.map +1 -1
  185. package/dist/resolve-today.d.ts +11 -1
  186. package/dist/resolve-today.d.ts.map +1 -1
  187. package/dist/resolve-today.js +26 -12
  188. package/dist/resolve-today.js.map +1 -1
  189. package/dist/row-packer.d.ts +42 -16
  190. package/dist/row-packer.d.ts.map +1 -1
  191. package/dist/row-packer.js +51 -78
  192. package/dist/row-packer.js.map +1 -1
  193. package/dist/schedule.d.ts +58 -1
  194. package/dist/schedule.d.ts.map +1 -1
  195. package/dist/schedule.js +280 -106
  196. package/dist/schedule.js.map +1 -1
  197. package/dist/start-rules.d.ts +27 -0
  198. package/dist/start-rules.d.ts.map +1 -0
  199. package/dist/start-rules.js +91 -0
  200. package/dist/start-rules.js.map +1 -0
  201. package/dist/start-terms.d.ts +23 -0
  202. package/dist/start-terms.d.ts.map +1 -0
  203. package/dist/start-terms.js +27 -0
  204. package/dist/start-terms.js.map +1 -0
  205. package/dist/status-dot.d.ts +46 -0
  206. package/dist/status-dot.d.ts.map +1 -0
  207. package/dist/status-dot.js +78 -0
  208. package/dist/status-dot.js.map +1 -0
  209. package/dist/style-resolution.d.ts.map +1 -1
  210. package/dist/style-resolution.js +23 -43
  211. package/dist/style-resolution.js.map +1 -1
  212. package/dist/text-measure.d.ts +28 -0
  213. package/dist/text-measure.d.ts.map +1 -0
  214. package/dist/text-measure.js +70 -0
  215. package/dist/text-measure.js.map +1 -0
  216. package/dist/themes/contrast.d.ts +4 -0
  217. package/dist/themes/contrast.d.ts.map +1 -0
  218. package/dist/themes/contrast.js +32 -0
  219. package/dist/themes/contrast.js.map +1 -0
  220. package/dist/themes/dark.d.ts.map +1 -1
  221. package/dist/themes/dark.js +28 -1
  222. package/dist/themes/dark.js.map +1 -1
  223. package/dist/themes/grayscale.d.ts.map +1 -1
  224. package/dist/themes/grayscale.js +28 -1
  225. package/dist/themes/grayscale.js.map +1 -1
  226. package/dist/themes/index.d.ts +4 -11
  227. package/dist/themes/index.d.ts.map +1 -1
  228. package/dist/themes/index.js +6 -4
  229. package/dist/themes/index.js.map +1 -1
  230. package/dist/themes/light.d.ts.map +1 -1
  231. package/dist/themes/light.js +28 -1
  232. package/dist/themes/light.js.map +1 -1
  233. package/dist/themes/shape.d.ts +31 -0
  234. package/dist/themes/shape.d.ts.map +1 -1
  235. package/dist/themes/shared.d.ts +44 -50
  236. package/dist/themes/shared.d.ts.map +1 -1
  237. package/dist/themes/shared.js +79 -83
  238. package/dist/themes/shared.js.map +1 -1
  239. package/dist/time-scale.d.ts +42 -6
  240. package/dist/time-scale.d.ts.map +1 -1
  241. package/dist/time-scale.js +109 -17
  242. package/dist/time-scale.js.map +1 -1
  243. package/dist/types.d.ts +528 -11
  244. package/dist/types.d.ts.map +1 -1
  245. package/dist/view-preset.d.ts +61 -6
  246. package/dist/view-preset.d.ts.map +1 -1
  247. package/dist/view-preset.js +439 -24
  248. package/dist/view-preset.js.map +1 -1
  249. package/dist/wave-barrier.d.ts +107 -0
  250. package/dist/wave-barrier.d.ts.map +1 -0
  251. package/dist/wave-barrier.js +174 -0
  252. package/dist/wave-barrier.js.map +1 -0
  253. package/dist/wave-hatch.d.ts +4 -0
  254. package/dist/wave-hatch.d.ts.map +1 -0
  255. package/dist/wave-hatch.js +21 -0
  256. package/dist/wave-hatch.js.map +1 -0
  257. package/dist/wave-layout.d.ts +99 -0
  258. package/dist/wave-layout.d.ts.map +1 -0
  259. package/dist/wave-layout.js +130 -0
  260. package/dist/wave-layout.js.map +1 -0
  261. package/dist/working-calendar.d.ts +128 -4
  262. package/dist/working-calendar.d.ts.map +1 -1
  263. package/dist/working-calendar.js +725 -51
  264. package/dist/working-calendar.js.map +1 -1
  265. package/dist/working-span.d.ts +11 -0
  266. package/dist/working-span.d.ts.map +1 -0
  267. package/dist/working-span.js +18 -0
  268. package/dist/working-span.js.map +1 -0
  269. package/package.json +7 -7
  270. package/src/axis-columns.ts +140 -0
  271. package/src/calendar-resolver.ts +161 -0
  272. package/src/calendar.ts +4 -45
  273. package/src/canvas-width.ts +36 -0
  274. package/src/capacity.ts +71 -30
  275. package/src/chart-translation.ts +88 -0
  276. package/src/container-header-geometry.ts +116 -0
  277. package/src/dsl-utils.ts +8 -26
  278. package/src/edge-routing.ts +347 -491
  279. package/src/frame-tab-geometry.ts +78 -25
  280. package/src/header-card-geometry.ts +17 -2
  281. package/src/header-logo.ts +67 -0
  282. package/src/i18n.ts +22 -1
  283. package/src/index.ts +97 -12
  284. package/src/inline-date-pin-geometry.ts +55 -58
  285. package/src/item-bar-geometry.ts +341 -5
  286. package/src/item-port-geometry.ts +69 -0
  287. package/src/layout-context.ts +210 -72
  288. package/src/layout-insights.ts +109 -2
  289. package/src/layout.ts +57 -1530
  290. package/src/nodes/anchor-node.ts +11 -4
  291. package/src/nodes/content-extent.ts +107 -0
  292. package/src/nodes/dependency-edges.ts +171 -0
  293. package/src/nodes/footnote-node.ts +171 -31
  294. package/src/nodes/group-node.ts +92 -215
  295. package/src/nodes/header-node.ts +255 -0
  296. package/src/nodes/include-node.ts +124 -110
  297. package/src/nodes/item-measure.ts +723 -0
  298. package/src/nodes/item-node.ts +358 -80
  299. package/src/nodes/marker-band.ts +406 -0
  300. package/src/nodes/marker-geometry.ts +1 -1
  301. package/src/nodes/milestone-node.ts +321 -136
  302. package/src/nodes/nowline-node.ts +87 -0
  303. package/src/nodes/parallel-node.ts +69 -39
  304. package/src/nodes/region-markers.ts +194 -0
  305. package/src/nodes/roadmap-node.ts +46 -950
  306. package/src/nodes/row-track.ts +217 -0
  307. package/src/nodes/sequence-item.ts +433 -0
  308. package/src/nodes/sequence-one.ts +40 -0
  309. package/src/nodes/swimlane-node.ts +66 -206
  310. package/src/nodes/wave-node.ts +609 -0
  311. package/src/non-working-display.ts +32 -0
  312. package/src/phases/date-window.ts +191 -0
  313. package/src/phases/finalize.ts +1303 -0
  314. package/src/phases/measure.ts +329 -0
  315. package/src/phases/place.ts +318 -0
  316. package/src/renderable.ts +23 -3
  317. package/src/resolve-today.ts +28 -25
  318. package/src/row-packer.ts +85 -84
  319. package/src/schedule.ts +362 -114
  320. package/src/start-rules.ts +113 -0
  321. package/src/start-terms.ts +38 -0
  322. package/src/status-dot.ts +81 -0
  323. package/src/style-resolution.ts +27 -57
  324. package/src/text-measure.ts +71 -0
  325. package/src/themes/contrast.ts +34 -0
  326. package/src/themes/dark.ts +28 -1
  327. package/src/themes/grayscale.ts +28 -1
  328. package/src/themes/index.ts +10 -7
  329. package/src/themes/light.ts +28 -1
  330. package/src/themes/shape.ts +38 -0
  331. package/src/themes/shared.ts +85 -93
  332. package/src/time-scale.ts +117 -19
  333. package/src/types.ts +529 -20
  334. package/src/view-preset.ts +520 -27
  335. package/src/wave-barrier.ts +242 -0
  336. package/src/wave-hatch.ts +25 -0
  337. package/src/wave-layout.ts +203 -0
  338. package/src/working-calendar.ts +875 -57
  339. package/src/working-span.ts +19 -0
@@ -0,0 +1,406 @@
1
+ // MarkerBand — the marker row's entries, packer and placements. Anchors,
2
+ // milestones, the titled non-working runs and the working exceptions all
3
+ // pack into one band of rows above the chart. The measure phase packs an
4
+ // estimate to size the band before any lane is placed; finalize packs
5
+ // every marker once the canvas is final (specs/layout-phases.md §3.3).
6
+ // Both build their entries and turn the packed rows into y positions
7
+ // with the helpers here; `markerLabelsRightX` sizes the canvas for the
8
+ // labels that fit on neither side.
9
+
10
+ import type { AnchorDeclaration, MilestoneDeclaration } from '@nowline/core';
11
+ import { parseIsoDate, propValue } from '../dsl-utils.js';
12
+ import { GUTTER_PX } from '../themes/shared.js';
13
+ import type { TimeScale } from '../time-scale.js';
14
+ import type {
15
+ BoundingBox,
16
+ MarkerRowPlacement,
17
+ PositionedNonWorkingRun,
18
+ PositionedWorkingException,
19
+ } from '../types.js';
20
+ import {
21
+ MARKER_BOLD_WIDTH_FACTOR,
22
+ MARKER_DIAMOND_RADIUS_PX,
23
+ MARKER_LABEL_GAP_PX,
24
+ MARKER_LABEL_HEIGHT_PX,
25
+ MARKER_ROW_CENTER_OFFSET_PX,
26
+ MARKER_ROW_PITCH_PX,
27
+ } from './marker-geometry.js';
28
+ import type { MilestonePlacement } from './milestone-node.js';
29
+
30
+ /** A packed marker band: its rows and each marker's placement at the band's y. */
31
+ export interface MarkerBand {
32
+ /** Rows the band holds: at least 1 when it has any marker, else 0. */
33
+ rowCount: number;
34
+ placements: Map<string, MarkerRowPlacement>;
35
+ }
36
+
37
+ /** A marker pinned by its `date:`: a dated anchor or milestone inside the window. */
38
+ export type DatePinnedMarker = MarkerEntity & { date: Date; isMilestone: boolean };
39
+
40
+ /**
41
+ * The date-pinned anchors, then the date-pinned milestones, each at the x
42
+ * of its `date:`. A marker with no date, or a date outside the window,
43
+ * is left out; after-only milestones are placed with the lanes.
44
+ */
45
+ export function datePinnedMarkerEntries(
46
+ anchors: ReadonlyMap<string, AnchorDeclaration>,
47
+ milestones: ReadonlyMap<string, MilestoneDeclaration>,
48
+ scale: TimeScale,
49
+ ): DatePinnedMarker[] {
50
+ const entries: DatePinnedMarker[] = [];
51
+ const add = (
52
+ id: string,
53
+ decl: AnchorDeclaration | MilestoneDeclaration,
54
+ isMilestone: boolean,
55
+ ): void => {
56
+ const date = parseIsoDate(propValue(decl.properties, 'date'));
57
+ if (!date) return;
58
+ const x = scale.forwardWithinDomain(date);
59
+ if (x === null) return;
60
+ entries.push({
61
+ id,
62
+ centerX: x,
63
+ radius: MARKER_DIAMOND_RADIUS_PX,
64
+ title: decl.title ?? id,
65
+ fontSize: 10,
66
+ bold: isMilestone,
67
+ date,
68
+ isMilestone,
69
+ });
70
+ };
71
+ for (const [id, anchor] of anchors) add(id, anchor, false);
72
+ for (const [id, milestone] of milestones) add(id, milestone, true);
73
+ return entries;
74
+ }
75
+
76
+ /**
77
+ * The calendar's marker-row entries: the titled non-working runs, then the
78
+ * working exceptions. Callers append them after the anchors and
79
+ * milestones.
80
+ */
81
+ export function calendarMarkerEntries(
82
+ runs: ReadonlyArray<PositionedNonWorkingRun> | undefined,
83
+ exceptions: ReadonlyArray<PositionedWorkingException> | undefined,
84
+ ): MarkerEntity[] {
85
+ return [...nonWorkingRunEntries(runs), ...workingExceptionEntries(exceptions)];
86
+ }
87
+
88
+ /**
89
+ * Each packed placement at the band's y: row `r`'s centre sits
90
+ * `MARKER_ROW_CENTER_OFFSET_PX + r * MARKER_ROW_PITCH_PX` below
91
+ * `markerRowY`, and its label box 4 px above the centre.
92
+ */
93
+ export function markerRowPlacements(
94
+ packed: ReadonlyMap<string, PackedPlacement>,
95
+ markerRowY: number,
96
+ ): Map<string, MarkerRowPlacement> {
97
+ const out = new Map<string, MarkerRowPlacement>();
98
+ for (const [id, p] of packed) {
99
+ const centerY = markerRowY + MARKER_ROW_CENTER_OFFSET_PX + p.rowIndex * MARKER_ROW_PITCH_PX;
100
+ out.set(id, {
101
+ rowIndex: p.rowIndex,
102
+ centerY,
103
+ labelBox: { ...p.labelBox, y: centerY - 4 },
104
+ labelSide: p.labelSide,
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /** The marker-row id of a titled non-working run: `nonworking:<ISO from>`. */
111
+ export function nonWorkingRunMarkerId(run: PositionedNonWorkingRun): string {
112
+ return `nonworking:${run.from.toISOString().slice(0, 10)}`;
113
+ }
114
+
115
+ /** The marker-row id of a working exception: `working:<ISO from>`. */
116
+ export function workingExceptionMarkerId(exception: PositionedWorkingException): string {
117
+ return `working:${exception.from.toISOString().slice(0, 10)}`;
118
+ }
119
+
120
+ /** A zero-radius marker-row entry for a calendar label at `x`. */
121
+ function calendarLabelEntry(id: string, x: number, label: string): MarkerEntity {
122
+ return { id, centerX: x, radius: 0, title: label, fontSize: 10, bold: false };
123
+ }
124
+
125
+ /**
126
+ * Marker-row entries for the titled runs (specs/working-calendar.md
127
+ * §7.4): a zero-radius marker at the run's x (the seam under `hide`, the
128
+ * band's left edge under `show`) whose title is the run's label. Callers
129
+ * append them after the anchors and milestones, so the stable sort in
130
+ * `packMarkerRow` places a marker that ties a run on x first.
131
+ */
132
+ function nonWorkingRunEntries(
133
+ runs: ReadonlyArray<PositionedNonWorkingRun> | undefined,
134
+ ): MarkerEntity[] {
135
+ const entries: MarkerEntity[] = [];
136
+ for (const run of runs ?? []) {
137
+ if (run.label === undefined) continue;
138
+ entries.push(calendarLabelEntry(nonWorkingRunMarkerId(run), run.x, run.label));
139
+ }
140
+ return entries;
141
+ }
142
+
143
+ /**
144
+ * Marker-row entries for the working exceptions (specs/working-calendar.md
145
+ * §7.4), packed beside the titled runs' and on the same terms: a zero-radius
146
+ * marker at the first turned day's x.
147
+ */
148
+ function workingExceptionEntries(
149
+ exceptions: ReadonlyArray<PositionedWorkingException> | undefined,
150
+ ): MarkerEntity[] {
151
+ return (exceptions ?? []).map((exception) =>
152
+ calendarLabelEntry(workingExceptionMarkerId(exception), exception.x, exception.label),
153
+ );
154
+ }
155
+
156
+ /**
157
+ * The canvas right edge the marker-row labels need. A label (anchor,
158
+ * milestone, titled run or working exception) that fits neither right of
159
+ * its x (against `chartRightX`) nor left of it (against `chartLeftX`)
160
+ * would fall back to the left and draw over a beside header card, so the
161
+ * canvas grows to hold it on the right instead. A slipped milestone's
162
+ * label never fits on the left, where its span runs.
163
+ */
164
+ export function markerLabelsRightX(
165
+ entries: ReadonlyArray<MarkerEntity>,
166
+ chartLeftX: number,
167
+ chartRightX: number,
168
+ estimateTextWidth: (text: string, fontSize: number) => number,
169
+ ): number {
170
+ let rightX = chartRightX;
171
+ for (const e of entries) {
172
+ const labelWidth = markerLabelWidth(e, estimateTextWidth);
173
+ const rightEnd = e.centerX + e.radius + MARKER_LABEL_GAP_PX + labelWidth;
174
+ const leftStart = e.centerX - e.radius - MARKER_LABEL_GAP_PX - labelWidth;
175
+ const fitsLeft = leftStart >= chartLeftX && e.slipFromX === undefined;
176
+ if (rightEnd > chartRightX && !fitsLeft) {
177
+ rightX = Math.max(rightX, rightEnd + GUTTER_PX);
178
+ }
179
+ }
180
+ return rightX;
181
+ }
182
+
183
+ export interface MarkerEntity {
184
+ id: string;
185
+ centerX: number;
186
+ radius: number;
187
+ title: string;
188
+ fontSize: number;
189
+ bold: boolean;
190
+ /**
191
+ * A slipped milestone's planned x: its entry sits at the slipped x
192
+ * (`centerX`, where the label goes), and the span from the planned
193
+ * diamond to it is reserved on its row.
194
+ */
195
+ slipFromX?: number;
196
+ }
197
+
198
+ /** A marker label's width: bold labels widen by `MARKER_BOLD_WIDTH_FACTOR`. */
199
+ function markerLabelWidth(
200
+ e: MarkerEntity,
201
+ estimateTextWidth: (text: string, fontSize: number) => number,
202
+ ): number {
203
+ return estimateTextWidth(e.title, e.fontSize) * (e.bold ? MARKER_BOLD_WIDTH_FACTOR : 1);
204
+ }
205
+
206
+ export interface PackedPlacement {
207
+ rowIndex: number;
208
+ labelBox: BoundingBox;
209
+ labelSide: 'left' | 'right';
210
+ }
211
+
212
+ /**
213
+ * A slack or overrun arrow between two markers, drawn on `from`'s row
214
+ * between the two diamonds (specs/rendering.md, Milestones, "Marker
215
+ * predecessors"). A milestone's arrow from a group or parallel starts on
216
+ * the milestone's own row and is not one.
217
+ */
218
+ export interface MarkerArrow {
219
+ from: string;
220
+ to: string;
221
+ }
222
+
223
+ /** The arrows between markers that `placements` draw, source to target. */
224
+ export function markerArrows(placements: readonly MilestonePlacement[]): MarkerArrow[] {
225
+ const out: MarkerArrow[] = [];
226
+ for (const p of placements) {
227
+ for (const a of p.slackArrows ?? []) {
228
+ if ('marker' in a && a.marker !== p.id) out.push({ from: a.marker, to: p.id });
229
+ }
230
+ }
231
+ return out;
232
+ }
233
+
234
+ /**
235
+ * Pack anchors + milestones into marker rows using a bottom-first
236
+ * tick-order strategy. Markers default to the row CLOSEST to the
237
+ * chart; conflicts push them UP toward the date ticks (the inverse of
238
+ * swimlane items which default to the top and push down).
239
+ *
240
+ * Walk order is left-to-right by `centerX` (tick order, not file
241
+ * declaration). For each marker:
242
+ * 1. Try the bottommost existing row (highest `rowIndex`), right
243
+ * side first then left, working UP through the rows.
244
+ * 2. If neither side fits at any existing row, GROW: prepend a new
245
+ * row at `rowIndex = 0`. Every previously-placed marker has its
246
+ * `rowIndex` incremented by 1 so the bottom corridor stays
247
+ * stable as the band expands upward toward the ticks.
248
+ *
249
+ * `arrows` (the marker-to-marker arrows, `markerArrows`) add three rules,
250
+ * so an arrow crosses no other marker and ends on its target's line:
251
+ * - An arrow's run, on its source's row between the two diamonds, is
252
+ * reserved on that row like a label: no other diamond, label or run
253
+ * may overlap it.
254
+ * - A target sits at or above each source's row (cut lines run down
255
+ * from a diamond). A source whose target is placed and that fits no
256
+ * allowed row grows a fresh row right below the target instead of
257
+ * at the top; when the rules contradict (a marker both a source and
258
+ * a target on one row), it grows at the top.
259
+ * - A label takes a side its own arrows do not run along, when that
260
+ * side fits; otherwise it keeps the usual right-then-left order.
261
+ * With no arrows the pack is unchanged.
262
+ *
263
+ * Returns row-relative placements (no centerY yet — caller fills that
264
+ * in once `markerRowY` is known). Row indices follow the existing
265
+ * convention: 0 = top of band (closest to ticks), `rowCount - 1` =
266
+ * bottom of band (closest to chart). The "bottom-first" preference
267
+ * lives entirely in the search order and grow direction; geometry
268
+ * stays anchored to `markerRowY` at the top.
269
+ */
270
+ export function packMarkerRow(
271
+ entries: MarkerEntity[],
272
+ chartLeftX: number,
273
+ chartRightX: number,
274
+ estimateTextWidth: (text: string, fontSize: number) => number,
275
+ arrows: readonly MarkerArrow[] = [],
276
+ ): { placements: Map<string, PackedPlacement>; rowCount: number } {
277
+ const sorted = [...entries].sort((a, b) => a.centerX - b.centerX);
278
+ const placements = new Map<string, PackedPlacement>();
279
+ type Span = { left: number; right: number };
280
+ let rowSpans: Span[][] = [];
281
+ const byId = new Map(entries.map((e) => [e.id, e]));
282
+ const knownArrows = arrows.filter((a) => byId.has(a.from) && byId.has(a.to));
283
+
284
+ type Side = 'left' | 'right';
285
+ for (const e of sorted) {
286
+ const labelWidth = markerLabelWidth(e, estimateTextWidth);
287
+ const naturalRightX = e.centerX + e.radius + MARKER_LABEL_GAP_PX;
288
+ const naturalLeftX = e.centerX - e.radius - MARKER_LABEL_GAP_PX - labelWidth;
289
+ const fitsRightCanvas = naturalRightX + labelWidth <= chartRightX;
290
+ const fitsLeftCanvas = naturalLeftX >= chartLeftX;
291
+ const diamondLeft = e.centerX - e.radius;
292
+ const diamondRight = e.centerX + e.radius;
293
+
294
+ const sideXLeft = (s: Side): number => (s === 'right' ? naturalRightX : naturalLeftX);
295
+ const sideFitsCanvas = (s: Side): boolean =>
296
+ s === 'right' ? fitsRightCanvas : fitsLeftCanvas;
297
+ // The runs of e's own arrows, on e's row between the two diamonds,
298
+ // and a slipped milestone's span from its planned diamond.
299
+ const slipRun: Span[] =
300
+ e.slipFromX === undefined
301
+ ? []
302
+ : [{ left: e.slipFromX - e.radius, right: e.centerX - e.radius }];
303
+ const runs: Span[] = knownArrows
304
+ .filter((a) => a.from === e.id)
305
+ .map((a) => {
306
+ const to = byId.get(a.to) as MarkerEntity;
307
+ return {
308
+ left: Math.min(e.centerX, to.centerX) + e.radius,
309
+ right: Math.max(e.centerX, to.centerX) - to.radius,
310
+ };
311
+ })
312
+ .concat(slipRun)
313
+ .filter((r) => r.right > r.left);
314
+ // An arrow meets its target's line only when the target sits at or
315
+ // above the source's row (cut lines run down from a diamond).
316
+ let minRow = -1;
317
+ let maxRow = Number.POSITIVE_INFINITY;
318
+ for (const a of knownArrows) {
319
+ if (a.to === e.id) {
320
+ const from = placements.get(a.from);
321
+ if (from) maxRow = Math.min(maxRow, from.rowIndex);
322
+ }
323
+ if (a.from === e.id) {
324
+ const to = placements.get(a.to);
325
+ if (to) minRow = Math.max(minRow, to.rowIndex);
326
+ }
327
+ }
328
+ // A label on the side its own arrow leaves would sit on the arrow.
329
+ const crossesOwnRun = (s: Side): boolean => {
330
+ const xLeft = sideXLeft(s);
331
+ return runs.some((r) => r.left < xLeft + labelWidth && r.right > xLeft);
332
+ };
333
+ const freeSide = (s: Side): boolean => sideFitsCanvas(s) && !crossesOwnRun(s);
334
+ // A slipped milestone's label never goes back over its span; the
335
+ // canvas grew for it (`markerLabelsRightX`).
336
+ const growSide: Side =
337
+ freeSide('right') || e.slipFromX !== undefined
338
+ ? 'right'
339
+ : freeSide('left')
340
+ ? 'left'
341
+ : fitsRightCanvas
342
+ ? 'right'
343
+ : 'left';
344
+ const collidesAt = (xLeft: number, row: number): boolean => {
345
+ const extLeft = Math.min(diamondLeft, xLeft);
346
+ const extRight = Math.max(diamondRight, xLeft + labelWidth);
347
+ const own = [{ left: extLeft, right: extRight }, ...runs];
348
+ return rowSpans[row].some((s) => own.some((o) => s.left < o.right && s.right > o.left));
349
+ };
350
+
351
+ let placedRow = -1;
352
+ let placedSide: Side = 'right';
353
+ let placedXLeft = naturalRightX;
354
+
355
+ // Bottom-first: try the highest existing row first, working up
356
+ // toward row 0 (closest to the ticks).
357
+ outer: for (let row = rowSpans.length - 1; row >= 0; row--) {
358
+ if (row < minRow || row > maxRow) continue;
359
+ for (const side of ['right', 'left'] as const) {
360
+ if (!freeSide(side)) continue;
361
+ const xLeft = sideXLeft(side);
362
+ if (collidesAt(xLeft, row)) continue;
363
+ placedRow = row;
364
+ placedSide = side;
365
+ placedXLeft = xLeft;
366
+ break outer;
367
+ }
368
+ }
369
+
370
+ if (placedRow === -1 && minRow >= 0 && minRow < maxRow) {
371
+ // An arrow source that must stay at or below its target and fits
372
+ // no row there: a fresh row right below the target, which keeps
373
+ // every placed arrow's source at or below its target.
374
+ const at = minRow + 1;
375
+ for (const [id, p] of placements) {
376
+ if (p.rowIndex >= at) placements.set(id, { ...p, rowIndex: p.rowIndex + 1 });
377
+ }
378
+ rowSpans.splice(at, 0, []);
379
+ placedRow = at;
380
+ placedSide = growSide;
381
+ placedXLeft = sideXLeft(placedSide);
382
+ } else if (placedRow === -1) {
383
+ // No existing row fits — prepend a fresh top row and shift
384
+ // every previous placement DOWN by one slot (rowIndex += 1)
385
+ // so the bottom corridor stays stable.
386
+ for (const [id, p] of placements) {
387
+ placements.set(id, { ...p, rowIndex: p.rowIndex + 1 });
388
+ }
389
+ rowSpans = [[], ...rowSpans];
390
+ placedRow = 0;
391
+ placedSide = growSide;
392
+ placedXLeft = sideXLeft(placedSide);
393
+ }
394
+
395
+ const extLeft = Math.min(diamondLeft, placedXLeft);
396
+ const extRight = Math.max(diamondRight, placedXLeft + labelWidth);
397
+ rowSpans[placedRow].push({ left: extLeft, right: extRight }, ...runs);
398
+ placements.set(e.id, {
399
+ rowIndex: placedRow,
400
+ labelBox: { x: placedXLeft, y: 0, width: labelWidth, height: MARKER_LABEL_HEIGHT_PX },
401
+ labelSide: placedSide,
402
+ });
403
+ }
404
+
405
+ return { placements, rowCount: rowSpans.length };
406
+ }
@@ -8,7 +8,7 @@
8
8
  // the timeline header. The marker band stacks N rows tall when
9
9
  // anchors and milestones can't fit on a single row without colliding.
10
10
  // - The bare `13` (= PITCH / 2) midpoint that used to sprinkle
11
- // `roadmap-node.ts` is derived from this constant — bumping the
11
+ // the roadmap composition code is derived from this constant — bumping the
12
12
  // pitch automatically re-centers labels.
13
13
  //
14
14
  // Diamond + label box dimensions are reused by every marker (anchor +