@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
@@ -1,19 +1,48 @@
1
- // ItemNode — first Renderable entity in the m2.5c port. Owns the
2
- // geometry of a single item bar: its visual box (insets + height) and
3
- // its caption x (inside vs. spilled past the bar).
1
+ // ItemNode — the Renderable for a single item bar: its visual box
2
+ // (insets + height) and its caption x (inside vs. spilled past the bar).
4
3
  //
5
- // Constructed from already-resolved inputs (start/end x, status,
6
- // remaining, meta text) so this node stays small and pure. The
7
- // dependency-resolution logic in layout.ts (after/before chains,
8
- // cursor.x, footnote indexing) remains where it is for now; m2.5c's
9
- // remaining work is to migrate the rest into sibling node files.
4
+ // Built from the item's measure (`measureItem`, `item-measure.ts`), which
5
+ // has already resolved the width, the caption fit and the bar's growth,
6
+ // so this node stays small and pure. The start resolution (`after:`
7
+ // chains, pins, the wave floor) and the `before:` caps stay in
8
+ // `sequenceItem`.
10
9
  //
11
- // `measure(ctx)` returns the bar's intrinsic size. `place(origin,
12
- // ctx)` returns a `PositionedItem`-shaped fragment with the box, row,
13
- // and `textX`. The shape is byte-stable with the legacy sequenceItem
14
- // arithmetic when fed the same inputs.
10
+ // `measure(ctx)` returns the bar's intrinsic size: the logical width and
11
+ // the band height plus the growth a wrapped title or a chip column adds.
12
+ // `place(origin, ctx)` returns a `PositionedItem`-shaped fragment with the
13
+ // box at that grown height, `textX`, and the title lines to paint.
14
+ //
15
+ // Caption fit decision (`fitItemCaption`), in order:
16
+ // 1. Title fits one line AND the meta line fits: in-bar, one line.
17
+ // 2. Otherwise, if the meta line fits and a greedy whitespace
18
+ // word-wrap of the title gives at most
19
+ // `ITEM_CAPTION_TITLE_MAX_LINES` lines (>= 2), each within the
20
+ // inner width: in-bar, wrapped.
21
+ // 3. Otherwise: spill to the right of the bar as a single line.
22
+ //
23
+ // The caption's left edge (`itemCaptionInsetX`) clears the link tile and
24
+ // the `after:` inline-date glyph in the bar's upper-left. The inset
25
+ // applies to EVERY caption line, so the left edge stays straight.
26
+ //
27
+ // The FIRST title line has a narrower budget than the rest: it shares
28
+ // the bar's upper-right with the status dot, footnote digits and the
29
+ // `before:` glyph, so it stops short of that cluster
30
+ // (`itemTitleFirstLineRightReservePx`). Line 2+ and the meta line sit
31
+ // below the cluster and keep the full inner width.
32
+ //
33
+ // Explicit breaks (lolay/nowline#60): a newline in the title is a hard
34
+ // line break and the author's lines are authoritative. No auto-wrap
35
+ // runs on them and the two-line cap does not apply. The caption stays
36
+ // in-bar when line 1 fits the first-line width, every other line fits
37
+ // the inner width and the meta line fits; otherwise it spills right as
38
+ // a multi-line block with its breaks preserved.
15
39
 
