@nowline/layout 0.8.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/dist/axis-columns.d.ts +70 -0
  2. package/dist/axis-columns.d.ts.map +1 -0
  3. package/dist/axis-columns.js +104 -0
  4. package/dist/axis-columns.js.map +1 -0
  5. package/dist/calendar-resolver.d.ts +27 -0
  6. package/dist/calendar-resolver.d.ts.map +1 -0
  7. package/dist/calendar-resolver.js +132 -0
  8. package/dist/calendar-resolver.js.map +1 -0
  9. package/dist/calendar.d.ts +0 -19
  10. package/dist/calendar.d.ts.map +1 -1
  11. package/dist/calendar.js +4 -44
  12. package/dist/calendar.js.map +1 -1
  13. package/dist/canvas-width.d.ts +23 -0
  14. package/dist/canvas-width.d.ts.map +1 -0
  15. package/dist/canvas-width.js +26 -0
  16. package/dist/canvas-width.js.map +1 -0
  17. package/dist/capacity.d.ts +28 -0
  18. package/dist/capacity.d.ts.map +1 -1
  19. package/dist/capacity.js +64 -29
  20. package/dist/capacity.js.map +1 -1
  21. package/dist/chart-translation.d.ts +22 -0
  22. package/dist/chart-translation.d.ts.map +1 -0
  23. package/dist/chart-translation.js +83 -0
  24. package/dist/chart-translation.js.map +1 -0
  25. package/dist/container-header-geometry.d.ts +64 -0
  26. package/dist/container-header-geometry.d.ts.map +1 -0
  27. package/dist/container-header-geometry.js +89 -0
  28. package/dist/container-header-geometry.js.map +1 -0
  29. package/dist/dsl-utils.d.ts +3 -4
  30. package/dist/dsl-utils.d.ts.map +1 -1
  31. package/dist/dsl-utils.js +8 -25
  32. package/dist/dsl-utils.js.map +1 -1
  33. package/dist/edge-routing.d.ts +69 -78
  34. package/dist/edge-routing.d.ts.map +1 -1
  35. package/dist/edge-routing.js +271 -401
  36. package/dist/edge-routing.js.map +1 -1
  37. package/dist/frame-tab-geometry.d.ts +24 -14
  38. package/dist/frame-tab-geometry.d.ts.map +1 -1
  39. package/dist/frame-tab-geometry.js +69 -23
  40. package/dist/frame-tab-geometry.js.map +1 -1
  41. package/dist/header-card-geometry.d.ts +12 -0
  42. package/dist/header-card-geometry.d.ts.map +1 -1
  43. package/dist/header-card-geometry.js +14 -2
  44. package/dist/header-card-geometry.js.map +1 -1
  45. package/dist/header-logo.d.ts +28 -0
  46. package/dist/header-logo.d.ts.map +1 -0
  47. package/dist/header-logo.js +51 -0
  48. package/dist/header-logo.js.map +1 -0
  49. package/dist/i18n.d.ts +11 -0
  50. package/dist/i18n.d.ts.map +1 -1
  51. package/dist/i18n.js +11 -1
  52. package/dist/i18n.js.map +1 -1
  53. package/dist/index.d.ts +20 -8
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -7
  56. package/dist/index.js.map +1 -1
  57. package/dist/inline-date-pin-geometry.d.ts +19 -11
  58. package/dist/inline-date-pin-geometry.d.ts.map +1 -1
  59. package/dist/inline-date-pin-geometry.js +45 -46
  60. package/dist/inline-date-pin-geometry.js.map +1 -1
  61. package/dist/item-bar-geometry.d.ts +192 -4
  62. package/dist/item-bar-geometry.d.ts.map +1 -1
  63. package/dist/item-bar-geometry.js +258 -5
  64. package/dist/item-bar-geometry.js.map +1 -1
  65. package/dist/item-port-geometry.d.ts +13 -0
  66. package/dist/item-port-geometry.d.ts.map +1 -0
  67. package/dist/item-port-geometry.js +63 -0
  68. package/dist/item-port-geometry.js.map +1 -0
  69. package/dist/layout-context.d.ts +115 -51
  70. package/dist/layout-context.d.ts.map +1 -1
  71. package/dist/layout-context.js +96 -5
  72. package/dist/layout-context.js.map +1 -1
  73. package/dist/layout-insights.d.ts.map +1 -1
  74. package/dist/layout-insights.js +73 -1
  75. package/dist/layout-insights.js.map +1 -1
  76. package/dist/layout.d.ts +35 -0
  77. package/dist/layout.d.ts.map +1 -1
  78. package/dist/layout.js +18 -1315
  79. package/dist/layout.js.map +1 -1
  80. package/dist/nodes/anchor-node.d.ts.map +1 -1
  81. package/dist/nodes/anchor-node.js +11 -4
  82. package/dist/nodes/anchor-node.js.map +1 -1
  83. package/dist/nodes/content-extent.d.ts +24 -0
  84. package/dist/nodes/content-extent.d.ts.map +1 -0
  85. package/dist/nodes/content-extent.js +100 -0
  86. package/dist/nodes/content-extent.js.map +1 -0
  87. package/dist/nodes/dependency-edges.d.ts +6 -0
  88. package/dist/nodes/dependency-edges.d.ts.map +1 -0
  89. package/dist/nodes/dependency-edges.js +148 -0
  90. package/dist/nodes/dependency-edges.js.map +1 -0
  91. package/dist/nodes/footnote-node.d.ts +24 -3
  92. package/dist/nodes/footnote-node.d.ts.map +1 -1
  93. package/dist/nodes/footnote-node.js +129 -25
  94. package/dist/nodes/footnote-node.js.map +1 -1
  95. package/dist/nodes/group-node.d.ts +10 -21
  96. package/dist/nodes/group-node.d.ts.map +1 -1
  97. package/dist/nodes/group-node.js +79 -152
  98. package/dist/nodes/group-node.js.map +1 -1
  99. package/dist/nodes/header-node.d.ts +56 -0
  100. package/dist/nodes/header-node.d.ts.map +1 -0
  101. package/dist/nodes/header-node.js +163 -0
  102. package/dist/nodes/header-node.js.map +1 -0
  103. package/dist/nodes/include-node.d.ts +22 -12
  104. package/dist/nodes/include-node.d.ts.map +1 -1
  105. package/dist/nodes/include-node.js +95 -62
  106. package/dist/nodes/include-node.js.map +1 -1
  107. package/dist/nodes/item-measure.d.ts +135 -0
  108. package/dist/nodes/item-measure.d.ts.map +1 -0
  109. package/dist/nodes/item-measure.js +514 -0
  110. package/dist/nodes/item-measure.js.map +1 -0
  111. package/dist/nodes/item-node.d.ts +162 -9
  112. package/dist/nodes/item-node.d.ts.map +1 -1
  113. package/dist/nodes/item-node.js +256 -69
  114. package/dist/nodes/item-node.js.map +1 -1
  115. package/dist/nodes/marker-band.d.ts +118 -0
  116. package/dist/nodes/marker-band.d.ts.map +1 -0
  117. package/dist/nodes/marker-band.js +303 -0
  118. package/dist/nodes/marker-band.js.map +1 -0
  119. package/dist/nodes/marker-geometry.js +1 -1
  120. package/dist/nodes/marker-geometry.js.map +1 -1
  121. package/dist/nodes/milestone-node.d.ts +112 -15
  122. package/dist/nodes/milestone-node.d.ts.map +1 -1
  123. package/dist/nodes/milestone-node.js +246 -95
  124. package/dist/nodes/milestone-node.js.map +1 -1
  125. package/dist/nodes/nowline-node.d.ts +8 -0
  126. package/dist/nodes/nowline-node.d.ts.map +1 -0
  127. package/dist/nodes/nowline-node.js +74 -0
  128. package/dist/nodes/nowline-node.js.map +1 -0
  129. package/dist/nodes/parallel-node.d.ts +7 -12
  130. package/dist/nodes/parallel-node.d.ts.map +1 -1
  131. package/dist/nodes/parallel-node.js +66 -30
  132. package/dist/nodes/parallel-node.js.map +1 -1
  133. package/dist/nodes/region-markers.d.ts +41 -0
  134. package/dist/nodes/region-markers.d.ts.map +1 -0
  135. package/dist/nodes/region-markers.js +130 -0
  136. package/dist/nodes/region-markers.js.map +1 -0
  137. package/dist/nodes/roadmap-node.d.ts +9 -72
  138. package/dist/nodes/roadmap-node.d.ts.map +1 -1
  139. package/dist/nodes/roadmap-node.js +38 -788
  140. package/dist/nodes/roadmap-node.js.map +1 -1
  141. package/dist/nodes/row-track.d.ts +33 -0
  142. package/dist/nodes/row-track.d.ts.map +1 -0
  143. package/dist/nodes/row-track.js +170 -0
  144. package/dist/nodes/row-track.js.map +1 -0
  145. package/dist/nodes/sequence-item.d.ts +25 -0
  146. package/dist/nodes/sequence-item.d.ts.map +1 -0
  147. package/dist/nodes/sequence-item.js +396 -0
  148. package/dist/nodes/sequence-item.js.map +1 -0
  149. package/dist/nodes/sequence-one.d.ts +5 -0
  150. package/dist/nodes/sequence-one.d.ts.map +1 -0
  151. package/dist/nodes/sequence-one.js +26 -0
  152. package/dist/nodes/sequence-one.js.map +1 -0
  153. package/dist/nodes/swimlane-node.d.ts +5 -17
  154. package/dist/nodes/swimlane-node.d.ts.map +1 -1
  155. package/dist/nodes/swimlane-node.js +62 -145
  156. package/dist/nodes/swimlane-node.js.map +1 -1
  157. package/dist/nodes/wave-node.d.ts +52 -0
  158. package/dist/nodes/wave-node.d.ts.map +1 -0
  159. package/dist/nodes/wave-node.js +483 -0
  160. package/dist/nodes/wave-node.js.map +1 -0
  161. package/dist/non-working-display.d.ts +14 -0
  162. package/dist/non-working-display.d.ts.map +1 -0
  163. package/dist/non-working-display.js +28 -0
  164. package/dist/non-working-display.js.map +1 -0
  165. package/dist/phases/date-window.d.ts +48 -0
  166. package/dist/phases/date-window.d.ts.map +1 -0
  167. package/dist/phases/date-window.js +130 -0
  168. package/dist/phases/date-window.js.map +1 -0
  169. package/dist/phases/finalize.d.ts +198 -0
  170. package/dist/phases/finalize.d.ts.map +1 -0
  171. package/dist/phases/finalize.js +1058 -0
  172. package/dist/phases/finalize.js.map +1 -0
  173. package/dist/phases/measure.d.ts +77 -0
  174. package/dist/phases/measure.d.ts.map +1 -0
  175. package/dist/phases/measure.js +222 -0
  176. package/dist/phases/measure.js.map +1 -0
  177. package/dist/phases/place.d.ts +28 -0
  178. package/dist/phases/place.d.ts.map +1 -0
  179. package/dist/phases/place.js +225 -0
  180. package/dist/phases/place.js.map +1 -0
  181. package/dist/renderable.d.ts.map +1 -1
  182. package/dist/renderable.js +23 -3
  183. package/dist/renderable.js.map +1 -1
  184. package/dist/resolve-today.d.ts +11 -1
  185. package/dist/resolve-today.d.ts.map +1 -1
  186. package/dist/resolve-today.js +26 -12
  187. package/dist/resolve-today.js.map +1 -1
  188. package/dist/row-packer.d.ts +42 -16
  189. package/dist/row-packer.d.ts.map +1 -1
  190. package/dist/row-packer.js +51 -78
  191. package/dist/row-packer.js.map +1 -1
  192. package/dist/schedule.d.ts +58 -1
  193. package/dist/schedule.d.ts.map +1 -1
  194. package/dist/schedule.js +280 -106
  195. package/dist/schedule.js.map +1 -1
  196. package/dist/start-rules.d.ts +27 -0
  197. package/dist/start-rules.d.ts.map +1 -0
  198. package/dist/start-rules.js +91 -0
  199. package/dist/start-rules.js.map +1 -0
  200. package/dist/start-terms.d.ts +23 -0
  201. package/dist/start-terms.d.ts.map +1 -0
  202. package/dist/start-terms.js +27 -0
  203. package/dist/start-terms.js.map +1 -0
  204. package/dist/status-dot.d.ts +46 -0
  205. package/dist/status-dot.d.ts.map +1 -0
  206. package/dist/status-dot.js +78 -0
  207. package/dist/status-dot.js.map +1 -0
  208. package/dist/style-resolution.d.ts.map +1 -1
  209. package/dist/style-resolution.js +23 -43
  210. package/dist/style-resolution.js.map +1 -1
  211. package/dist/text-measure.d.ts +28 -0
  212. package/dist/text-measure.d.ts.map +1 -0
  213. package/dist/text-measure.js +70 -0
  214. package/dist/text-measure.js.map +1 -0
  215. package/dist/themes/contrast.d.ts +4 -0
  216. package/dist/themes/contrast.d.ts.map +1 -0
  217. package/dist/themes/contrast.js +32 -0
  218. package/dist/themes/contrast.js.map +1 -0
  219. package/dist/themes/dark.d.ts.map +1 -1
  220. package/dist/themes/dark.js +28 -1
  221. package/dist/themes/dark.js.map +1 -1
  222. package/dist/themes/grayscale.d.ts.map +1 -1
  223. package/dist/themes/grayscale.js +28 -1
  224. package/dist/themes/grayscale.js.map +1 -1
  225. package/dist/themes/index.d.ts +4 -11
  226. package/dist/themes/index.d.ts.map +1 -1
  227. package/dist/themes/index.js +6 -4
  228. package/dist/themes/index.js.map +1 -1
  229. package/dist/themes/light.d.ts.map +1 -1
  230. package/dist/themes/light.js +28 -1
  231. package/dist/themes/light.js.map +1 -1
  232. package/dist/themes/shape.d.ts +31 -0
  233. package/dist/themes/shape.d.ts.map +1 -1
  234. package/dist/themes/shared.d.ts +44 -50
  235. package/dist/themes/shared.d.ts.map +1 -1
  236. package/dist/themes/shared.js +79 -83
  237. package/dist/themes/shared.js.map +1 -1
  238. package/dist/time-scale.d.ts +42 -6
  239. package/dist/time-scale.d.ts.map +1 -1
  240. package/dist/time-scale.js +109 -17
  241. package/dist/time-scale.js.map +1 -1
  242. package/dist/types.d.ts +528 -11
  243. package/dist/types.d.ts.map +1 -1
  244. package/dist/view-preset.d.ts +61 -6
  245. package/dist/view-preset.d.ts.map +1 -1
  246. package/dist/view-preset.js +439 -24
  247. package/dist/view-preset.js.map +1 -1
  248. package/dist/wave-barrier.d.ts +107 -0
  249. package/dist/wave-barrier.d.ts.map +1 -0
  250. package/dist/wave-barrier.js +174 -0
  251. package/dist/wave-barrier.js.map +1 -0
  252. package/dist/wave-hatch.d.ts +4 -0
  253. package/dist/wave-hatch.d.ts.map +1 -0
  254. package/dist/wave-hatch.js +21 -0
  255. package/dist/wave-hatch.js.map +1 -0
  256. package/dist/wave-layout.d.ts +99 -0
  257. package/dist/wave-layout.d.ts.map +1 -0
  258. package/dist/wave-layout.js +130 -0
  259. package/dist/wave-layout.js.map +1 -0
  260. package/dist/working-calendar.d.ts +128 -4
  261. package/dist/working-calendar.d.ts.map +1 -1
  262. package/dist/working-calendar.js +725 -51
  263. package/dist/working-calendar.js.map +1 -1
  264. package/dist/working-span.d.ts +11 -0
  265. package/dist/working-span.d.ts.map +1 -0
  266. package/dist/working-span.js +18 -0
  267. package/dist/working-span.js.map +1 -0
  268. package/package.json +4 -4
  269. package/src/axis-columns.ts +140 -0
  270. package/src/calendar-resolver.ts +161 -0
  271. package/src/calendar.ts +4 -45
  272. package/src/canvas-width.ts +36 -0
  273. package/src/capacity.ts +71 -30
  274. package/src/chart-translation.ts +88 -0
  275. package/src/container-header-geometry.ts +116 -0
  276. package/src/dsl-utils.ts +8 -26
  277. package/src/edge-routing.ts +347 -491
  278. package/src/frame-tab-geometry.ts +78 -25
  279. package/src/header-card-geometry.ts +17 -2
  280. package/src/header-logo.ts +67 -0
  281. package/src/i18n.ts +22 -1
  282. package/src/index.ts +97 -12
  283. package/src/inline-date-pin-geometry.ts +55 -58
  284. package/src/item-bar-geometry.ts +341 -5
  285. package/src/item-port-geometry.ts +69 -0
  286. package/src/layout-context.ts +210 -72
  287. package/src/layout-insights.ts +109 -2
  288. package/src/layout.ts +57 -1530
  289. package/src/nodes/anchor-node.ts +11 -4
  290. package/src/nodes/content-extent.ts +107 -0
  291. package/src/nodes/dependency-edges.ts +171 -0
  292. package/src/nodes/footnote-node.ts +171 -31
  293. package/src/nodes/group-node.ts +92 -214
  294. package/src/nodes/header-node.ts +255 -0
  295. package/src/nodes/include-node.ts +124 -110
  296. package/src/nodes/item-measure.ts +723 -0
  297. package/src/nodes/item-node.ts +358 -80
  298. package/src/nodes/marker-band.ts +406 -0
  299. package/src/nodes/marker-geometry.ts +1 -1
  300. package/src/nodes/milestone-node.ts +321 -136
  301. package/src/nodes/nowline-node.ts +87 -0
  302. package/src/nodes/parallel-node.ts +69 -38
  303. package/src/nodes/region-markers.ts +194 -0
  304. package/src/nodes/roadmap-node.ts +46 -950
  305. package/src/nodes/row-track.ts +217 -0
  306. package/src/nodes/sequence-item.ts +433 -0
  307. package/src/nodes/sequence-one.ts +40 -0
  308. package/src/nodes/swimlane-node.ts +66 -205
  309. package/src/nodes/wave-node.ts +609 -0
  310. package/src/non-working-display.ts +32 -0
  311. package/src/phases/date-window.ts +191 -0
  312. package/src/phases/finalize.ts +1303 -0
  313. package/src/phases/measure.ts +329 -0
  314. package/src/phases/place.ts +318 -0
  315. package/src/renderable.ts +23 -3
  316. package/src/resolve-today.ts +28 -25
  317. package/src/row-packer.ts +85 -84
  318. package/src/schedule.ts +362 -114
  319. package/src/start-rules.ts +113 -0
  320. package/src/start-terms.ts +38 -0
  321. package/src/status-dot.ts +81 -0
  322. package/src/style-resolution.ts +27 -57
  323. package/src/text-measure.ts +71 -0
  324. package/src/themes/contrast.ts +34 -0
  325. package/src/themes/dark.ts +28 -1
  326. package/src/themes/grayscale.ts +28 -1
  327. package/src/themes/index.ts +10 -7
  328. package/src/themes/light.ts +28 -1
  329. package/src/themes/shape.ts +38 -0
  330. package/src/themes/shared.ts +85 -93
  331. package/src/time-scale.ts +117 -19
  332. package/src/types.ts +529 -20
  333. package/src/view-preset.ts +520 -27
  334. package/src/wave-barrier.ts +242 -0
  335. package/src/wave-hatch.ts +25 -0
  336. package/src/wave-layout.ts +203 -0
  337. package/src/working-calendar.ts +875 -57
  338. package/src/working-span.ts +19 -0
