@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,70 +1,30 @@
1
1
  // GroupNode — Renderable for a `group { ... }` block. Sequences
2
- // children inside the group's content area using the same row-pack
3
- // engine `SwimlaneNode` uses, so an item whose desired start collides
4
- // with a sibling's `rightEdge`, caption `spillX`, or a slack-arrow
5
- // corridor bumps to a new row inside the group. The group's reported
6
- // `box.height` grows with the row stack so parent containers stack
7
- // against the actual painted extent.
2
+ // children inside the group's body through `sequenceRowTrack`
3
+ // (row-track.ts), the loop `SwimlaneNode` uses, so an item whose desired
4
+ // start collides with a sibling's `rightEdge`, caption `spillX`, or a
5
+ // slack-arrow corridor bumps to a new row inside the group.
8
6
  //
9
- // Filled-style groups paint a title chiclet flush in the upper-left
10
- // corner of the box; the layout reserves vertical top padding equal
11
- // to the chiclet height plus a small gutter before the first inner
12
- // row begins.
13
-
14
- import type { EntityProperty, GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
15
- import { isItemDeclaration } from '@nowline/core';
16
- import { deriveItemDurationDays } from '../calendar.js';
17
- import { propValues } from '../dsl-utils.js';
7
+ // The group is drawn above and around its content, never beside it
8
+ // (specs/true-width-layout.md §3.3): a header band (title, inline-date
9
+ // glyphs, a bracket spanning exactly its dates) over a body padded above
10
+ // and below its rows. Its box spans exactly its children's earliest start
11
+ // and latest end; see `container-header-geometry.ts`.
12
+
13
+ import type { GroupBlock } from '@nowline/core';
14
+ import { containerChrome } from '../container-header-geometry.js';
15
+ import { propValue, propValues } from '../dsl-utils.js';
18
16
  import { computeContainerInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
19
- import type { LayoutContext, TrackCursor } from '../layout-context.js';
20
- import { RowPacker } from '../row-packer.js';
17
+ import type { LayoutContext, LayoutHelpers, TrackCursor } from '../layout-context.js';
18
+ import { resolvePinnedOrSequentialStart } from '../start-rules.js';
21
19
  import { resolveStyle } from '../style-resolution.js';
22
- import {
23
- GROUP_BOTTOM_PAD_PX,
24
- GROUP_BRACKET_LABEL_OVERHANG_PX,
25
- GROUP_TITLE_TAB_GUTTER_PX,
26
- GROUP_TITLE_TAB_HEIGHT_PX,
27
- ITEM_INSET_PX,
28
- MIN_ITEM_WIDTH,
29
- TRACK_BLOCK_TAIL_GUTTER_PX,
30
- } from '../themes/shared.js';
31
- import type {
32
- BoundingBox,
33
- PositionedGroup,
34
- PositionedItem,
35
- PositionedTrackChild,
36
- } from '../types.js';
37
-
38
- export interface GroupNodeDeps {
39
- sequenceItem: (
40
- child: ItemDeclaration,
41
- cursor: TrackCursor,
42
- ctx: LayoutContext,
43
- ownerOverride?: string,
44
- ) => PositionedItem;
45
- sequenceOne: (
46
- child: ItemDeclaration | GroupBlock | ParallelBlock,
47
- cursor: TrackCursor,
48
- ctx: LayoutContext,
49
- ) => PositionedTrackChild;
50
- resolveChildStart: (
51
- props: EntityProperty[],
52
- seqDefault: number,
53
- laneLeftX: number,
54
- ctx: LayoutContext,
55
- ) => number;
56
- newCursor: (x: number, y: number) => TrackCursor;
57
- estimateTextWidth: (text: string, fontSize: number) => number;
58
- /** Predict the extra vertical height an item's wrapped label-chip
59
- * rows will add to its bar; used to size the row-packer's row
60
- * pitch ahead of the call to `sequenceItem`. */
61
- predictItemChipExtraHeight: (item: ItemDeclaration, ctx: LayoutContext) => number;
62
- }
20
+ import type { BoundingBox, PositionedGroup } from '../types.js';
21
+ import { waveFloorX } from '../wave-layout.js';
22
+ import { sequenceRowTrack } from './row-track.js';
63
23
 
64
24
  export class GroupNode {
65
25
  constructor(
66
26
  public readonly node: GroupBlock,
67
- private readonly deps: GroupNodeDeps,
27
+ private readonly helpers: LayoutHelpers,
68
28
  ) {}
69
29
 
70
30
  get id(): string {
@@ -72,18 +32,41 @@ export class GroupNode {
72
32
  }
73
33
 
74
34
  /**
75
- * Sequence children inside the group's content area, advance the
76
- * parent `cursor` past the group's right edge plus
77
- * `TRACK_BLOCK_TAIL_GUTTER_PX` of breathing room, and return a
78
- * `PositionedGroup` whose `box.height` covers the chiclet pad,
79
- * every row-packed child row, and the bottom pad.
35
+ * Sequence children inside the group's body, advance the parent
36
+ * `cursor` to the group's end in time (its children's duration ends,
37
+ * never a caption spill or a header title), and return a
38
+ * `PositionedGroup` whose box covers the header, the padded rows and
39
+ * the group's dates.
80
40
  */
81
41
  place(cursor: TrackCursor, ctx: LayoutContext): PositionedGroup {
82
42
  const { node } = this;
83
- const { deps } = this;
43
+ const { helpers } = this;
84
44
  const style = resolveStyle('group', node.properties, ctx.styleCtx);
85
- const startX = cursor.x;
86
- const title = node.title ?? node.name;
45
+ // The group starts after every `after:` term, then its own wave and
46
+ // its lead wave floor it, so its box never opens in a column before
47
+ // its first piece of work (specs/waves.md §5.1). A lane child's
48
+ // cursor already sits there (`resolveChildStart`), so this is the
49
+ // identity for it; a parallel's group track starts at the parallel's
50
+ // start, so this is where its `after:` applies
51
+ // (specs/scheduler-unification.md §8 decision 5).
52
+ const startX = waveFloorX(
53
+ node,
54
+ resolvePinnedOrSequentialStart(node.properties, cursor.x, ctx),
55
+ ctx,
56
+ );
57
+ // In a roadmap with waves, a group with no title, no `style:` and
58
+ // no `labels:` that carries `wave:` only assigns membership: it
59
+ // draws nothing, which would otherwise sit exactly on the wave
60
+ // boundary its box opens at, and reserves nothing
61
+ // (specs/waves.md §9.7). An id alone does not make it titled
62
+ // (Example 4's `api-track`).
63
+ const waveOnly =
64
+ ctx.waves !== undefined &&
65
+ node.title === undefined &&
66
+ propValue(node.properties, 'wave') !== undefined &&
67
+ propValue(node.properties, 'style') === undefined &&
68
+ propValues(node.properties, 'labels').length === 0;
69
+ const title = waveOnly ? undefined : (node.title ?? node.name);
87
70
 
88
71
  // Group children chain in time inside the group, so they
89
72
  // form one sub-flow under the parent's flow path. See
@@ -91,170 +74,65 @@ export class GroupNode {
91
74
  const previousFlowKey = ctx.currentFlowKey;
92
75
  ctx.nextGroupId += 1;
93
76
  ctx.currentFlowKey = `${previousFlowKey}/group:${node.name ?? `group-${ctx.nextGroupId}`}`;
94
- // Mirrors `renderGroup`'s `hasFill` decision so the painted box
95
- // and the layout's reservation agree on whether a chiclet exists.
96
- const hasChiclet = style.bg !== 'none' && style.bg !== '#ffffff' && Boolean(title);
97
- const topPad = hasChiclet ? GROUP_TITLE_TAB_HEIGHT_PX + GROUP_TITLE_TAB_GUTTER_PX : 0;
98
- const bottomPad = hasChiclet ? GROUP_BOTTOM_PAD_PX : 0;
99
- // Bracket-style groups paint their label at `box.y - 2`, so the
100
- // label glyph overhangs ABOVE box.y. Without an explicit
101
- // reservation, two bracket-titled groups stacked in a parallel
102
- // collide: the previous sibling's bracket-foot ends at its
103
- // box.bottom, and the next sibling's label visual top sits just
104
- // above box.y — they touch in the gap. Shift `box.y` down by
105
- // the overhang amount so the glyph lands in space we own.
106
- const bracketLabelOverhang = !hasChiclet && title ? GROUP_BRACKET_LABEL_OVERHANG_PX : 0;
107
- const startY = cursor.y + bracketLabelOverhang;
108
-
109
- const step = ctx.bandScale.step();
110
- const groupContentLeftX = startX;
111
- const packer = new RowPacker({
112
- laneLeftX: groupContentLeftX,
113
- originY: startY + topPad,
114
- minRowHeight: step,
115
- slackCorridors: ctx.slackCorridors,
77
+ const afterDate = pickInlineDate(propValues(node.properties, 'after'));
78
+ const beforeDate = pickInlineDate(propValues(node.properties, 'before'));
79
+ const chrome = containerChrome({
80
+ drawn: !waveOnly,
81
+ hasTitle: Boolean(title),
82
+ hasPins: Boolean(afterDate || beforeDate),
83
+ hasBracket: style.bracket !== 'none',
116
84
  });
117
- let timeCursorX = groupContentLeftX;
118
-
119
- const children: PositionedTrackChild[] = [];
120
- for (const child of node.content) {
121
- if (child.$type === 'DescriptionDirective') continue;
122
-
123
- if (!isItemDeclaration(child)) {
124
- const blockProps = (child as ParallelBlock | GroupBlock).properties ?? [];
125
- const blockStart = deps.resolveChildStart(
126
- blockProps,
127
- timeCursorX,
128
- groupContentLeftX,
129
- ctx,
130
- );
131
- const { rowIndex, y: blockY } = packer.placeBlock();
132
- const innerCursor = deps.newCursor(blockStart, blockY);
133
- const positioned = deps.sequenceOne(
134
- child as ItemDeclaration | GroupBlock | ParallelBlock,
135
- innerCursor,
136
- ctx,
137
- );
138
- children.push(positioned);
139
- const blockEnd = positioned.box.x + positioned.box.width;
140
- const blockHeight = Math.max(step, innerCursor.height);
141
- packer.commitBlock({
142
- rowIndex,
143
- placed: positioned,
144
- blockHeight,
145
- blockEnd,
146
- });
147
- timeCursorX = Math.max(timeCursorX, blockEnd);
148
- continue;
149
- }
150
-
151
- const props = (child as ItemDeclaration).properties;
152
- const desiredStart = deps.resolveChildStart(props, timeCursorX, groupContentLeftX, ctx);
153
- // Predict logical extent so the row-packer can bump on
154
- // collision before we hand off to `sequenceItem`. Mirrors
155
- // SwimlaneNode's pre-flight width math.
156
- const durationDays = deriveItemDurationDays(props, ctx.sizes, ctx.cal);
157
- const naturalWidth = Math.max(MIN_ITEM_WIDTH, durationDays * ctx.timeline.pixelsPerDay);
158
- const desiredEnd = desiredStart + naturalWidth;
159
- const childId = (child as ItemDeclaration).name ?? '';
160
-
161
- const chipExtra = deps.predictItemChipExtraHeight(child as ItemDeclaration, ctx);
162
- const predictedHeight = step + chipExtra;
163
- const { rowIndex, y: rowY } = packer.placeItem({
164
- childId,
165
- desiredStart,
166
- desiredEnd,
167
- // Row pitch = `step()` + extra chip-row height. Keeps
168
- // the inter-row visible gap (= step - bandwidth) intact
169
- // when an item's labels wrap and grow the bar.
170
- predictedHeight,
171
- });
172
85
 
173
- const innerCursor = deps.newCursor(desiredStart, rowY);
174
- const positioned = deps.sequenceItem(child as ItemDeclaration, innerCursor, ctx);
175
- children.push(positioned);
176
-
177
- const itemLogicalEnd = positioned.box.x + positioned.box.width + ITEM_INSET_PX;
178
- timeCursorX = Math.max(timeCursorX, itemLogicalEnd);
179
-
180
- let spillReservation: number | null = null;
181
- const hasAnySpill =
182
- positioned.textSpills ||
183
- positioned.chipsOutside ||
184
- positioned.dotSpills ||
185
- positioned.iconSpills ||
186
- positioned.footnoteSpills;
187
- if (hasAnySpill) {
188
- const farRight = Math.max(
189
- positioned.decorationsRightX,
190
- positioned.chipsOutside ? positioned.chipsRightX : 0,
191
- );
192
- spillReservation = farRight + 6;
193
- }
194
-
195
- packer.commitItem({
196
- rowIndex,
197
- placed: positioned,
198
- logicalEnd: itemLogicalEnd,
199
- spillReservation,
200
- rowHeight: predictedHeight,
201
- });
202
- }
203
-
204
- const innerHeight = Math.max(step, packer.usedHeight());
205
- // The group's painted box hugs everything its children put on
206
- // screen, including caption text that spills past a bar's right
207
- // edge. `usedRightX` is the rightmost extent any row reached —
208
- // either a bar's logical right or a caption's reserved spill —
209
- // so the orange tint visually "owns" the spilled title/meta.
210
- //
211
- // The cursor channel (`cursor.x` / `cursor.maxX`) stays on the
212
- // COMPACT `timeCursorX` (bar logical ends only). Bubbling the
213
- // wide `visualRightX` upward would propagate through
214
- // `ParallelNode.maxRight → parallel.box.width → swimlane
215
- // blockEnd → timeCursorX`, pushing every subsequent sibling
216
- // right by the spill width — including siblings on entirely
217
- // different rows of the parent swimlane. The painted box and
218
- // the logical cursor advance are intentionally decoupled here.
219
- const visualRightX = Math.max(timeCursorX, packer.usedRightX());
86
+ const track = sequenceRowTrack(
87
+ node,
88
+ { leftX: startX, firstRowY: cursor.y + chrome.header + chrome.pad },
89
+ ctx,
90
+ helpers,
91
+ );
92
+ const { children, timeCursorX, timeStartX } = track;
93
+
94
+ // Every row's height carries the inter-row gap below it; the body
95
+ // ends `pad` below the last child's painted bottom, not below that
96
+ // gap, and the cursor adds the gap back after the body so a sibling
97
+ // stacked below keeps the same rhythm as two items.
98
+ const step = ctx.bandScale.step();
99
+ const interRowGap = step - ctx.bandScale.bandwidth();
100
+ const contentHeight = Math.max(step, track.packer.usedHeight()) - interRowGap;
101
+ // The bracket and body span exactly the children's dates
102
+ // (specs/true-width-layout.md §3.3): what the group paints past them
103
+ // (a spilled caption, its title) is footprint the row packer keeps,
104
+ // not width.
220
105
  const box: BoundingBox = {
221
- x: startX,
222
- y: startY,
223
- width: visualRightX - startX,
224
- height: topPad + innerHeight + bottomPad,
106
+ x: timeStartX,
107
+ y: cursor.y,
108
+ width: timeCursorX - timeStartX,
109
+ height: chrome.header + chrome.pad + contentHeight + chrome.pad,
225
110
  };
226
- cursor.x = timeCursorX + TRACK_BLOCK_TAIL_GUTTER_PX;
227
- cursor.maxX = Math.max(cursor.maxX, cursor.x);
228
- // The painted `box.height` is the group's tight visual
229
- // footprint (chiclet + content + bottomPad). The cursor
230
- // advance, however, must include one inter-row gap
231
- // (`step - bandwidth`) so a sibling stacked below in a
232
- // `parallel` (or any track-pack consumer) doesn't butt
233
- // right up against the group's bottom edge. Items naturally
234
- // include that gap because they report `cursor.height = step`
235
- // (bandwidth + gap); groups need to add it explicitly since
236
- // their painted height is gap-less.
237
- const interRowGap = ctx.bandScale.step() - ctx.bandScale.bandwidth();
238
- cursor.height = Math.max(cursor.height, bracketLabelOverhang + box.height + interRowGap);
111
+ // A parallel joins on its tracks' ends, as engine C does
112
+ // (specs/scheduler-unification.md §8 decision 8).
113
+ cursor.startX = box.x;
114
+ cursor.x = timeCursorX;
115
+ cursor.height = Math.max(cursor.height, box.height + interRowGap);
239
116
  const id = node.name;
117
+ // `after:` the group is a time path: its last child's duration end.
240
118
  if (id) {
241
119
  ctx.entityLeftEdges.set(id, box.x);
242
- ctx.entityRightEdges.set(id, box.x + box.width);
120
+ ctx.entityRightEdges.set(id, timeCursorX);
243
121
  }
244
122
  ctx.currentFlowKey = previousFlowKey;
245
- const inlineDatePins = computeContainerInlineDatePins({
246
- box,
247
- afterDate: pickInlineDate(propValues(node.properties, 'after')),
248
- beforeDate: pickInlineDate(propValues(node.properties, 'before')),
249
- });
123
+ const inlineDatePins = waveOnly
124
+ ? []
125
+ : computeContainerInlineDatePins({ box, afterDate, beforeDate, title });
250
126
  return {
251
127
  kind: 'group',
252
128
  id,
253
129
  title,
254
130
  box,
131
+ headerHeight: chrome.header,
255
132
  children,
256
133
  style,
257
134
  inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
135
+ ...(waveOnly ? { waveOnly: true as const } : {}),
258
136
  };
259
137
  }
260
138
  }
@@ -0,0 +1,255 @@
1
+ // HeaderNode — the roadmap header (title, author, company logo) in both
2
+ // positions, `beside` (a card left of the chart) and `above` (a band over
3
+ // it). `measure` sizes it before anything else is laid out, because the
4
+ // header decides where the chart starts: its left edge in beside mode,
5
+ // its top in above mode, and how low the header rows must end so the
6
+ // beside card fits (specs/layout-phases.md §3.7).
7
+ //
8
+ // HeaderNode does not implement `Renderable`: that signature is built
9
+ // around a time scale and a row band, which a header has no use for.
10
+
11
+ import {
12
+ HEADER_AUTHOR_FONT_SIZE_PX,
13
+ HEADER_AUTHOR_LINE_HEIGHT_PX,
14
+ HEADER_CARD_PADDING_BOTTOM,
15
+ HEADER_CARD_PADDING_TOP,
16
+ HEADER_CARD_PADDING_X,
17
+ HEADER_LOGO_GAP_PX,
18
+ HEADER_LOGO_PAD_Y_PX,
19
+ HEADER_TITLE_FONT_SIZE_PX,
20
+ HEADER_TITLE_LINE_HEIGHT_PX,
21
+ HEADER_TITLE_TO_AUTHOR_GAP_PX,
22
+ } from '../header-card-geometry.js';
23
+ import type { LogoDimensions, ScaledLogo } from '../header-logo.js';
24
+ import { estimateTextWidth, wrapText } from '../text-measure.js';
25
+ import {
26
+ HEADER_ABOVE_HEIGHT_PX,
27
+ HEADER_BESIDE_MAX_WIDTH_PX,
28
+ HEADER_BESIDE_MIN_WIDTH_PX,
29
+ } from '../themes/shared.js';
30
+ import type { BoundingBox, HeaderPosition, PositionedHeader, ResolvedStyle } from '../types.js';
31
+
32
+ // Left margin between the canvas's left edge and the visible card. The
33
+ // matching right-side breathing room is owned by `GUTTER_PX` (the canonical
34
+ // content gutter, applied between `chartLeftX` and `originX`), so the gap
35
+ // from the card's right edge to the timeline strip is the same as the gap
36
+ // between two adjacent items.
37
+ const HEADER_CARD_OUTER_PAD = 6;
38
+
39
+ /** Clearance (px) the beside card keeps from the canvas top. */
40
+ const HEADER_CARD_TOP_INSET = 4;
41
+
42
+ /** Gap (px) between the header (or the canvas top) and the chart. */
43
+ const CHART_TOP_GAP_PX = 8;
44
+
45
+ export interface MeasuredHeader {
46
+ position: HeaderPosition;
47
+ titleLines: string[];
48
+ authorLines: string[];
49
+ cardWidth: number;
50
+ /** Card height, grown when the logo is taller than the text. */
51
+ cardHeight: number;
52
+ /** Card height the title / author text alone needs. */
53
+ textCardHeight: number;
54
+ /** Width the logo takes left of the text, gap included; 0 without a logo. */
55
+ logoSlot: number;
56
+ logo?: ScaledLogo;
57
+ /** Beside: the card's box width, which is also `chartLeftX`. Above: 0. */
58
+ chartLeftX: number;
59
+ /** Above: the band height, bumped for an `xl` logo. Beside never reads it. */
60
+ aboveHeight: number;
61
+ /** Where the chart may start: below the canvas top, or below the above band. */
62
+ chartTopY: number;
63
+ /** Beside: the lowest the header rows may end so the card fits (card + 4 px inset). Above: 0. */
64
+ minRowsBottomY: number;
65
+ }
66
+
67
+ /** The positioned header without its attribution box, which finalize adds last. */
68
+ export type PositionedHeaderParts = Omit<PositionedHeader, 'attributionBox'>;
69
+
70
+ /** Where the chart ended up, as far as the header needs to know. */
71
+ export interface HeaderFrame {
72
+ /** Bottom of the header rows; the beside card's bottom hugs it. */
73
+ rowsBottomY: number;
74
+ /** Bottom of the chart; the beside header column runs down to it. */
75
+ chartBottomY: number;
76
+ /** The final canvas right edge; the above band spans to it. */
77
+ canvasRightX: number;
78
+ }
79
+
80
+ export interface HeaderNodeInput {
81
+ /** The roadmap's title (or name); an empty string when it has neither. */
82
+ title: string;
83
+ author?: string;
84
+ /** The resolved `default roadmap` style; `headerPosition` picks the mode. */
85
+ style: ResolvedStyle;
86
+ /** The drawn logo size, when the file declares a logo and the caller loaded one. */
87
+ logo?: ScaledLogo;
88
+ }
89
+
90
+ export class HeaderNode {
91
+ constructor(private readonly input: HeaderNodeInput) {}
92
+
93
+ measure(): MeasuredHeader {
94
+ const { title, author, style, logo } = this.input;
95
+ const isBeside = style.headerPosition === 'beside';
96
+ // Pre-size the beside-mode header card. Width = max line width +
97
+ // padding, clamped to MIN..MAX with word-wrap once the title
98
+ // exceeds MAX. Above mode keeps the fixed-strip geometry (full
99
+ // canvas width, fixed height) but still reads the wrapped lines.
100
+ const sized = sizeBesideHeader(title, author, logo);
101
+ // Above-mode strip: fixed height, bumped only when the logo plus
102
+ // its clearance is taller (`logo-size:xl`).
103
+ const aboveHeight = logo
104
+ ? Math.max(HEADER_ABOVE_HEIGHT_PX, logo.height + 2 * HEADER_LOGO_PAD_Y_PX)
105
+ : HEADER_ABOVE_HEIGHT_PX;
106
+ return {
107
+ position: style.headerPosition,
108
+ titleLines: sized.titleLines,
109
+ authorLines: sized.authorLines,
110
+ cardWidth: sized.cardWidth,
111
+ cardHeight: sized.cardHeight,
112
+ textCardHeight: sized.textCardHeight,
113
+ logoSlot: sized.logoSlot,
114
+ ...(logo ? { logo } : {}),
115
+ chartLeftX: isBeside ? sized.boxWidth : 0,
116
+ aboveHeight,
117
+ chartTopY: isBeside ? CHART_TOP_GAP_PX : aboveHeight + CHART_TOP_GAP_PX,
118
+ // In beside mode the card's BOTTOM aligns with the bottom of the
119
+ // header rows so it visually anchors to the chart's top. The
120
+ // rows must end low enough for the card plus a 4 px top inset;
121
+ // the measure phase pushes the timeline down when they would not.
122
+ minRowsBottomY: isBeside ? sized.cardHeight + HEADER_CARD_TOP_INSET : 0,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The header box, the card, the logo box and the text inset, once the
128
+ * chart's extent is known. Beside: a column `chartLeftX` wide down to
129
+ * the chart bottom, with the card's bottom on `rowsBottomY`. Above: a
130
+ * band `aboveHeight` tall across the canvas, the card filling it.
131
+ */
132
+ place(measured: MeasuredHeader, frame: HeaderFrame): PositionedHeaderParts {
133
+ const { title, author, style } = this.input;
134
+ const isBeside = measured.position === 'beside';
135
+ const box: BoundingBox = isBeside
136
+ ? { x: 0, y: 0, width: measured.chartLeftX, height: frame.chartBottomY }
137
+ : { x: 0, y: 0, width: frame.canvasRightX, height: measured.aboveHeight };
138
+ // The visible card inside the box. Beside, its BOTTOM hugs the
139
+ // bottom of the header rows so the title block anchors to the
140
+ // chart's top edge whichever header rows are present; the measure
141
+ // phase already pushed the rows down far enough for the card to
142
+ // keep HEADER_CARD_TOP_INSET from the canvas top.
143
+ const cardBox: BoundingBox = isBeside
144
+ ? {
145
+ x: HEADER_CARD_OUTER_PAD,
146
+ y: frame.rowsBottomY - measured.cardHeight,
147
+ width: measured.cardWidth,
148
+ height: measured.cardHeight,
149
+ }
150
+ : { x: 0, y: 0, width: frame.canvasRightX, height: measured.aboveHeight };
151
+ // Logo at the card's left padding, vertically centered; the text
152
+ // shifts right past it and centers in the (possibly taller) card.
153
+ let logo: PositionedHeader['logo'];
154
+ let textInset: PositionedHeader['textInset'];
155
+ if (measured.logo) {
156
+ logo = {
157
+ box: {
158
+ x: box.x + cardBox.x + HEADER_CARD_PADDING_X,
159
+ y: box.y + cardBox.y + (cardBox.height - measured.logo.height) / 2,
160
+ width: measured.logo.width,
161
+ height: measured.logo.height,
162
+ },
163
+ assetRef: measured.logo.assetRef,
164
+ intrinsic: measured.logo.intrinsic,
165
+ };
166
+ textInset = {
167
+ x: measured.logoSlot,
168
+ y: Math.max(0, (cardBox.height - measured.textCardHeight) / 2),
169
+ };
170
+ }
171
+ return {
172
+ box,
173
+ position: style.headerPosition,
174
+ title,
175
+ author,
176
+ titleLines: measured.titleLines,
177
+ authorLines: measured.authorLines,
178
+ cardBox,
179
+ logo,
180
+ ...(textInset ? { textInset } : {}),
181
+ style,
182
+ };
183
+ }
184
+ }
185
+
186
+ /** Sized output from the beside-mode header word-wrap pass. */
187
+ interface SizedHeader {
188
+ titleLines: string[];
189
+ authorLines: string[];
190
+ cardWidth: number;
191
+ cardHeight: number;
192
+ textCardHeight: number;
193
+ logoSlot: number;
194
+ boxWidth: number;
195
+ }
196
+
197
+ function sizeBesideHeader(
198
+ title: string,
199
+ author: string | undefined,
200
+ logo?: LogoDimensions,
201
+ ): SizedHeader {
202
+ const maxContentWidth = HEADER_BESIDE_MAX_WIDTH_PX - 2 * HEADER_CARD_PADDING_X;
203
+ const titleLines = wrapText(title, maxContentWidth, HEADER_TITLE_FONT_SIZE_PX);
204
+ const authorLines = wrapText(author ?? '', maxContentWidth, HEADER_AUTHOR_FONT_SIZE_PX);
205
+
206
+ let widest = 0;
207
+ for (const line of titleLines)
208
+ widest = Math.max(widest, estimateTextWidth(line, HEADER_TITLE_FONT_SIZE_PX));
209
+ for (const line of authorLines)
210
+ widest = Math.max(widest, estimateTextWidth(line, HEADER_AUTHOR_FONT_SIZE_PX));
211
+
212
+ // A logo sits left of the text and widens the card past the text-only
213
+ // MAX clamp (which bounds the text column, not the logo slot).
214
+ const hasText = titleLines.length > 0 || authorLines.length > 0;
215
+ const logoSlot = logo ? logo.width + (hasText ? HEADER_LOGO_GAP_PX : 0) : 0;
216
+
217
+ const naturalCardWidth = widest + 2 * HEADER_CARD_PADDING_X;
218
+ // `HEADER_BESIDE_{MIN,MAX}_WIDTH_PX` bound the **boxWidth** (= cardWidth
219
+ // + left outer pad). Subtract one outer pad to derive the cardWidth
220
+ // bounds.
221
+ const cardWidth = Math.max(
222
+ HEADER_BESIDE_MIN_WIDTH_PX - HEADER_CARD_OUTER_PAD,
223
+ Math.min(HEADER_BESIDE_MAX_WIDTH_PX - HEADER_CARD_OUTER_PAD, naturalCardWidth) + logoSlot,
224
+ );
225
+
226
+ const titleBlockHeight =
227
+ titleLines.length > 0 ? (titleLines.length - 1) * HEADER_TITLE_LINE_HEIGHT_PX : 0;
228
+ const authorBlockHeight =
229
+ authorLines.length > 0
230
+ ? HEADER_TITLE_TO_AUTHOR_GAP_PX +
231
+ (authorLines.length - 1) * HEADER_AUTHOR_LINE_HEIGHT_PX
232
+ : 0;
233
+ const textCardHeight =
234
+ HEADER_CARD_PADDING_TOP + titleBlockHeight + authorBlockHeight + HEADER_CARD_PADDING_BOTTOM;
235
+ // A logo taller than the text block grows the card; logo and text are
236
+ // then both centered vertically.
237
+ const cardHeight = logo
238
+ ? Math.max(textCardHeight, logo.height + 2 * HEADER_LOGO_PAD_Y_PX)
239
+ : textCardHeight;
240
+
241
+ // `boxWidth` only includes the LEFT outer pad — the right-side breathing
242
+ // room between the card and the chart is owned by `GUTTER_PX` in the
243
+ // measure phase. So `boxWidth` doubles as `chartLeftX` (the card's right
244
+ // edge in canvas coordinates).
245
+ const boxWidth = cardWidth + HEADER_CARD_OUTER_PAD;
246
+ return {
247
+ titleLines,
248
+ authorLines,
249
+ cardWidth,
250
+ cardHeight,
251
+ textCardHeight,
252
+ logoSlot,
253
+ boxWidth,
254
+ };
255
+ }