16
- import { ITEM_LINK_ICON_INSET_PX, ITEM_LINK_ICON_TILE_SIZE_PX } from '../item-bar-geometry.js';
40
+ import {
41
+ ITEM_CAPTION_TITLE_MAX_LINES,
42
+ ITEM_DECORATION_SPILL_GAP_PX,
43
+ itemCaptionInsetX,
44
+ itemTitleFirstLineRightReservePx,
45
+ } from '../item-bar-geometry.js';
17
46
  import type {
18
47
  IntrinsicSize,
19
48
  MeasureContext,
@@ -21,35 +50,34 @@ import type {
21
50
  Point,
22
51
  Renderable,
23
52
  } from '../renderable.js';
24
- import { ITEM_INSET_PX, MIN_ITEM_WIDTH } from '../themes/shared.js';
25
- import type { BoundingBox } from '../types.js';
53
+ import { estimateTextWidth, wrapText } from '../text-measure.js';
54
+ import { ITEM_SEAM_PX, MIN_ITEM_WIDTH } from '../themes/shared.js';
55
+ import type { BoundingBox, PositionedItem } from '../types.js';
26
56
 
27
57
  /**
28
- * Inner padding applied on each side of the title text — the bar's
29
- * inner-padded text area is `box.width - 2 * TEXT_INSET_PX` wide.
30
- * Text spills past the bar when either the title or the meta line
58
+ * Inner padding applied on the RIGHT of the title text (the left inset
59
+ * comes from `itemCaptionInsetX`, which adds the link-icon column and
60
+ * the `after:` glyph column when present) — the bar's inner-padded text area is
61
+ * `box.width - itemCaptionInsetX(..) - TEXT_INSET_PX` wide. Text wraps
62
+ * or spills past the bar when either the title or the meta line
31
63
  * exceeds that area.
32
64
  */
33
65
  const TEXT_INSET_PX = 12;
34
66
 
35
67
  /**
36
68
  * Gap (px) between the bar's right edge and overflow text. Smaller
37
- * than `TEXT_INSET_PX` so the text reads as belonging to this bar —
38
- * adjacent bars are at least `2 * ITEM_INSET_PX = 12` away, so the
39
- * text still has a clear visual home.
69
+ * than `TEXT_INSET_PX` so the text reads as belonging to this bar.
40
70
  */
41
71
  const TEXT_OUTSIDE_GAP_PX = 4;
42
72
 
43
73
  const TITLE_FONT_SIZE_PX = 13;
44
74
  const META_FONT_SIZE_PX = 11;
45
75
 
46
- export interface ItemNodeInput {
47
- id: string;
76
+ /** What the caption fit needs to know about a bar. */
77
+ export interface ItemCaptionInput {
48
78
  title: string;
49
- /** Logical left x of the column the bar lives in. */
50
- logicalLeftX: number;
51
- /** Logical right x of the column the bar lives in. */
52
- logicalRightX: number;
79
+ /** Width of the visible bar box (the logical width less the insets). */
80
+ visualWidth: number;
53
81
  /** Caption text shown under the title (e.g. "1w - 50% remaining"). */
54
82
  metaText?: string;
55
83
  /**
@@ -70,6 +98,46 @@ export interface ItemNodeInput {
70
98
  * collide with the icon.
71
99
  */
72
100
  hasLinkIcon?: boolean;
101
+ /**
102
+ * True when the item carries an inline `after:DATE`, which paints a
103
+ * calendar glyph in the bar's upper-left (right of the link tile
104
+ * when there is one). Caption text indents past the glyph so the
105
+ * title doesn't run under it. The glyph spills out of a bar narrower
106
+ * than `MIN_BAR_WIDTH_FOR_INLINE_DATE_PX`; the fit checks the bar
107
+ * width and reserves nothing in that case.
108
+ */
109
+ hasAfterGlyph?: boolean;
110
+ /**
111
+ * Space (px) the title's FIRST line leaves free at the bar's right
112
+ * edge, so it clears the status dot, footnote digits and `before:`
113
+ * glyph in the upper-right. Layout computes it with
114
+ * `itemTitleFirstLineRightReservePx` once it knows the item's
115
+ * footnotes and `before:` date. Defaults to the status-dot-only
116
+ * reserve for the bar width, because the dot is always present.
117
+ */
118
+ titleFirstLineRightReservePx?: number;
119
+ }
120
+
121
+ /** The caption's left indent inside the bar and its fit decision. */
122
+ export interface ItemCaptionMeasure {
123
+ /** Indent (px) from the bar's left edge to an in-bar caption. */
124
+ leftInset: number;
125
+ fit: ItemCaptionFit;
126
+ }
127
+
128
+ /**
129
+ * The part of an item's measure (`ItemMeasure`, `item-measure.ts`) that
130
+ * `ItemNode` needs to report its size and place its box.
131
+ */
132
+ export interface ItemNodeGeometry {
133
+ id: string;
134
+ /** Logical width: the item's span in time. */
135
+ naturalWidth: number;
136
+ /** Width of the visible bar box. */
137
+ visualWidth: number;
138
+ caption: ItemCaptionMeasure;
139
+ /** Growth over the band height (a wrapped title, a chip column); 0 for none. */
140
+ barExtra: number;
73
141
  }
74
142
 
75
143
  export interface PlacedItemGeometry {
@@ -84,88 +152,298 @@ export interface PlacedItemGeometry {
84
152
  textX: number;
85
153
  /** True when text spills past the bar's right edge. */
86
154
  textSpills: boolean;
155
+ /**
156
+ * The title as it should be painted, one entry per line. Always has
157
+ * at least one entry. Exactly two entries (never more than
158
+ * `ITEM_CAPTION_TITLE_MAX_LINES`) when the title auto-wrapped inside
159
+ * the bar; the author's own lines when the title has explicit breaks
160
+ * (any count, in-bar or spilled); otherwise `[title]`, including
161
+ * when a break-free title spills to the right.
162
+ */
163
+ titleLines: string[];
164
+ }
165
+
166
+ /**
167
+ * Estimated width (px) of an item's whole meta line: `metaText` plus the
168
+ * trailing capacity suffix (`trailingWidth`, 0 when there is none). Zero
169
+ * when the item has no meta line. `measureItemCaption` feeds it to
170
+ * `fitItemCaption`.
171
+ */
172
+ export function estimateItemMetaWidth(metaText: string | undefined, trailingWidth: number): number {
173
+ const metaTextWidth = metaText ? estimateTextWidth(metaText, META_FONT_SIZE_PX) : 0;
174
+ return metaTextWidth + trailingWidth;
175
+ }
176
+
177
+ /**
178
+ * The seam a bar gives up on each side, from inside its dates: `ITEM_SEAM_PX`,
179
+ * shrunk for a bar too short to keep both seams and the `MIN_ITEM_WIDTH`
180
+ * floor, so the floor stays centred in its dates.
181
+ */
182
+ export function itemBarInsetPx(logicalWidth: number): number {
183
+ return Math.max(0, Math.min(ITEM_SEAM_PX, (logicalWidth - MIN_ITEM_WIDTH) / 2));
184
+ }
185
+
186
+ /**
187
+ * The visible bar width for a logical width: the span less a seam on each
188
+ * side, never under `MIN_ITEM_WIDTH` (specs/true-width-layout.md §3.1).
189
+ */
190
+ export function itemVisualWidth(logicalWidth: number): number {
191
+ return Math.max(MIN_ITEM_WIDTH, logicalWidth - 2 * itemBarInsetPx(logicalWidth));
192
+ }
193
+
194
+ export interface ItemCaptionFit {
195
+ /** True when the caption must render to the right of the bar. */
196
+ textSpills: boolean;
197
+ /** Title lines to paint; see `PlacedItemGeometry.titleLines`. */
198
+ titleLines: string[];
199
+ }
200
+
201
+ /**
202
+ * The lines of a title that contains an explicit line break, or
203
+ * `undefined` for a title with none (the common case, left untouched).
204
+ *
205
+ * `\r\n` and a lone `\r` count as `\n`, each line is trimmed, and
206
+ * leading and trailing empty lines are dropped. Interior empty lines
207
+ * stay as blank lines. A title that is only breaks and whitespace gives
208
+ * `[]`; one that has a single line left after trimming (`"Plan\n"`)
209
+ * gives one line, and callers then treat it as an ordinary single-line
210
+ * title: a break at either end cannot separate anything.
211
+ *
212
+ * Langium's string converter has already turned the source escape `\n`
213
+ * into a real newline and `\\n` into the two characters `\` `n`, so
214
+ * only the former reaches here as a break.
215
+ */
216
+ export function titleBreakLines(title: string): string[] | undefined {
217
+ if (!/[\r\n]/.test(title)) return undefined;
218
+ const lines = title
219
+ .replace(/\r\n?/g, '\n')
220
+ .split('\n')
221
+ .map((line) => line.trim());
222
+ let start = 0;
223
+ let end = lines.length;
224
+ while (start < end && lines[start] === '') start += 1;
225
+ while (end > start && lines[end - 1] === '') end -= 1;
226
+ return lines.slice(start, end);
227
+ }
228
+
229
+ /**
230
+ * Decide how an item's caption sits relative to its bar: one line
231
+ * in-bar, word-wrapped in-bar, or spilled to the right. This is the
232
+ * single source of truth for the decision; `measureItemCaption` calls it,
233
+ * and the row track and `sequenceItem` share that one measure, so the
234
+ * reserved row and the placed bar agree.
235
+ *
236
+ * `metaWidth` is the full estimated width of the meta line (text plus
237
+ * any trailing capacity suffix), or 0 when the item has no meta line.
238
+ * A meta line is never wrapped, so one that is wider than `innerWidth`
239
+ * forces a spill even when the title alone would wrap.
240
+ *
241
+ * `firstLineWidth` (default `innerWidth`) is the budget for the title's
242
+ * first line alone, narrower than `innerWidth` by the top-right
243
+ * decoration cluster. It governs both the single-line fit check and
244
+ * line 1 of a wrapped title; later lines and the meta line use
245
+ * `innerWidth`.
246
+ *
247
+ * `firstLineHardWidth` (default `firstLineWidth`) is the widest line 1 may
248
+ * be: it also uses the top-right decoration's `ITEM_DECORATION_SPILL_GAP_PX`
249
+ * clearance, up to that cluster's own left edge. It is a fallback, tried
250
+ * only for a wrap or explicit break that would otherwise spill, so a caption
251
+ * that fits with the gap never moves; it lets a first word that misses
252
+ * `firstLineWidth` by a hair wrap instead of spilling.
253
+ *
254
+ * Words never break mid-word: a single word wider than its line's
255
+ * budget, or a title that needs more than `ITEM_CAPTION_TITLE_MAX_LINES`
256
+ * lines, spills.
257
+ *
258
+ * A title with an explicit line break (see `titleBreakLines`) skips all
259
+ * of the above: its lines are authoritative and are returned as
260
+ * `titleLines` whether the caption stays in-bar or spills.
261
+ */
262
+ export function fitItemCaption(
263
+ rawTitle: string,
264
+ innerWidth: number,
265
+ metaWidth: number,
266
+ firstLineWidth: number = innerWidth,
267
+ firstLineHardWidth: number = firstLineWidth,
268
+ ): ItemCaptionFit {
269
+ let title = rawTitle;
270
+ const broken = titleBreakLines(rawTitle);
271
+ if (broken) {
272
+ if (broken.length >= 2) {
273
+ // Explicit breaks: the author's lines are authoritative, so no
274
+ // auto-wrap and no line cap. In-bar only when line 1 fits the
275
+ // first-line width, every other line fits the inner width and
276
+ // the meta line fits; otherwise the whole block spills right.
277
+ const fits =
278
+ metaWidth <= innerWidth &&
279
+ broken.every(
280
+ (line, n) =>
281
+ estimateTextWidth(line, TITLE_FONT_SIZE_PX) <=
282
+ (n === 0 ? firstLineHardWidth : innerWidth),
283
+ );
284
+ return { textSpills: !fits, titleLines: broken };
285
+ }
286
+ // Zero or one line left after trimming: an ordinary title, now
287
+ // free of its stray break.
288
+ title = broken[0] ?? '';
289
+ }
290
+ const titleOverflows =
291
+ title.length > 0 && estimateTextWidth(title, TITLE_FONT_SIZE_PX) > firstLineWidth;
292
+ const metaOverflows = metaWidth > innerWidth;
293
+ if (!titleOverflows && !metaOverflows) {
294
+ return { textSpills: false, titleLines: [title] };
295
+ }
296
+ if (titleOverflows && !metaOverflows) {
297
+ // Line 1 first keeps the dot's clearance; only when that spills does
298
+ // it try the hard limit.
299
+ for (const line1Width of [firstLineWidth, firstLineHardWidth]) {
300
+ const lines = wrapText(title, innerWidth, TITLE_FONT_SIZE_PX, line1Width);
301
+ // `lines.length >= 2` keeps single-line behavior byte-identical: a
302
+ // title that only "overflows" because of repeated whitespace in
303
+ // the raw string (the estimate counts every space) normalizes to
304
+ // one line, and that case still spills exactly as it always has.
305
+ if (
306
+ lines.length >= 2 &&
307
+ lines.length <= ITEM_CAPTION_TITLE_MAX_LINES &&
308
+ lines.every(
309
+ (line, n) =>
310
+ estimateTextWidth(line, TITLE_FONT_SIZE_PX) <=
311
+ (n === 0 ? line1Width : innerWidth),
312
+ )
313
+ ) {
314
+ return { textSpills: false, titleLines: lines };
315
+ }
316
+ }
317
+ }
318
+ return { textSpills: true, titleLines: [title] };
87
319
  }
88
320
 
89
321
  /**
90
- * Approx. rendered width of `text` at `fontSizePx`. Matches the legacy
91
- * `sequenceItem` heuristic (intentionally pessimistic at ~0.58 em/char so
92
- * borderline-fitting captions trigger spill rather than clip).
322
+ * The title lines an item finally paints. `fit` is the caption-fit
323
+ * decision; `forcedSpill` is true when something outside it pushes the
324
+ * caption out of the bar anyway (a narrow-bar link icon, `iconSpills`).
325
+ * `measureItem` calls it; the line count sets the bar's growth.
326
+ *
327
+ * - Explicit breaks keep the author's lines, in-bar or spilled.
328
+ * - An auto-wrap (two lines) survives only while the caption stays
329
+ * in-bar; a spilled break-free title is one line.
330
+ * - Otherwise a single line: the title, minus any stray break at
331
+ * either end.
93
332
  */
94
- function estimateTextWidth(text: string, fontSizePx: number): number {
95
- return text.length * fontSizePx * 0.58;
333
+ export function resolveCaptionTitleLines(
334
+ title: string,
335
+ fit: ItemCaptionFit,
336
+ forcedSpill: boolean,
337
+ ): string[] {
338
+ const broken = titleBreakLines(title);
339
+ if (broken && broken.length >= 2) return broken;
340
+ if (!fit.textSpills && !forcedSpill && fit.titleLines.length >= 2) return fit.titleLines;
341
+ return [broken ? (broken[0] ?? '') : title];
96
342
  }
97
343
 
344
+ /**
345
+ * Fit an item's caption to its bar: the left indent past the link tile
346
+ * and the `after:` glyph, the inner and first-line widths, and the
347
+ * `fitItemCaption` decision on them.
348
+ */
349
+ export function measureItemCaption(input: ItemCaptionInput): ItemCaptionMeasure {
350
+ const { visualWidth } = input;
351
+ // The link icon and the `after:` glyph (when present) live in the
352
+ // bar's upper-left and share the title's vertical band. The
353
+ // caption indents past them so the title doesn't render on top.
354
+ const leftInset = itemCaptionInsetX(
355
+ !!input.hasLinkIcon,
356
+ input.hasAfterGlyph ? { barWidth: visualWidth } : undefined,
357
+ );
358
+ const innerWidth = Math.max(0, visualWidth - leftInset - TEXT_INSET_PX);
359
+ // Trailing decoration (capacity suffix) renders to the right of
360
+ // metaText. When metaText is empty the suffix sits at the
361
+ // caption's leading edge, so its width is the entire meta-line
362
+ // budget; when both exist the suffix needs a small separator gap
363
+ // (rendered via `<tspan dx>` later) included in trailingWidth.
364
+ const metaWidth = estimateItemMetaWidth(input.metaText, input.metaTrailingWidth ?? 0);
365
+ const firstLineReserve =
366
+ input.titleFirstLineRightReservePx ??
367
+ itemTitleFirstLineRightReservePx({
368
+ barWidth: visualWidth,
369
+ footnoteLabels: [],
370
+ hasBeforeGlyph: false,
371
+ });
372
+ const firstLineWidth = Math.max(0, visualWidth - leftInset - firstLineReserve);
373
+ // The same line without the cluster's clearance gap: its left edge is the limit.
374
+ const firstLineHardWidth = Math.max(
375
+ firstLineWidth,
376
+ visualWidth -
377
+ leftInset -
378
+ Math.max(TEXT_INSET_PX, firstLineReserve - ITEM_DECORATION_SPILL_GAP_PX),
379
+ );
380
+ return {
381
+ leftInset,
382
+ fit: fitItemCaption(input.title, innerWidth, metaWidth, firstLineWidth, firstLineHardWidth),
383
+ };
384
+ }
385
+
386
+ /**
387
+ * Optional observer of every `ItemNode` that `sequenceItem` places, with
388
+ * the context it was placed in and the item it became. Tests set it to
389
+ * check a node's measure against the item's final box; layout never does.
390
+ */
391
+ export type ItemNodeTrace = (node: ItemNode, ctx: PlaceContext, item: PositionedItem) => void;
392
+
393
+ let itemNodeTrace: ItemNodeTrace | undefined;
394
+
395
+ /** Install (or, with `undefined`, remove) the `ItemNodeTrace`. */
396
+ export function setItemNodeTrace(trace: ItemNodeTrace | undefined): void {
397
+ itemNodeTrace = trace;
398
+ }
399
+
400
+ /** Report a placed node to the `ItemNodeTrace`, when one is installed. */
401
+ export function traceItemNode(node: ItemNode, ctx: PlaceContext, item: PositionedItem): void {
402
+ itemNodeTrace?.(node, ctx, item);
403
+ }
404
+
405
+ /**
406
+ * One item bar, built from its measure. `measure` reports the logical
407
+ * width and the grown height (`bandwidth + barExtra`), and `place`
408
+ * returns the box at that height, so no caller patches it afterwards.
409
+ */
98
410
  export class ItemNode implements Renderable<PlacedItemGeometry> {
99
- constructor(public readonly input: ItemNodeInput) {}
411
+ constructor(public readonly geometry: ItemNodeGeometry) {}
100
412
 
101
413
  get id(): string {
102
- return this.input.id;
414
+ return this.geometry.id;
103
415
  }
104
416
 
105
417
  measure(ctx: MeasureContext): IntrinsicSize {
106
- const naturalWidth = Math.max(
107
- MIN_ITEM_WIDTH,
108
- this.input.logicalRightX - this.input.logicalLeftX,
109
- );
110
418
  return {
111
- width: naturalWidth,
112
- height: ctx.bands.bandwidth(),
419
+ width: this.geometry.naturalWidth,
420
+ height: ctx.bands.bandwidth() + this.geometry.barExtra,
113
421
  };
114
422
  }
115
423
 
116
424
  place(origin: Point, ctx: PlaceContext): PlacedItemGeometry {
117
- const intrinsic = this.measure(ctx);
118
- const visualWidth = Math.max(MIN_ITEM_WIDTH, intrinsic.width - 2 * ITEM_INSET_PX);
119
- const boxX = origin.x + ITEM_INSET_PX;
425
+ const { naturalWidth, visualWidth, caption, barExtra } = this.geometry;
426
+ const bandwidth = ctx.bands.bandwidth();
427
+ const boxX = origin.x + itemBarInsetPx(naturalWidth);
120
428
  const box: BoundingBox = {
121
429
  x: boxX,
122
430
  y: origin.y,
123
431
  width: visualWidth,
124
- height: intrinsic.height,
432
+ // The bar grows downward to enclose a wrapped title or a chip
433
+ // column; an ungrown bar is exactly the band height.
434
+ height: barExtra > 0 ? bandwidth + barExtra : bandwidth,
125
435
  };
126
-
127
- // The link icon (when present) lives in the bar's upper-left
128
- // and shares the title's vertical band. The caption indents
129
- // past the icon so the title doesn't render on top of it.
130
- const linkColumn = this.input.hasLinkIcon
131
- ? ITEM_LINK_ICON_INSET_PX +
132
- ITEM_LINK_ICON_TILE_SIZE_PX +
133
- LINK_ICON_TO_CAPTION_GAP_PX -
134
- TEXT_INSET_PX
135
- : 0;
136
- const captionLeftInset = TEXT_INSET_PX + Math.max(0, linkColumn);
137
- const innerWidth = Math.max(0, visualWidth - captionLeftInset - TEXT_INSET_PX);
138
- const titleStr = this.input.title;
139
- const titleWidth = titleStr ? estimateTextWidth(titleStr, TITLE_FONT_SIZE_PX) : 0;
140
- const metaTextWidth = this.input.metaText
141
- ? estimateTextWidth(this.input.metaText, META_FONT_SIZE_PX)
142
- : 0;
143
- const trailingWidth = this.input.metaTrailingWidth ?? 0;
144
- // Trailing decoration (capacity suffix) renders to the right of
145
- // metaText. When metaText is empty the suffix sits at the
146
- // caption's leading edge, so its width is the entire meta-line
147
- // budget; when both exist the suffix needs a small separator gap
148
- // (rendered via `<tspan dx>` later) included in trailingWidth.
149
- const metaWidth = metaTextWidth + trailingWidth;
150
- const hasMetaContent = this.input.metaText !== undefined || trailingWidth > 0;
151
- const textSpills =
152
- (titleStr.length > 0 && titleWidth > innerWidth) ||
153
- (hasMetaContent && metaWidth > innerWidth);
436
+ const { textSpills, titleLines } = caption.fit;
154
437
  const textX = textSpills
155
438
  ? boxX + visualWidth + TEXT_OUTSIDE_GAP_PX
156
- : boxX + captionLeftInset;
439
+ : boxX + caption.leftInset;
157
440
 
158
441
  return {
159
- id: this.input.id,
442
+ id: this.geometry.id,
160
443
  box,
161
444
  textX,
162
445
  textSpills,
446
+ titleLines,
163
447
  };
164
448
  }
165
449
  }
166
-
167
- /**
168
- * Horizontal gap (px) between the link-icon tile's right edge and the
169
- * start of the caption text when both render inside the bar.
170
- */
171
- const LINK_ICON_TO_CAPTION_GAP_PX = 4;