@@ -0,0 +1,217 @@
1
+ // Row track — the child loop that `SwimlaneNode` and `GroupNode` share
2
+ // (specs/layout-phases.md §3.8). Feeds a lane's or a group's content to
3
+ // a `RowPacker` in DSL order: an item is measured (`measureItem`) so the
4
+ // packer can bump it to a fresh row before `sequenceItem` places it with
5
+ // the same measure; a parallel or nested group takes a fresh row of its own through
6
+ // `sequenceOne`. The callers keep what differs between them: the lane
7
+ // its title tab and top-pad collapse, the group its header and body. Both
8
+ // read the packer's used height and right edge, and the track's span in
9
+ // time, from the result.
10
+
11
+ import type { GroupBlock, SwimlaneDeclaration } from '@nowline/core';
12
+ import { isItemDeclaration } from '@nowline/core';
13
+ import { type TrackNode, trackChildren } from '@nowline/core/internal';
14
+ import {
15
+ CONTAINER_HEADER_CLEARANCE_PX,
16
+ containerHeaderRightX,
17
+ } from '../container-header-geometry.js';
18
+ import { type LayoutContext, type LayoutHelpers, newCursor } from '../layout-context.js';
19
+ import { RowPacker } from '../row-packer.js';
20
+ import { resolveChildStart } from '../start-rules.js';
21
+ import type { PositionedItem, PositionedTrackChild } from '../types.js';
22
+ import { itemSpanPx } from '../working-span.js';
23
+ import { trackChildPaintedRightX } from './content-extent.js';
24
+ import { measureItem } from './item-measure.js';
25
+ import { sequenceItem } from './sequence-item.js';
26
+
27
+ /** Ids of every named item under `node`, `node` included. */
28
+ function itemIdsUnder(node: TrackNode): string[] {
29
+ if (isItemDeclaration(node)) return node.name ? [node.name] : [];
30
+ return trackChildren(node).flatMap(itemIdsUnder);
31
+ }
32
+
33
+ export interface RowTrackOrigin {
34
+ /** Left edge of the track: the lane's left x, or the group's content left x. */
35
+ leftX: number;
36
+ /** Top of the first row (the packer's `originY`). */
37
+ firstRowY: number;
38
+ }
39
+
40
+ export interface RowTrackResult {
41
+ children: PositionedTrackChild[];
42
+ /**
43
+ * Compact cursor, in time: item duration ends and blocks' compact ends
44
+ * only, never a caption spill.
45
+ */
46
+ timeCursorX: number;
47
+ /**
48
+ * Earliest child start, in time: where a group's bracket and body open.
49
+ * The track's left x when it has no children.
50
+ */
51
+ timeStartX: number;
52
+ /** The packer the children were fed to; read `usedHeight()` and `usedRightX()` from it. */
53
+ packer: RowPacker;
54
+ }
55
+
56
+ /**
57
+ * Sequence a lane's or a group's track children (core's `trackChildren`:
58
+ * description directives skipped) into rows starting at `origin`, in DSL
59
+ * order. Nested blocks recurse through `sequenceOne`, which carries the
60
+ * inherited state (cursor, flow key) the walk alone does not.
61
+ */
62
+ export function sequenceRowTrack(
63
+ container: SwimlaneDeclaration | GroupBlock,
64
+ origin: RowTrackOrigin,
65
+ ctx: LayoutContext,
66
+ helpers: LayoutHelpers,
67
+ ): RowTrackResult {
68
+ const step = ctx.bandScale.step();
69
+ const leftX = origin.leftX;
70
+
71
+ // Topmost-fit row pack. See `RowPacker` for the full contract.
72
+ // The packer owns the rows; we feed it children in DSL order and
73
+ // it returns each child's resolved (rowIndex, y).
74
+ const packer = new RowPacker({
75
+ laneLeftX: leftX,
76
+ originY: origin.firstRowY,
77
+ minRowHeight: step,
78
+ slackCorridors: ctx.slackCorridors,
79
+ });
80
+ let timeCursorX = leftX;
81
+ let timeStartX = Number.POSITIVE_INFINITY;
82
+ // Items not yet placed in this track: an item never bumps for the slack
83
+ // arrow of one still to come, since rows never move up and that item
84
+ // would follow it down.
85
+ const pendingIds = new Set(trackChildren(container).flatMap(itemIdsUnder));
86
+
87
+ const children: PositionedTrackChild[] = [];
88
+ for (const child of trackChildren(container)) {
89
+ for (const id of itemIdsUnder(child)) pendingIds.delete(id);
90
+ if (!isItemDeclaration(child)) {
91
+ const blockStart = resolveChildStart(child, timeCursorX, ctx);
92
+ const { rowIndex, y: blockY } = packer.placeBlock();
93
+ const cursor = newCursor(blockStart, blockY);
94
+ const positioned = helpers.sequenceOne(child, cursor, ctx);
95
+ children.push(positioned);
96
+ // A block's box spans only its dates, so a caption its children
97
+ // spill and a header title that runs past its end are outside
98
+ // it; the row reserves them, as it does an item's spill, so the
99
+ // next child drops a row rather than landing on them
100
+ // (specs/true-width-layout.md §3.2, §3.3). The time cursor takes
101
+ // the block's end in time, so that child still starts on its
102
+ // date, as in engine C (specs/scheduler-unification.md §8
103
+ // decision 7).
104
+ const blockEnd = positioned.box.x + positioned.box.width;
105
+ const blockHeight = Math.max(step, cursor.height);
106
+ const contentRight = reservedRightX(positioned);
107
+ packer.commitBlock({
108
+ rowIndex,
109
+ placed: positioned,
110
+ blockHeight,
111
+ blockEnd,
112
+ contentOffsetY: firstTrackOffsetY(positioned),
113
+ spillReservation: contentRight > blockEnd ? contentRight : null,
114
+ paintedRight: trackChildPaintedRightX(positioned),
115
+ });
116
+ timeStartX = Math.min(timeStartX, cursor.startX);
117
+ timeCursorX = Math.max(timeCursorX, cursor.x);
118
+ continue;
119
+ }
120
+
121
+ const item = child;
122
+ const desiredStart = resolveChildStart(item, timeCursorX, ctx);
123
+ // Measure the item where `sequenceItem` will place it, so the row
124
+ // pack can decide BEFORE handing off: the measure gives the logical
125
+ // extent and the bar's growth, and `sequenceItem` reuses it. Under
126
+ // `show` the bar snaps to a working day; the cursor below keeps the
127
+ // raw start.
128
+ const predictedStart = ctx.scale.startX(desiredStart);
129
+ const measure = measureItem(item, predictedStart, ctx);
130
+ const desiredEnd = predictedStart + measure.naturalWidth;
131
+ // Row pitch = `step()` + the bar's growth. Keeps the inter-row
132
+ // visible gap (= step - bandwidth) intact when a title wraps or an
133
+ // item's labels wrap and grow the bar. The placed box is
134
+ // `bandwidth + barExtra` tall, so this is also the placed pitch.
135
+ const pitch = step + measure.barExtra;
136
+ const { rowIndex, y: rowY } = packer.placeItem({
137
+ childId: item.name ?? '',
138
+ desiredStart: predictedStart,
139
+ desiredEnd,
140
+ predictedHeight: pitch,
141
+ pendingIds,
142
+ });
143
+
144
+ const cursor = newCursor(desiredStart, rowY);
145
+ const positioned = sequenceItem(item, cursor, ctx, measure);
146
+ children.push(positioned);
147
+ timeStartX = Math.min(timeStartX, cursor.startX);
148
+
149
+ // The painted footprint in LOGICAL space: the item's span, or the
150
+ // painted box when the 1 px floor makes it wider. The row packer
151
+ // keeps it, so a following bar never overlaps it.
152
+ const itemLogicalEnd = Math.max(
153
+ predictedStart + measure.naturalWidth,
154
+ positioned.box.x + positioned.box.width,
155
+ );
156
+ // The time cursor advances by the duration, so the next chained
157
+ // item starts on its date, as in engine C.
158
+ const durationEnd = predictedStart + itemSpanPx(ctx, predictedStart, measure.durationDays);
159
+ timeCursorX = Math.max(timeCursorX, durationEnd);
160
+
161
+ packer.commitItem({
162
+ rowIndex,
163
+ placed: positioned,
164
+ logicalEnd: itemLogicalEnd,
165
+ spillReservation: itemSpillReservation(positioned),
166
+ rowHeight: pitch,
167
+ });
168
+ }
169
+
170
+ if (timeStartX === Number.POSITIVE_INFINITY) timeStartX = ctx.scale.startX(leftX);
171
+ return { children, timeCursorX, timeStartX, packer };
172
+ }
173
+
174
+ /**
175
+ * How far below a block's top its first track starts: its header band plus
176
+ * body pad, read off the first child so a block that reserves no chrome (a
177
+ * wave-only group) reads 0.
178
+ */
179
+ function firstTrackOffsetY(block: PositionedTrackChild): number {
180
+ if (block.kind === 'item') return 0;
181
+ return (block.children[0]?.box.y ?? block.box.y) - block.box.y;
182
+ }
183
+
184
+ /**
185
+ * The row reservation an item's spill claims, or null when nothing spills.
186
+ * `decorationsRightX` already aggregates the spilled dot / icon / footnote /
187
+ * caption right edges from `sequenceItem`; the chip column tracks separately
188
+ * via `chipsRightX`. A 6 px buffer leaves the next chained item's bar a
189
+ * visible gutter past the spilled cluster instead of butting against it.
190
+ */
191
+ function itemSpillReservation(item: PositionedItem): number | null {
192
+ const spills =
193
+ item.textSpills ||
194
+ item.chipsOutside ||
195
+ item.dotSpills ||
196
+ item.iconSpills ||
197
+ item.footnoteSpills;
198
+ if (!spills) return null;
199
+ return Math.max(item.decorationsRightX, item.chipsOutside ? item.chipsRightX : 0) + 6;
200
+ }
201
+
202
+ /**
203
+ * Rightmost x a placed child reserves on its row: an item's bar or spill
204
+ * reservation; for a group or parallel, whose box spans only its dates, the
205
+ * furthest of its header (title and glyphs, plus clearance) and its
206
+ * children's reservations.
207
+ */
208
+ function reservedRightX(child: PositionedTrackChild): number {
209
+ const boxRight = child.box.x + child.box.width;
210
+ if (child.kind === 'item') return Math.max(boxRight, itemSpillReservation(child) ?? boxRight);
211
+ const headerRight = containerHeaderRightX(child);
212
+ return Math.max(
213
+ boxRight,
214
+ headerRight > boxRight ? headerRight + CONTAINER_HEADER_CLEARANCE_PX : boxRight,
215
+ ...child.children.map(reservedRightX),
216
+ );
217
+ }
@@ -0,0 +1,433 @@
1
+ // sequenceItem — place one item on its track: resolve its start (pins,
2
+ // `after:` chains, the wave floor), take its measure (`measureItem`, the
3
+ // row track hands in the one it packed the row with), apply the `before:`
4
+ // caps, build its `ItemNode` and lay out the chips and the spill column,
5
+ // then register it in the context's entity maps.
6
+
7
+ import type { ItemDeclaration } from '@nowline/core';
8
+ import { parseIsoDate, propValue, propValues } from '../dsl-utils.js';
9
+ import { computeItemInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
10
+ import {
11
+ ITEM_CAPTION_INSET_X_PX,
12
+ ITEM_CAPTION_META_BASELINE_OFFSET_PX,
13
+ ITEM_CAPTION_SPILL_GAP_PX,
14
+ ITEM_CAPTION_TITLE_FONT_SIZE_PX,
15
+ LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX,
16
+ LABEL_CHIP_GAP_BETWEEN_PX,
17
+ LABEL_CHIP_HEIGHT_PX,
18
+ LABEL_CHIP_ROW_STEP_PX,
19
+ layoutSpillCluster,
20
+ MIN_BAR_WIDTH_FOR_DOT_PX,
21
+ MIN_BAR_WIDTH_FOR_FOOTNOTE_PX,
22
+ } from '../item-bar-geometry.js';
23
+ import type { LayoutContext, TrackCursor } from '../layout-context.js';
24
+ import { nonWorkingPinOf, resolvePinnedOrSequentialStart } from '../start-rules.js';
25
+ import { statusDotColor } from '../status-dot.js';
26
+ import { estimateTextWidth } from '../text-measure.js';
27
+ import { PROGRESS_STRIP_HEIGHT_PX } from '../themes/shared.js';
28
+ import type { BoundingBox, PositionedItem, PositionedLabelChip } from '../types.js';
29
+ import { waveHatchKind } from '../wave-hatch.js';
30
+ import { accumulateWaveMember, waveFloorX, waveRoleOf } from '../wave-layout.js';
31
+ import { itemSpanPx } from '../working-span.js';
32
+ import { buildLabelChip, type ItemMeasure, measureItem } from './item-measure.js';
33
+ import { ItemNode, traceItemNode } from './item-node.js';
34
+
35
+ /**
36
+ * Place `node` at `cursor` and advance the cursor: `cursor.x` to its
37
+ * duration end.
38
+ * `measured` is the item's measure at the snapped start it is placed at,
39
+ * when the caller (a lane or group row track) already took it to pack the
40
+ * row; a parallel track's child is measured here.
41
+ */
42
+ export function sequenceItem(
43
+ node: ItemDeclaration,
44
+ cursor: TrackCursor,
45
+ ctx: LayoutContext,
46
+ measured?: ItemMeasure,
47
+ ): PositionedItem {
48
+ const props = node.properties;
49
+ const afterRaw = propValues(props, 'after');
50
+ const beforeRaw = propValues(props, 'before');
51
+
52
+ // Resolve start x with the swimlane child rule: the latest of the
53
+ // after-chain and the cursor position. A lane or group child's cursor
54
+ // already sits at that start; a direct parallel track's cursor sits at
55
+ // the block's start, so this is where its `after:` applies.
56
+ let startX = resolvePinnedOrSequentialStart(props, cursor.x, ctx);
57
+ // Wave barrier (specs/waves.md §5.1, §8.4): the floor is one more term
58
+ // in the max, after the `after:` logic. Parallel tracks reach this
59
+ // function directly, so this is where their floor applies.
60
+ if (ctx.waves) startX = waveFloorX(node, startX, ctx);
61
+ const nonWorkingPin = nonWorkingPinOf(props, startX, ctx);
62
+
63
+ // Under `show` a start that lands in a non-working day moves to the next
64
+ // working day. The floor and NL.I1008 above read the unsnapped start,
65
+ // so their ties and texts match `hide`; the identity under `hide`.
66
+ const placedStartX = ctx.scale.startX(startX);
67
+ // Width, style and tint, progress, meta line, chips, caption fit and
68
+ // the bar's growth (specs/layout-phases.md §3.6). A lane or group row
69
+ // track measured the item at this same start to size its row (the
70
+ // cursor sits at `resolveChildStart`, which an `after:` chain and the
71
+ // wave floor both return unchanged); a parallel track measures here.
72
+ const measure = measured ?? measureItem(node, placedStartX, ctx);
73
+ if (measure.startX !== placedStartX) {
74
+ throw new Error(
75
+ `layout: item "${node.name ?? node.title ?? '(unnamed)'}" was measured at x ${measure.startX} but placed at x ${placedStartX}`,
76
+ );
77
+ }
78
+ const { naturalWidth, style, status, progress, footnoteIndicators, caption } = measure;
79
+ const { metaText, ownerDisplay, capacity, capacityTrailingWidth } = measure.meta;
80
+ const linkInfo = measure.link;
81
+ const hasLinkIcon = linkInfo.icon !== 'none';
82
+ const chipSamples = measure.chips.samples;
83
+ const chipsOutside = measure.chips.outside;
84
+ // Logical extent: the item's span in time; the bar box is inset from it
85
+ // by its seams (specs/true-width-layout.md §3.1).
86
+ const logicalLeft = placedStartX;
87
+ const logicalRight = placedStartX + naturalWidth;
88
+ // Duration end: where the item ends in time. The time paths take it
89
+ // (the cursor, `after:` references, the wave barrier), so what follows
90
+ // starts on its date, as in engine C; the bar is drawn inside it
91
+ // (specs/true-width-layout.md §3.1).
92
+ const durationEndX = placedStartX + itemSpanPx(ctx, placedStartX, measure.durationDays);
93
+
94
+ // Handle `before:` — item must end by the earliest cap. Each cap is
95
+ // either an entity id (looked up in entityLeftEdges) or an inline ISO
96
+ // date literal (resolved through the time scale). The validator enforces
97
+ // "at most one inline date per direction" so at most one element per
98
+ // before-list will hit the date path. The cap is a time path: the item
99
+ // overflows when its duration end passes it; the red tail runs to the
100
+ // painted end.
101
+ let hasOverflow = false;
102
+ let overflowBox: BoundingBox | undefined;
103
+ let overflowAnchorId: string | undefined;
104
+ if (beforeRaw.length > 0) {
105
+ let earliestCapX: number | undefined;
106
+ let earliestCapRef: string | undefined;
107
+ for (const ref of beforeRaw) {
108
+ const inlineDate = parseIsoDate(ref);
109
+ const capX = inlineDate
110
+ ? (ctx.scale.forwardWithinDomain(inlineDate) ?? undefined)
111
+ : ctx.entityLeftEdges.get(ref);
112
+ if (capX === undefined) continue;
113
+ if (earliestCapX === undefined || capX < earliestCapX) {
114
+ earliestCapX = capX;
115
+ earliestCapRef = ref;
116
+ }
117
+ }
118
+ if (earliestCapX !== undefined && durationEndX > earliestCapX) {
119
+ hasOverflow = true;
120
+ overflowBox = {
121
+ x: earliestCapX,
122
+ y: cursor.y,
123
+ width: logicalRight - earliestCapX,
124
+ height: ctx.bandScale.bandwidth(),
125
+ };
126
+ overflowAnchorId = earliestCapRef;
127
+ }
128
+ }
129
+
130
+ // The bar box (grown by `measure.barExtra`) and the caption's x, inset
131
+ // from the logical extent logicalLeft/logicalRight.
132
+ const titleStr = caption.title;
133
+ const itemNode = new ItemNode(measure);
134
+ const placeCtx = { time: ctx.scale, bands: ctx.bandScale, style };
135
+ const placed = itemNode.place({ x: logicalLeft, y: cursor.y }, placeCtx);
136
+ const itemBox = placed.box;
137
+ const bandwidth = ctx.bandScale.bandwidth();
138
+
139
+ // Narrow-bar decoration spill — when a bar is too narrow to host
140
+ // the dot, link icon, or footnote with its full inset, those
141
+ // glyphs render in the same spill column as the (already-
142
+ // spilling) caption text, in reading order
143
+ // `[bar][dot][icon][title][footnote#…][meta]`. Each decoration's
144
+ // threshold is independent (a 20-px-wide bar can host the dot
145
+ // but not the icon, etc.); see the `MIN_BAR_WIDTH_FOR_*`
146
+ // constants in `item-bar-geometry`.
147
+ //
148
+ // Forcing `textSpills` when `iconSpills` keeps the icon and
149
+ // title visually adjacent — otherwise a spilled icon would
150
+ // float at `bar.right + 6` while the title stayed inside the
151
+ // bar, breaking the icon→title affordance.
152
+ const dotSpills = itemBox.width < MIN_BAR_WIDTH_FOR_DOT_PX;
153
+ // Measured on the same box width as the other two.
154
+ const iconSpills = caption.iconSpills;
155
+ const footnoteSpillsForNarrow = itemBox.width < MIN_BAR_WIDTH_FOR_FOOTNOTE_PX;
156
+ const textSpills = placed.textSpills || iconSpills;
157
+ // The lines the title paints on (`resolveCaptionTitleLines`).
158
+ const captionLines = caption.lines;
159
+
160
+ // Label chips lay out left → right at natural text width.
161
+ //
162
+ // INSIDE the bar (chipsOutside === false): single row,
163
+ // left-aligned at the caption inset, anchored just above the
164
+ // bottom progress strip.
165
+ //
166
+ // OUTSIDE the bar (chipsOutside === true): the whole chip set
167
+ // moves to the spill column at `bar.right + 6`. Within the
168
+ // column, chips pack into rows capped at the bar's visual
169
+ // width — see `packSpillChips`. Row 0 sits at the same y the
170
+ // single-row would have used; subsequent rows stack DOWNWARD by
171
+ // one `LABEL_CHIP_ROW_STEP_PX`.
172
+ //
173
+ // When chips spill OR the title wrapped over a meta line, the BAR
174
+ // ITSELF GROWS DOWNWARD so the chip column (or the second title
175
+ // line plus meta) reads as enclosed by the bar — the painted
176
+ // footprint of the bar is `bandwidth + barExtra` (the larger of
177
+ // the title and chip growth, `measure.barExtra`, which `ItemNode`
178
+ // has already applied to the box) and the bottom progress strip
179
+ // moves with the new bottom edge. Chip Y is anchored to the
180
+ // ORIGINAL bandwidth (relative to the bar's top), not to the grown
181
+ // box.height, so row 0 stays where a single-row chip would
182
+ // naturally render and rows 1..N grow downward into the new bar
183
+ // area.
184
+ //
185
+ // When the caption ALSO spills (`textSpills && chipsOutside`),
186
+ // row 0's y drops below the meta baseline so the spilled stack
187
+ // reads `title → meta → chip-row-0 → chip-row-1 → ...` at a
188
+ // single column inside the (now-taller) bar.
189
+ const chipPack = measure.chips.pack;
190
+ // Baseline (px from the bar's top) of the caption's last line: the
191
+ // meta line when present, else the last title line. Chips stacked
192
+ // inside the bar must clear it.
193
+ const captionLastBaseline = caption.lastBaseline;
194
+ const barExtra = measure.barExtra;
195
+
196
+ const labelChips: PositionedLabelChip[] = [];
197
+ const baseChipY =
198
+ itemBox.y +
199
+ bandwidth -
200
+ PROGRESS_STRIP_HEIGHT_PX -
201
+ LABEL_CHIP_HEIGHT_PX -
202
+ LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
203
+ // Chips under a SPILLED caption clear the classic meta baseline (38),
204
+ // or the caption's own last baseline when explicit breaks make the
205
+ // spilled block taller than that; an in-bar caption is cleared at its
206
+ // own last baseline (which a wrapped title pushes down).
207
+ const captionStackChipY =
208
+ itemBox.y +
209
+ (chipsOutside && textSpills
210
+ ? Math.max(ITEM_CAPTION_META_BASELINE_OFFSET_PX, captionLastBaseline)
211
+ : captionLastBaseline) +
212
+ LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
213
+ // Inside-bar chips need to clear the caption's last baseline —
214
+ // the natural `baseChipY` (anchored to bar bottom) sits above
215
+ // the meta line at typical bandwidths, and above line 2 of a
216
+ // wrapped title, so the chip rect would overlap the text
217
+ // vertically. Use whichever Y is lower. With no meta and a
218
+ // single-line title the caption bottom is above `baseChipY`, so
219
+ // this resolves to `baseChipY` as before.
220
+ // Outside-bar chips already reuse `captionStackChipY` when the
221
+ // caption ALSO spills; with caption inside we don't need to
222
+ // shift them since they're horizontally separated from the meta.
223
+ const stackBelowCaption =
224
+ (chipsOutside && textSpills) || (!chipsOutside && chipSamples.length > 0);
225
+ const chipRow0Y = stackBelowCaption ? Math.max(baseChipY, captionStackChipY) : baseChipY;
226
+ const chipStartX = chipsOutside
227
+ ? itemBox.x + itemBox.width + ITEM_CAPTION_SPILL_GAP_PX
228
+ : itemBox.x + ITEM_CAPTION_INSET_X_PX;
229
+
230
+ let chipsRightX = chipStartX;
231
+ if (chipPack) {
232
+ for (let r = 0; r < chipPack.rows.length; r += 1) {
233
+ const rowY = chipRow0Y + r * LABEL_CHIP_ROW_STEP_PX;
234
+ let rowCursorX = chipStartX;
235
+ for (const sample of chipPack.rows[r]) {
236
+ const chip = buildLabelChip(sample.id, ctx.styleCtx, rowCursorX, rowY);
237
+ labelChips.push(chip);
238
+ rowCursorX += chip.box.width + LABEL_CHIP_GAP_BETWEEN_PX;
239
+ }
240
+ const rowRight = rowCursorX - LABEL_CHIP_GAP_BETWEEN_PX;
241
+ if (rowRight > chipsRightX) chipsRightX = rowRight;
242
+ }
243
+ } else {
244
+ let rowCursorX = chipStartX;
245
+ for (const sample of chipSamples) {
246
+ const chip = buildLabelChip(sample.id, ctx.styleCtx, rowCursorX, chipRow0Y);
247
+ labelChips.push(chip);
248
+ rowCursorX += chip.box.width + LABEL_CHIP_GAP_BETWEEN_PX;
249
+ }
250
+ chipsRightX = chipSamples.length > 0 ? rowCursorX - LABEL_CHIP_GAP_BETWEEN_PX : chipStartX;
251
+ }
252
+
253
+ // `footnoteIndicators` (one superscript per footnote that names this
254
+ // item) was resolved above, before the caption fit.
255
+
256
+ const owner = ownerDisplay ?? propValue(props, 'owner');
257
+ const description = node.description?.text;
258
+
259
+ // Footnote glyphs only need to spill when there's at least one
260
+ // indicator AND the bar is too narrow to host them at the inset-
261
+ // right anchor. Compute the final boolean here once we know the
262
+ // indicator count.
263
+ const footnoteSpills = footnoteIndicators.length > 0 && footnoteSpillsForNarrow;
264
+
265
+ // Spill-column x positions for the decorations. The cluster
266
+ // mirrors the in-bar reading order so users see the same visual
267
+ // hierarchy whether everything fits inside or trails off to the
268
+ // right:
269
+ //
270
+ // In-bar (default): [icon] [title] [¹²] [dot]
271
+ // Spilled (narrow): [bar] [icon?] [title][¹²?] [dot?]
272
+ //
273
+ // The dot lives at the trailing edge in BOTH cases — pushing it
274
+ // to the LEFT of the title (with the title trailing it) read as
275
+ // the dot belonging to the next item, not this one. A missing
276
+ // decoration just collapses out of the row; e.g. an item with
277
+ // no link AND a too-narrow bar gives `[bar] [title] [dot]`.
278
+ //
279
+ // `decorationsRightX` is the furthest right edge any spilled
280
+ // glyph reaches; the row-packer uses it (alongside spilled-chip
281
+ // width) to reserve x-extent so the next chained item bumps to
282
+ // a fresh row instead of landing under the spilled cluster.
283
+ let captionSpillWidth: number | null = null;
284
+ if (textSpills) {
285
+ // The widest title LINE: a multi-line spill is as wide as its
286
+ // longest line, not as the raw string (newlines included). A
287
+ // caption capped at the chart's end arrives with its lines already
288
+ // truncated (`measureItem`).
289
+ const titleW = Math.max(
290
+ ...captionLines.map((line) => estimateTextWidth(line, ITEM_CAPTION_TITLE_FONT_SIZE_PX)),
291
+ );
292
+ // Spill column width is the wider of the title and the *full* meta
293
+ // line (text + capacity suffix). `capacityTrailingWidth` is 0 when
294
+ // no capacity suffix is rendered, so this stays a no-op for items
295
+ // without `capacity:`.
296
+ const metaW = (metaText ? estimateTextWidth(metaText, 11) : 0) + capacityTrailingWidth;
297
+ captionSpillWidth = Math.max(titleW, metaW);
298
+ }
299
+ const spill = layoutSpillCluster({
300
+ barRightX: itemBox.x + itemBox.width,
301
+ iconSpills,
302
+ captionWidth: captionSpillWidth,
303
+ footnoteCount: footnoteSpills ? footnoteIndicators.length : 0,
304
+ dotSpills,
305
+ });
306
+ const { iconSpillX, footnoteSpillStartX, dotSpillCx } = spill;
307
+ const decorationsRightX = Math.max(itemBox.x + itemBox.width, spill.rightX);
308
+
309
+ const id = node.name;
310
+ // Reference-target edges resolve `after:` / `before:` lookups, so only
311
+ // EXPLICIT ids register here — an id-less item never becomes a
312
+ // referenceable target. Entity edges live in LOGICAL space so chained
313
+ // items / `after:` references sit on the column boundary, not on the
314
+ // visually inset bar edge. The visible 12 px gutter between bars then
315
+ // becomes a clean attach corridor. The right edge is the duration end:
316
+ // `after:` is a time path (decision 6). Items attach their arrows off
317
+ // their final box (`placedItems`), never off this map.
318
+ if (id) {
319
+ ctx.entityLeftEdges.set(id, logicalLeft);
320
+ ctx.entityRightEdges.set(id, durationEndX);
321
+ }
322
+ // A member's end feeds its wave's barrier: its duration end, the lane
323
+ // cursor's advance in `sequenceRowTrack`, never a group box or a
324
+ // caption spill (specs/waves.md §8.4).
325
+ accumulateWaveMember(node, durationEndX, ctx);
326
+ // Drawing / flow maps key on a registration handle EVERY item has: the
327
+ // explicit id when present, else a synthetic, non-referenceable handle
328
+ // (see `syntheticItemKey`). This lets a title-only item register its own
329
+ // dependency-arrow target geometry and join flow-key dedup without
330
+ // entering the human-referenceable namespace above. The item itself is
331
+ // registered in `ctx.placedItems` once it is built (below); its arrow
332
+ // and slack-arrow attach points are derived from its final box later.
333
+ const drawKey = id ?? syntheticItemKey(node);
334
+ ctx.itemFlowKey.set(drawKey, ctx.currentFlowKey);
335
+
336
+ // A parallel track joins, and its container's bracket spans, from
337
+ // `cursor.startX` to `cursor.x`.
338
+ cursor.startX = logicalLeft;
339
+ cursor.x = durationEndX;
340
+ // The next row in a parallel/group/lane starts at
341
+ // `cursor.y + cursor.height`. Default pitch is `bandScale.step()`
342
+ // (bandwidth + inter-row gap). When the bar grew to enclose a
343
+ // spilled chip column or a wrapped title, the pitch grows by the
344
+ // SAME amount so the inter-row gap stays constant — the next row's
345
+ // bar starts `step − bandwidth` px below the (now-taller) bar bottom.
346
+ cursor.height = Math.max(cursor.height, ctx.bandScale.step() + barExtra);
347
+
348
+ const inlineDatePins = computeItemInlineDatePins({
349
+ box: itemBox,
350
+ afterDate: pickInlineDate(afterRaw),
351
+ beforeDate: pickInlineDate(beforeRaw),
352
+ hasLinkIcon,
353
+ footnoteCount: footnoteIndicators.length,
354
+ });
355
+
356
+ const result: PositionedItem = {
357
+ kind: 'item',
358
+ id,
359
+ title: titleStr,
360
+ // Only present when the painted lines differ from `[title]`: an
361
+ // auto-wrap, explicit breaks, or a stray leading/trailing break
362
+ // that was trimmed away. A plain one-line title carries no key.
363
+ ...(captionLines.length >= 2 || captionLines[0] !== titleStr
364
+ ? { titleLines: captionLines }
365
+ : {}),
366
+ box: itemBox,
367
+ dateSpan: { startX: logicalLeft, endX: logicalRight },
368
+ status,
369
+ statusDotColor: statusDotColor(status, style.bg, ctx.styleCtx.theme),
370
+ progressFraction: progress,
371
+ footnoteIndicators,
372
+ labelChips,
373
+ chipsOutside,
374
+ chipsRightX,
375
+ linkIcon: linkInfo.icon,
376
+ linkHref: linkInfo.href,
377
+ hasOverflow,
378
+ overflowBox,
379
+ overflowAnchorId,
380
+ owner,
381
+ description,
382
+ metaText,
383
+ textSpills,
384
+ dotSpills,
385
+ iconSpills,
386
+ footnoteSpills,
387
+ dotSpillCx,
388
+ iconSpillX,
389
+ footnoteSpillStartX,
390
+ decorationsRightX,
391
+ capacity,
392
+ size: measure.size,
393
+ style,
394
+ inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
395
+ };
396
+ // Wave fields exist only in a roadmap with waves (omitted, never
397
+ // undefined, so wave-free models keep their exact shape).
398
+ const waveRole = waveRoleOf(node, ctx);
399
+ if (waveRole !== undefined) result.waveRole = waveRole;
400
+ if (waveRole === 'background') result.waveHatch = waveHatchKind(style.bg);
401
+ if (nonWorkingPin !== undefined) result.nonWorkingPin = nonWorkingPin;
402
+ // The caption cap at the chart's end truncated a title line; the full
403
+ // title stays the bar's tooltip.
404
+ if (caption.truncated) result.titleTruncated = true;
405
+ // A background item carries its localized hover line from placement
406
+ // on (specs/waves.md §9.3); members and wave-free items carry none.
407
+ if (waveRole === 'background' && ctx.waves) {
408
+ result.waveTooltip = ctx.waves.backgroundTooltip;
409
+ }
410
+ // Register the item object itself, not coordinates sampled from it:
411
+ // the row packer and the marker-band shift may still move this box
412
+ // down, and every attach port is read off the final box (see
413
+ // `item-port-geometry.ts`).
414
+ ctx.placedItems.set(drawKey, result);
415
+ traceItemNode(itemNode, placeCtx, result);
416
+ return result;
417
+ }
418
+
419
+ /**
420
+ * Internal drawing handle for an id-less item. Title-only items have no
421
+ * `node.name`, yet they still need a stable key to register their own
422
+ * dependency-arrow target geometry and join flow-key dedup. The handle is
423
+ * derived from the item's source position so it is deterministic and
424
+ * byte-stable, and the `#item@line:col` form cannot be produced by the
425
+ * grammar's id rule — so `after:` / `before:` / `on:` can never resolve to it
426
+ * (an id-less item stays non-referenceable; declare an explicit id to
427
+ * reference it). Distinct items always start at distinct positions, so the
428
+ * handle is unique within a file.
429
+ */
430
+ export function syntheticItemKey(node: ItemDeclaration): string {
431
+ const start = node.$cstNode?.range.start;
432
+ return `#item@${start ? start.line + 1 : 0}:${start ? start.character + 1 : 0}`;
433
+ }