@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
@@ -4,7 +4,7 @@
4
4
  // slot; `before:DATE` paints it in the top-RIGHT slot. The two helpers
5
5
  // below produce `Point` coordinates for the glyph's top-left corner, plus
6
6
  // a `spilled` flag indicating whether the bar was too narrow to host the
7
- // glyph inside (item case only — containers always have room).
7
+ // glyph inside (item case only — containers never spill).
8
8
  //
9
9
  // Slot interleaving rules (per specs/rendering.md "Inline-date glyph"):
10
10
  //
@@ -18,25 +18,26 @@
18
18
  // footnotes are present (so the inline-date glyph inserts at the LEFT
19
19
  // end of the existing badge cluster rather than reordering it).
20
20
  //
21
- // Container (group, parallel): glyph sits at the bounding box's top
22
- // corners with the standard inset. Containers don't carry status dots
23
- // or footnote indicators in their own decoration row, so no
24
- // interleaving math is needed.
21
+ // Container (group, parallel): the glyphs sit in the header band, in the
22
+ // title row above the bracket (see `computeContainerInlineDatePins`).
23
+ // Containers don't carry status dots or footnote indicators, so the
24
+ // only neighbor to clear is the container's own title.
25
25
 
26
+ import { parseIsoDate } from '@nowline/core/internal';
27
+ import {
28
+ CONTAINER_BRACKET_LINE_OFFSET_PX,
29
+ containerHeaderTitleWidth,
30
+ containerHeaderTitleX,
31
+ } from './container-header-geometry.js';
26
32
  import {
27
33
  INLINE_DATE_GLYPH_GAP_PX,
28
- INLINE_DATE_GLYPH_INSET_LEFT_PX,
29
34
  INLINE_DATE_GLYPH_INSET_RIGHT_PX,
30
35
  INLINE_DATE_GLYPH_INSET_TOP_PX,
31
36
  INLINE_DATE_GLYPH_TILE_SIZE_PX,
32
37
  ITEM_CAPTION_SPILL_GAP_PX,
33
- ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
34
- ITEM_FOOTNOTE_INDICATOR_STEP_PX,
35
- ITEM_LINK_ICON_INSET_PX,
36
- ITEM_LINK_ICON_TILE_SIZE_PX,
37
- ITEM_STATUS_DOT_INSET_RIGHT_PX,
38
- ITEM_STATUS_DOT_RADIUS_PX,
39
- MIN_BAR_WIDTH_FOR_INLINE_DATE_PX,
38
+ inlineDateGlyphSpills,
39
+ itemAfterGlyphInsideLeftX,
40
+ itemBeforeGlyphInsideLeftX,
40
41
  } from './item-bar-geometry.js';
41
42
  import type { BoundingBox, InlineDatePin, Point } from './types.js';
42
43
 
@@ -68,15 +69,13 @@ export function computeItemInlineDatePins(opts: ItemInlineDatePinInputs): Inline
68
69
  const pins: InlineDatePin[] = [];
69
70
  const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
70
71
  const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
71
- const spilled = box.width < MIN_BAR_WIDTH_FOR_INLINE_DATE_PX;
72
+ const spilled = inlineDateGlyphSpills(box.width);
72
73
 
73
74
  if (afterDate) {
74
- const insideLeftX = hasLinkIcon
75
- ? box.x +
76
- ITEM_LINK_ICON_INSET_PX +
77
- ITEM_LINK_ICON_TILE_SIZE_PX +
78
- INLINE_DATE_GLYPH_GAP_PX
79
- : box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX;
75
+ // Walk RIGHT past the link tile when there is one; the formula is
76
+ // shared with the caption's left inset, see
77
+ // `itemAfterGlyphInsideLeftX`.
78
+ const insideLeftX = itemAfterGlyphInsideLeftX(box.x, hasLinkIcon);
80
79
  const glyphLeft: Point = spilled
81
80
  ? {
82
81
  x: box.x - ITEM_CAPTION_SPILL_GAP_PX - tileSize,
@@ -93,31 +92,17 @@ export function computeItemInlineDatePins(opts: ItemInlineDatePinInputs): Inline
93
92
  }
94
93
 
95
94
  if (beforeDate) {
96
- // Walk LEFT from the rightmost top-decoration slot:
97
- // - rightmost footnote anchors at (box.right - INSET_RIGHT_PX)
98
- // - leftmost footnote sits one step further left per extra digit
99
- // - status dot left edge sits at (box.right - INSET_RIGHT - DOT_RADIUS)
100
- // - inline-date glyph sits one INLINE_DATE_GLYPH_GAP_PX further left
95
+ // Walk LEFT from the rightmost top-decoration slot; the formula is
96
+ // shared with the title first-line clearance, see
97
+ // `itemBeforeGlyphInsideLeftX`.
101
98
  const rightEdge = box.x + box.width;
102
- let anchorRightX: number;
103
- if (footnoteCount > 0) {
104
- const leftmostFootnoteCenter =
105
- rightEdge -
106
- ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX -
107
- (footnoteCount - 1) * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
108
- anchorRightX = leftmostFootnoteCenter - INLINE_DATE_GLYPH_GAP_PX;
109
- } else {
110
- const dotLeftEdge =
111
- rightEdge - ITEM_STATUS_DOT_INSET_RIGHT_PX - ITEM_STATUS_DOT_RADIUS_PX;
112
- anchorRightX = dotLeftEdge - INLINE_DATE_GLYPH_GAP_PX;
113
- }
114
- const insideRightX = anchorRightX - tileSize;
99
+ const insideLeftX = itemBeforeGlyphInsideLeftX(rightEdge, footnoteCount);
115
100
  const glyphLeft: Point = spilled
116
101
  ? {
117
102
  x: rightEdge + ITEM_CAPTION_SPILL_GAP_PX,
118
103
  y: topY,
119
104
  }
120
- : { x: insideRightX, y: topY };
105
+ : { x: insideLeftX, y: topY };
121
106
  pins.push({
122
107
  side: 'before',
123
108
  isoDate: beforeDate,
@@ -131,50 +116,60 @@ export function computeItemInlineDatePins(opts: ItemInlineDatePinInputs): Inline
131
116
  }
132
117
 
133
118
  export interface ContainerInlineDatePinInputs {
134
- /** Bounding box that anchors the glyph corners. For styled groups and
135
- * bracketed parallels this is the visible box; for unstyled groups
136
- * and bare parallels it is the logical bounding box (leftmost child
137
- * start, rightmost child end, top of the highest child row). */
119
+ /** The container's box: `box.y` is its header's top, `box.x` and
120
+ * `box.width` its dates. */
138
121
  box: BoundingBox;
139
122
  afterDate: string | undefined;
140
123
  beforeDate: string | undefined;
124
+ /** The title painted in the header, if any. */
125
+ title: string | undefined;
141
126
  }
142
127
 
143
128
  /**
144
129
  * Compute inline-date pin glyph placements for a container (group or
145
- * parallel). The glyphs sit flush to the box's top-LEFT (`after`) and
146
- * top-RIGHT (`before`) corners with the standard inset; containers
147
- * never spill (they always have room for a 12 px tile in their own
148
- * top-decoration row).
130
+ * parallel). Containers never spill. Both glyphs sit in the header's title
131
+ * row, above the bracket line (specs/true-width-layout.md §3.3):
132
+ *
133
+ * - `after` takes the row's leftmost slot, on the container's start; the
134
+ * title follows one gap past it (`containerHeaderTitleX`).
135
+ * - `before` sits the standard inset short of the container's end (a
136
+ * sibling may start on that end, level with the header), but never
137
+ * slides left onto the title (estimated width) or the `after` glyph:
138
+ * when those run past its slot, it sits one gap past whichever ends
139
+ * last, beyond the bracket's end, and the row reserves it.
149
140
  */
150
141
  export function computeContainerInlineDatePins(
151
142
  opts: ContainerInlineDatePinInputs,
152
143
  ): InlineDatePin[] {
153
- const { box, afterDate, beforeDate } = opts;
144
+ const { box, afterDate, beforeDate, title } = opts;
154
145
  if (!afterDate && !beforeDate) return [];
155
146
 
156
147
  const pins: InlineDatePin[] = [];
157
148
  const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
158
- const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
149
+ const topY = box.y + (CONTAINER_BRACKET_LINE_OFFSET_PX - tileSize) / 2;
150
+ // Right edge of whatever already occupies the row's left end.
151
+ let leftClearX = title
152
+ ? containerHeaderTitleX(box.x, Boolean(afterDate)) + containerHeaderTitleWidth(title)
153
+ : box.x;
159
154
 
160
155
  if (afterDate) {
156
+ leftClearX = Math.max(leftClearX, box.x + tileSize);
161
157
  pins.push({
162
158
  side: 'after',
163
159
  isoDate: afterDate,
164
- glyphTopLeft: { x: box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX, y: topY },
160
+ glyphTopLeft: { x: box.x, y: topY },
165
161
  glyphSize: tileSize,
166
162
  spilled: false,
167
163
  });
168
164
  }
169
165
 
170
166
  if (beforeDate) {
167
+ const flushRightX = box.x + box.width - INLINE_DATE_GLYPH_INSET_RIGHT_PX - tileSize;
168
+ const clearX = leftClearX > box.x ? leftClearX + INLINE_DATE_GLYPH_GAP_PX : box.x;
171
169
  pins.push({
172
170
  side: 'before',
173
171
  isoDate: beforeDate,
174
- glyphTopLeft: {
175
- x: box.x + box.width - INLINE_DATE_GLYPH_INSET_RIGHT_PX - tileSize,
176
- y: topY,
177
- },
172
+ glyphTopLeft: { x: Math.max(flushRightX, clearX), y: topY },
178
173
  glyphSize: tileSize,
179
174
  spilled: false,
180
175
  });
@@ -184,13 +179,15 @@ export function computeContainerInlineDatePins(
184
179
  }
185
180
 
186
181
  /**
187
- * Returns the first ISO date literal in `values`, or undefined when none
188
- * is present. The validator enforces "at most one inline date per
189
- * direction" so this lookup is unambiguous.
182
+ * Returns the first ISO date literal in `values` that names a real
183
+ * calendar day, or undefined when none is present. The validator enforces
184
+ * "at most one inline date per direction" so this lookup is unambiguous.
185
+ * An impossible day (`2026-02-30`, NL.E0405) is no date: the scheduler
186
+ * ignores it, so it gets no glyph either.
190
187
  */
191
188
  export function pickInlineDate(values: readonly string[]): string | undefined {
192
189
  for (const v of values) {
193
- if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
190
+ if (parseIsoDate(v)) return v;
194
191
  }
195
192
  return undefined;
196
193
  }
@@ -14,6 +14,8 @@
14
14
  // `box.width` used as the "right edge" / "bottom edge" anchor where
15
15
  // noted.
16
16
 
17
+ import { estimateTextWidth } from './text-measure.js';
18
+
17
19
  // ---- Caption (title + meta text) ---------------------------------
18
20
 
19
21
  /** Horizontal inset (px) for the caption's left edge inside the bar. */
@@ -39,6 +41,66 @@ export const ITEM_CAPTION_TITLE_FONT_SIZE_PX = 13;
39
41
  /** Font size (px) of the meta text. */
40
42
  export const ITEM_CAPTION_META_FONT_SIZE_PX = 11;
41
43
 
44
+ /**
45
+ * Baseline-to-baseline pitch (px) between consecutive TITLE lines when
46
+ * a long title word-wraps inside the bar. The meta line sits one more
47
+ * pitch below the last title line for every extra title line, see
48
+ * `itemCaptionMetaBaselineOffset`.
49
+ */
50
+ export const ITEM_CAPTION_TITLE_LINE_HEIGHT_PX = 16;
51
+
52
+ /**
53
+ * Most title lines layout will wrap a title onto inside the bar. A
54
+ * title that needs more than this spills to the right of the bar as a
55
+ * single line instead.
56
+ */
57
+ export const ITEM_CAPTION_TITLE_MAX_LINES = 2;
58
+
59
+ /**
60
+ * Baseline Y (px from the bar's top) of the meta line when the title
61
+ * occupies `lineCount` lines. One line gives the classic
62
+ * `ITEM_CAPTION_META_BASELINE_OFFSET_PX`; each extra title line pushes
63
+ * the meta baseline down by `ITEM_CAPTION_TITLE_LINE_HEIGHT_PX`.
64
+ */
65
+ export function itemCaptionMetaBaselineOffset(lineCount: number): number {
66
+ return (
67
+ ITEM_CAPTION_META_BASELINE_OFFSET_PX +
68
+ (Math.max(1, lineCount) - 1) * ITEM_CAPTION_TITLE_LINE_HEIGHT_PX
69
+ );
70
+ }
71
+
72
+ /**
73
+ * Baseline Y (px from the bar's top) of the LAST caption line: the
74
+ * meta line when the item has one, otherwise the final title line.
75
+ * Anything stacked below the caption (label chips) must clear this.
76
+ */
77
+ export function itemCaptionLastBaselineOffset(lineCount: number, hasMeta: boolean): number {
78
+ if (hasMeta) return itemCaptionMetaBaselineOffset(lineCount);
79
+ return (
80
+ ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX +
81
+ (Math.max(1, lineCount) - 1) * ITEM_CAPTION_TITLE_LINE_HEIGHT_PX
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Extra bar height (px) a wrapped title needs on top of the default
87
+ * `bandwidth`. The default bar is sized so the caption's last baseline
88
+ * is at most `ITEM_CAPTION_META_BASELINE_OFFSET_PX`; anything below
89
+ * that grows the bar by the overshoot:
90
+ *
91
+ * - 1 title line (with or without meta): 0.
92
+ * - 2 title lines, no meta: line 2 lands at baseline 36, so 0.
93
+ * - 2 title lines + meta: meta baseline 54, so 16.
94
+ *
95
+ * The same number is the row-pitch increase for the bar's row.
96
+ */
97
+ export function computeTitleBarExtra(lineCount: number, hasMeta: boolean): number {
98
+ return Math.max(
99
+ 0,
100
+ itemCaptionLastBaselineOffset(lineCount, hasMeta) - ITEM_CAPTION_META_BASELINE_OFFSET_PX,
101
+ );
102
+ }
103
+
42
104
  // ---- Status dot (upper-right glyph) ------------------------------
43
105
 
44
106
  /** Distance (px) from the bar's right edge to the dot's center. */
@@ -61,6 +123,13 @@ export const ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX = 22;
61
123
  /** Baseline Y (px from bar's top) for footnote indicator digits. */
62
124
  export const ITEM_FOOTNOTE_INDICATOR_BASELINE_OFFSET_PX = 14;
63
125
 
126
+ /**
127
+ * Font size (px) of the footnote indicator digits. The renderer paints
128
+ * them at this size and layout measures them with it (to find the
129
+ * cluster's left edge), so both read the same constant.
130
+ */
131
+ export const ITEM_FOOTNOTE_INDICATOR_FONT_SIZE_PX = 10;
132
+
64
133
  /**
65
134
  * Horizontal step (px) between consecutive footnote indicators when
66
135
  * an item carries more than one footnote — they walk LEFT from the
@@ -76,6 +145,65 @@ export const ITEM_LINK_ICON_TILE_SIZE_PX = 14;
76
145
  /** Distance (px) from the bar's right/bottom edges to the tile's edge. */
77
146
  export const ITEM_LINK_ICON_INSET_PX = 6;
78
147
 
148
+ /**
149
+ * Horizontal gap (px) between the link-icon tile's right edge and the
150
+ * start of the caption text when both render inside the bar.
151
+ */
152
+ export const ITEM_LINK_ICON_TO_CAPTION_GAP_PX = 4;
153
+
154
+ /**
155
+ * The item carries an inline `after:DATE` glyph, on a bar of the given
156
+ * painted width. Passed to `itemCaptionInsetX` so the glyph, not just
157
+ * the link tile, reserves room at the caption's left edge.
158
+ */
159
+ export interface ItemCaptionAfterGlyph {
160
+ /** Painted bar width (px): `box.width`. Decides whether the glyph spills. */
161
+ barWidth: number;
162
+ }
163
+
164
+ /**
165
+ * Left inset (px) of the in-bar caption from the bar's left edge. It is
166
+ * `ITEM_CAPTION_INSET_X_PX` normally, pushed right past the link-icon
167
+ * tile (and its gap) when the bar carries a link icon, and pushed right
168
+ * past the `after:` inline-date glyph (and a gap) when the bar carries
169
+ * one INSIDE it:
170
+ *
171
+ * - no link icon, no glyph: 12
172
+ * - link icon, no glyph: 24
173
+ * - glyph, no link icon: 22 (the glyph spans 6..18, plus a 4 px gap)
174
+ * - glyph and link icon: 40 (the glyph spans 24..36, plus a 4 px gap)
175
+ *
176
+ * A glyph on a bar narrower than `MIN_BAR_WIDTH_FOR_INLINE_DATE_PX`
177
+ * spills out of the bar to the left and reserves nothing.
178
+ *
179
+ * `hasLinkIcon` is the same flag `computeItemInlineDatePins` places the
180
+ * glyph with, and the glyph's right edge comes from the same helper
181
+ * (`itemAfterGlyphInsideLeftX`), so the inset and the glyph can't
182
+ * disagree. The inset applies to every caption line, not only line 1,
183
+ * so the caption's left edge stays straight.
184
+ */
185
+ export function itemCaptionInsetX(
186
+ hasLinkIcon: boolean,
187
+ afterGlyph?: ItemCaptionAfterGlyph,
188
+ ): number {
189
+ let inset = ITEM_CAPTION_INSET_X_PX;
190
+ if (hasLinkIcon) {
191
+ const linkColumn =
192
+ ITEM_LINK_ICON_INSET_PX +
193
+ ITEM_LINK_ICON_TILE_SIZE_PX +
194
+ ITEM_LINK_ICON_TO_CAPTION_GAP_PX -
195
+ ITEM_CAPTION_INSET_X_PX;
196
+ inset = ITEM_CAPTION_INSET_X_PX + Math.max(0, linkColumn);
197
+ }
198
+ if (afterGlyph && !inlineDateGlyphSpills(afterGlyph.barWidth)) {
199
+ // Offsets from a left edge of 0, so the numbers are exact integers.
200
+ const glyphRight =
201
+ itemAfterGlyphInsideLeftX(0, hasLinkIcon) + INLINE_DATE_GLYPH_TILE_SIZE_PX;
202
+ inset = Math.max(inset, glyphRight + ITEM_DECORATION_SPILL_GAP_PX);
203
+ }
204
+ return inset;
205
+ }
206
+
79
207
  // ---- Narrow-bar decoration spill --------------------------------
80
208
 
81
209
  /**
@@ -86,12 +214,14 @@ export const ITEM_LINK_ICON_INSET_PX = 6;
86
214
  export const ITEM_DECORATION_SPILL_GAP_PX = 4;
87
215
 
88
216
  /**
89
- * Minimum bar width (px) needed to host the status dot inside the
90
- * bar with its full inset. Below this, the dot would have to
91
- * extend past the bar's left edge, so the dot spills into the
92
- * caption column to the right of the bar instead.
217
+ * Minimum bar width (px) at which the status dot sits INSIDE the bar:
218
+ * the dot's centre is `ITEM_STATUS_DOT_INSET_RIGHT_PX` from the right
219
+ * edge, so a bar of twice that width has the same air on both sides
220
+ * of it (the centre sits mid-bar). A narrower bar would crowd the
221
+ * dot against its left edge, so the dot spills into the caption
222
+ * column to the right of the bar instead.
93
223
  */
94
- export const MIN_BAR_WIDTH_FOR_DOT_PX = ITEM_STATUS_DOT_INSET_RIGHT_PX + ITEM_STATUS_DOT_RADIUS_PX;
224
+ export const MIN_BAR_WIDTH_FOR_DOT_PX = 2 * ITEM_STATUS_DOT_INSET_RIGHT_PX;
95
225
 
96
226
  /**
97
227
  * Minimum bar width (px) needed to host the link-icon tile AND the
@@ -116,6 +246,71 @@ export const MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX =
116
246
  */
117
247
  export const MIN_BAR_WIDTH_FOR_FOOTNOTE_PX = ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX + 1;
118
248
 
249
+ /** What sits in a bar's spill column, in reading order. */
250
+ export interface SpillClusterInput {
251
+ /** The bar's visual right edge. */
252
+ barRightX: number;
253
+ /** The link icon spilled out of the bar. */
254
+ iconSpills: boolean;
255
+ /** The spilled caption's width (its widest title line or the meta line), or null when it stays in the bar. */
256
+ captionWidth: number | null;
257
+ /** Spilled footnote indicators; 0 when they stay in the bar. */
258
+ footnoteCount: number;
259
+ /** The status dot spilled out of the bar. */
260
+ dotSpills: boolean;
261
+ }
262
+
263
+ /** Where each part of a spill column sits; null for a part that is not in it. */
264
+ export interface SpillCluster {
265
+ iconSpillX: number | null;
266
+ captionX: number | null;
267
+ footnoteSpillStartX: number | null;
268
+ dotSpillCx: number | null;
269
+ /** Right edge of the last part, or the column's start when it is empty. */
270
+ rightX: number;
271
+ }
272
+
273
+ /**
274
+ * Lay out a bar's spill column `[icon?] [caption?] [¹²?] [dot?]` from
275
+ * `ITEM_CAPTION_SPILL_GAP_PX` past the bar, one `ITEM_DECORATION_SPILL_GAP_PX`
276
+ * between the parts that are present and none after the last. The item
277
+ * measure reads it to cap a caption at the chart's end, and `sequenceItem`
278
+ * places the parts with it.
279
+ */
280
+ export function layoutSpillCluster(input: SpillClusterInput): SpillCluster {
281
+ let cursor = input.barRightX + ITEM_CAPTION_SPILL_GAP_PX;
282
+ // A gap goes in only once something is already in the column, so the
283
+ // cluster leaves no dangling gap past its last part.
284
+ let needGap = false;
285
+ let iconSpillX: number | null = null;
286
+ if (input.iconSpills) {
287
+ iconSpillX = cursor;
288
+ cursor = iconSpillX + ITEM_LINK_ICON_TILE_SIZE_PX;
289
+ needGap = true;
290
+ }
291
+ let captionX: number | null = null;
292
+ if (input.captionWidth !== null) {
293
+ if (needGap) cursor += ITEM_DECORATION_SPILL_GAP_PX;
294
+ captionX = cursor;
295
+ cursor += input.captionWidth;
296
+ needGap = true;
297
+ }
298
+ let footnoteSpillStartX: number | null = null;
299
+ if (input.footnoteCount > 0) {
300
+ if (needGap) cursor += ITEM_DECORATION_SPILL_GAP_PX;
301
+ footnoteSpillStartX = cursor;
302
+ cursor += input.footnoteCount * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
303
+ needGap = true;
304
+ }
305
+ let dotSpillCx: number | null = null;
306
+ if (input.dotSpills) {
307
+ if (needGap) cursor += ITEM_DECORATION_SPILL_GAP_PX;
308
+ dotSpillCx = cursor + ITEM_STATUS_DOT_RADIUS_PX;
309
+ cursor = dotSpillCx + ITEM_STATUS_DOT_RADIUS_PX;
310
+ }
311
+ return { iconSpillX, captionX, footnoteSpillStartX, dotSpillCx, rightX: cursor };
312
+ }
313
+
119
314
  // ---- Inline-date glyph (after:DATE / before:DATE corner badge) ---
120
315
  //
121
316
  // Renders the renderer's built-in `calendar` SVG at top-LEFT (`after`)
@@ -165,6 +360,147 @@ export const MIN_BAR_WIDTH_FOR_INLINE_DATE_PX =
165
360
  INLINE_DATE_GLYPH_TILE_SIZE_PX +
166
361
  INLINE_DATE_GLYPH_INSET_RIGHT_PX;
167
362
 
363
+ /**
364
+ * True when a bar of painted width `barWidth` is too narrow to host the
365
+ * inline-date glyph inside, so it spills (`before:` right, `after:` left).
366
+ * The one threshold behind the glyph's `spilled` flag and the caption
367
+ * inset's decision to reserve room for it.
368
+ */
369
+ export function inlineDateGlyphSpills(barWidth: number): boolean {
370
+ return barWidth < MIN_BAR_WIDTH_FOR_INLINE_DATE_PX;
371
+ }
372
+
373
+ /**
374
+ * Left x of the `after:` inline-date glyph when it sits INSIDE the bar,
375
+ * for a bar whose left edge is at `leftEdge`:
376
+ *
377
+ * - no link icon: the glyph sits at the leftmost top-decoration slot,
378
+ * `INLINE_DATE_GLYPH_INSET_LEFT_PX` (6) from the edge
379
+ * - link icon: one `INLINE_DATE_GLYPH_GAP_PX` right of the tile's right
380
+ * edge (6 + 14 + 4 = 24 from the edge)
381
+ *
382
+ * The one formula behind both the glyph placement
383
+ * (`computeItemInlineDatePins`) and the caption's left inset
384
+ * (`itemCaptionInsetX`), so the two can never disagree. Pass
385
+ * `leftEdge = 0` to get the offset from the bar's left edge.
386
+ */
387
+ export function itemAfterGlyphInsideLeftX(leftEdge: number, hasLinkIcon: boolean): number {
388
+ return hasLinkIcon
389
+ ? leftEdge +
390
+ ITEM_LINK_ICON_INSET_PX +
391
+ ITEM_LINK_ICON_TILE_SIZE_PX +
392
+ INLINE_DATE_GLYPH_GAP_PX
393
+ : leftEdge + INLINE_DATE_GLYPH_INSET_LEFT_PX;
394
+ }
395
+
396
+ /**
397
+ * Left x of the `before:` inline-date glyph when it sits INSIDE the bar,
398
+ * for a bar whose right edge is at `rightEdge`. The glyph walks LEFT
399
+ * from the rightmost top-decoration slot:
400
+ *
401
+ * - rightmost footnote anchors at (rightEdge - INSET_RIGHT_PX)
402
+ * - the leftmost footnote sits one step further left per extra digit
403
+ * - the status dot's left edge sits at (rightEdge - INSET_RIGHT - DOT_RADIUS)
404
+ * - the glyph sits one `INLINE_DATE_GLYPH_GAP_PX` further left than
405
+ * whichever of those is leftmost (footnotes win when present, since
406
+ * they are always left of the dot)
407
+ *
408
+ * The one formula behind both the glyph placement
409
+ * (`computeItemInlineDatePins`) and the title first-line clearance
410
+ * (`itemTitleFirstLineRightReservePx`), so the two can never disagree.
411
+ * Pass `rightEdge = 0` to get the offset from the bar's right edge
412
+ * (negative, inside the bar) with exact integer arithmetic.
413
+ */
414
+ export function itemBeforeGlyphInsideLeftX(rightEdge: number, footnoteCount: number): number {
415
+ let anchorRightX: number;
416
+ if (footnoteCount > 0) {
417
+ const leftmostFootnoteCenter =
418
+ rightEdge -
419
+ ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX -
420
+ (footnoteCount - 1) * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
421
+ anchorRightX = leftmostFootnoteCenter - INLINE_DATE_GLYPH_GAP_PX;
422
+ } else {
423
+ const dotLeftEdge = rightEdge - ITEM_STATUS_DOT_INSET_RIGHT_PX - ITEM_STATUS_DOT_RADIUS_PX;
424
+ anchorRightX = dotLeftEdge - INLINE_DATE_GLYPH_GAP_PX;
425
+ }
426
+ return anchorRightX - INLINE_DATE_GLYPH_TILE_SIZE_PX;
427
+ }
428
+
429
+ // ---- Title first-line clearance (top-right decoration cluster) ---
430
+ //
431
+ // The status dot, footnote digits and `before:` glyph all sit in the
432
+ // bar's upper-right at the title's first-line height (title baseline
433
+ // 20, cluster y roughly 5-17). Line 2 (baseline 36) and the meta line
434
+ // (baseline 38) are below the cluster, so only the FIRST title line has
435
+ // to stay clear of it; every other caption line may use the full inner
436
+ // width.
437
+
438
+ export interface ItemTitleFirstLineReserveInputs {
439
+ /** Painted bar width (px): `box.width`. */
440
+ barWidth: number;
441
+ /**
442
+ * Footnote indicator labels in the order the renderer paints them,
443
+ * left to right (ascending footnote number). Empty when the item
444
+ * carries no footnote.
445
+ */
446
+ footnoteLabels: readonly string[];
447
+ /** True when the item has an inline `before:DATE` glyph. */
448
+ hasBeforeGlyph: boolean;
449
+ }
450
+
451
+ /**
452
+ * Right-side space (px) the FIRST title line must leave free, measured
453
+ * from the bar's right edge: the larger of the plain right inset
454
+ * (`ITEM_CAPTION_INSET_X_PX`) and the distance to the top-right
455
+ * cluster's left edge plus `ITEM_DECORATION_SPILL_GAP_PX` of air.
456
+ *
457
+ * The cluster is the leftmost of three parts, each counted only when it
458
+ * renders INSIDE the bar (a narrow bar spills it into the column to the
459
+ * right instead, where it can't touch the title):
460
+ *
461
+ * - status dot: left edge at (right - 17); absent below
462
+ * `MIN_BAR_WIDTH_FOR_DOT_PX` (24). Dot only gives 21.
463
+ * - footnote digits: leftmost digit's left edge at
464
+ * (right - 22 - (n-1) * 8 - width); absent when they spill
465
+ * (`MIN_BAR_WIDTH_FOR_FOOTNOTE_PX`).
466
+ * - `before:` glyph: its left x, from `itemBeforeGlyphInsideLeftX`;
467
+ * absent when it spills (`MIN_BAR_WIDTH_FOR_INLINE_DATE_PX`).
468
+ *
469
+ * Offsets are computed from a right edge of 0 (inside the bar is
470
+ * negative) so the common cases are exact integers.
471
+ */
472
+ export function itemTitleFirstLineRightReservePx(opts: ItemTitleFirstLineReserveInputs): number {
473
+ const { barWidth, footnoteLabels, hasBeforeGlyph } = opts;
474
+ let clusterLeft = Number.POSITIVE_INFINITY;
475
+ if (barWidth >= MIN_BAR_WIDTH_FOR_DOT_PX) {
476
+ clusterLeft = Math.min(
477
+ clusterLeft,
478
+ -(ITEM_STATUS_DOT_INSET_RIGHT_PX + ITEM_STATUS_DOT_RADIUS_PX),
479
+ );
480
+ }
481
+ const footnoteCount = footnoteLabels.length;
482
+ const footnotesInside = footnoteCount > 0 && barWidth >= MIN_BAR_WIDTH_FOR_FOOTNOTE_PX;
483
+ if (footnotesInside) {
484
+ // Digit `k` (0 = leftmost) anchors its RIGHT end at
485
+ // `-22 - (n-1-k) * 8`, so its left edge is that minus its width.
486
+ // Normally digit 0 reaches furthest left; checking all of them
487
+ // also covers a wide late digit (e.g. footnote 100 next to 9).
488
+ for (let k = 0; k < footnoteCount; k += 1) {
489
+ const anchorX =
490
+ -ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX -
491
+ (footnoteCount - 1 - k) * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
492
+ const left =
493
+ anchorX -
494
+ estimateTextWidth(footnoteLabels[k], ITEM_FOOTNOTE_INDICATOR_FONT_SIZE_PX);
495
+ clusterLeft = Math.min(clusterLeft, left);
496
+ }
497
+ }
498
+ if (hasBeforeGlyph && barWidth >= MIN_BAR_WIDTH_FOR_INLINE_DATE_PX) {
499
+ clusterLeft = Math.min(clusterLeft, itemBeforeGlyphInsideLeftX(0, footnoteCount));
500
+ }
501
+ return Math.max(ITEM_CAPTION_INSET_X_PX, -clusterLeft + ITEM_DECORATION_SPILL_GAP_PX);
502
+ }
503
+
168
504
  // ---- Label chips (along the bar's bottom) ------------------------
169
505
 
170
506
  /** Height (px) of a label chip rectangle. */
@@ -0,0 +1,69 @@
1
+ // Arrow ends: where dependency arrows and milestone slack lines meet an
2
+ // item bar or a container.
3
+ //
4
+ // Every end is a pure function of the entity's FINAL geometry (its box,
5
+ // plus an item's `dateSpan` and the row bandwidth), read when the arrows
6
+ // are built, never captured while the entity is placed. Layout moves
7
+ // placed content down afterwards (a marker band that outgrows its sizing
8
+ // pushes the whole chart down), and a port captured at placement time
9
+ // would stay behind at the old y. Nothing moves horizontally, so the
10
+ // dates in x an item records at placement stay true.
11
+ //
12
+ // Rules (specs/true-width-layout.md §3.4, §3.5):
13
+ //
14
+ // - x comes from the dates: the router puts the port in the middle of
15
+ // the source's last day and the target's first day (`edge-routing.ts`).
16
+ // - y comes from the painted edges: top and bottom of the bar, or of the
17
+ // container (its header's top to its body's bottom).
18
+ // - On its own row an arrow runs at the row's NOMINAL midline,
19
+ // `box.y + bandwidth / 2`, so a bar grown by a wrapped title or a chip
20
+ // column shares the line with its un-grown row neighbours. A source
21
+ // whose caption spills right leaves at the centre of its bottom
22
+ // progress strip instead, so the leg runs under the caption; milestone
23
+ // slack lines leave on the same y.
24
+
25
+ import type { ArrowEnd } from './edge-routing.js';
26
+ import { PROGRESS_STRIP_HEIGHT_PX } from './themes/shared.js';
27
+ import type { PositionedGroup, PositionedItem, PositionedParallel } from './types.js';
28
+
29
+ /** Y of the bottom progress strip's vertical center. */
30
+ function progressStripMidY(item: PositionedItem): number {
31
+ return item.box.y + item.box.height - PROGRESS_STRIP_HEIGHT_PX / 2;
32
+ }
33
+
34
+ /** An item as an arrow end. */
35
+ export function itemArrowEnd(item: PositionedItem, bandwidth: number): ArrowEnd {
36
+ const { box } = item;
37
+ return {
38
+ startX: item.dateSpan.startX,
39
+ endX: item.dateSpan.endX,
40
+ paintedX: box.x,
41
+ paintedWidth: box.width,
42
+ top: box.y,
43
+ bottom: box.y + box.height,
44
+ rowY: box.y + bandwidth / 2,
45
+ exitY: itemSlackAttachY(item, bandwidth),
46
+ };
47
+ }
48
+
49
+ /** A group or parallel as an arrow source: its box spans exactly its dates. */
50
+ export function containerArrowEnd(container: PositionedGroup | PositionedParallel): ArrowEnd {
51
+ const { box } = container;
52
+ return {
53
+ startX: box.x,
54
+ endX: box.x + box.width,
55
+ paintedX: box.x,
56
+ paintedWidth: box.width,
57
+ top: box.y,
58
+ bottom: box.y + box.height,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Y where a milestone slack line leaves `item`: the row's nominal midline,
64
+ * or the progress strip's center when the caption spilled (so the line
65
+ * runs under the spilled title / meta text). Its x is the item's end date.
66
+ */
67
+ export function itemSlackAttachY(item: PositionedItem, bandwidth: number): number {
68
+ return item.textSpills ? progressStripMidY(item) : item.box.y + bandwidth / 2;
69
+ }