@nowline/layout 0.2.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 (183) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +103 -0
  3. package/dist/band-scale.d.ts +56 -0
  4. package/dist/band-scale.d.ts.map +1 -0
  5. package/dist/band-scale.js +86 -0
  6. package/dist/band-scale.js.map +1 -0
  7. package/dist/calendar.d.ts +79 -0
  8. package/dist/calendar.d.ts.map +1 -0
  9. package/dist/calendar.js +210 -0
  10. package/dist/calendar.js.map +1 -0
  11. package/dist/capacity.d.ts +72 -0
  12. package/dist/capacity.d.ts.map +1 -0
  13. package/dist/capacity.js +163 -0
  14. package/dist/capacity.js.map +1 -0
  15. package/dist/dsl-utils.d.ts +5 -0
  16. package/dist/dsl-utils.d.ts.map +1 -0
  17. package/dist/dsl-utils.js +28 -0
  18. package/dist/dsl-utils.js.map +1 -0
  19. package/dist/edge-routing.d.ts +89 -0
  20. package/dist/edge-routing.d.ts.map +1 -0
  21. package/dist/edge-routing.js +435 -0
  22. package/dist/edge-routing.js.map +1 -0
  23. package/dist/frame-tab-geometry.d.ts +78 -0
  24. package/dist/frame-tab-geometry.d.ts.map +1 -0
  25. package/dist/frame-tab-geometry.js +115 -0
  26. package/dist/frame-tab-geometry.js.map +1 -0
  27. package/dist/header-card-geometry.d.ts +29 -0
  28. package/dist/header-card-geometry.d.ts.map +1 -0
  29. package/dist/header-card-geometry.js +41 -0
  30. package/dist/header-card-geometry.js.map +1 -0
  31. package/dist/i18n.d.ts +48 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +114 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/include-chrome-geometry.d.ts +86 -0
  36. package/dist/include-chrome-geometry.d.ts.map +1 -0
  37. package/dist/include-chrome-geometry.js +104 -0
  38. package/dist/include-chrome-geometry.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +10 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/item-bar-geometry.d.ts +127 -0
  44. package/dist/item-bar-geometry.d.ts.map +1 -0
  45. package/dist/item-bar-geometry.js +173 -0
  46. package/dist/item-bar-geometry.js.map +1 -0
  47. package/dist/lane-utilization.d.ts +90 -0
  48. package/dist/lane-utilization.d.ts.map +1 -0
  49. package/dist/lane-utilization.js +206 -0
  50. package/dist/lane-utilization.js.map +1 -0
  51. package/dist/layout-context.d.ts +143 -0
  52. package/dist/layout-context.d.ts.map +1 -0
  53. package/dist/layout-context.js +8 -0
  54. package/dist/layout-context.js.map +1 -0
  55. package/dist/layout.d.ts +18 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +1213 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/nodes/anchor-node.d.ts +16 -0
  60. package/dist/nodes/anchor-node.d.ts.map +1 -0
  61. package/dist/nodes/anchor-node.js +68 -0
  62. package/dist/nodes/anchor-node.js.map +1 -0
  63. package/dist/nodes/footnote-node.d.ts +10 -0
  64. package/dist/nodes/footnote-node.d.ts.map +1 -0
  65. package/dist/nodes/footnote-node.js +41 -0
  66. package/dist/nodes/footnote-node.js.map +1 -0
  67. package/dist/nodes/group-node.d.ts +29 -0
  68. package/dist/nodes/group-node.d.ts.map +1 -0
  69. package/dist/nodes/group-node.js +187 -0
  70. package/dist/nodes/group-node.js.map +1 -0
  71. package/dist/nodes/include-node.d.ts +16 -0
  72. package/dist/nodes/include-node.d.ts.map +1 -0
  73. package/dist/nodes/include-node.js +117 -0
  74. package/dist/nodes/include-node.js.map +1 -0
  75. package/dist/nodes/item-node.d.ts +51 -0
  76. package/dist/nodes/item-node.d.ts.map +1 -0
  77. package/dist/nodes/item-node.js +108 -0
  78. package/dist/nodes/item-node.js.map +1 -0
  79. package/dist/nodes/marker-geometry.d.ts +22 -0
  80. package/dist/nodes/marker-geometry.d.ts.map +1 -0
  81. package/dist/nodes/marker-geometry.js +38 -0
  82. package/dist/nodes/marker-geometry.js.map +1 -0
  83. package/dist/nodes/milestone-node.d.ts +48 -0
  84. package/dist/nodes/milestone-node.d.ts.map +1 -0
  85. package/dist/nodes/milestone-node.js +210 -0
  86. package/dist/nodes/milestone-node.js.map +1 -0
  87. package/dist/nodes/parallel-node.d.ts +21 -0
  88. package/dist/nodes/parallel-node.d.ts.map +1 -0
  89. package/dist/nodes/parallel-node.js +80 -0
  90. package/dist/nodes/parallel-node.js.map +1 -0
  91. package/dist/nodes/roadmap-node.d.ts +76 -0
  92. package/dist/nodes/roadmap-node.d.ts.map +1 -0
  93. package/dist/nodes/roadmap-node.js +788 -0
  94. package/dist/nodes/roadmap-node.js.map +1 -0
  95. package/dist/nodes/swimlane-node.d.ts +38 -0
  96. package/dist/nodes/swimlane-node.d.ts.map +1 -0
  97. package/dist/nodes/swimlane-node.js +308 -0
  98. package/dist/nodes/swimlane-node.js.map +1 -0
  99. package/dist/renderable.d.ts +44 -0
  100. package/dist/renderable.d.ts.map +1 -0
  101. package/dist/renderable.js +21 -0
  102. package/dist/renderable.js.map +1 -0
  103. package/dist/row-packer.d.ts +125 -0
  104. package/dist/row-packer.d.ts.map +1 -0
  105. package/dist/row-packer.js +169 -0
  106. package/dist/row-packer.js.map +1 -0
  107. package/dist/style-resolution.d.ts +14 -0
  108. package/dist/style-resolution.d.ts.map +1 -0
  109. package/dist/style-resolution.js +191 -0
  110. package/dist/style-resolution.js.map +1 -0
  111. package/dist/themes/dark.d.ts +4 -0
  112. package/dist/themes/dark.d.ts.map +1 -0
  113. package/dist/themes/dark.js +241 -0
  114. package/dist/themes/dark.js.map +1 -0
  115. package/dist/themes/index.d.ts +15 -0
  116. package/dist/themes/index.d.ts.map +1 -0
  117. package/dist/themes/index.js +30 -0
  118. package/dist/themes/index.js.map +1 -0
  119. package/dist/themes/light.d.ts +4 -0
  120. package/dist/themes/light.d.ts.map +1 -0
  121. package/dist/themes/light.js +248 -0
  122. package/dist/themes/light.js.map +1 -0
  123. package/dist/themes/shape.d.ts +194 -0
  124. package/dist/themes/shape.d.ts.map +1 -0
  125. package/dist/themes/shape.js +6 -0
  126. package/dist/themes/shape.js.map +1 -0
  127. package/dist/themes/shared.d.ts +145 -0
  128. package/dist/themes/shared.d.ts.map +1 -0
  129. package/dist/themes/shared.js +310 -0
  130. package/dist/themes/shared.js.map +1 -0
  131. package/dist/time-scale.d.ts +39 -0
  132. package/dist/time-scale.d.ts.map +1 -0
  133. package/dist/time-scale.js +62 -0
  134. package/dist/time-scale.js.map +1 -0
  135. package/dist/types.d.ts +483 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +6 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/view-preset.d.ts +23 -0
  140. package/dist/view-preset.d.ts.map +1 -0
  141. package/dist/view-preset.js +146 -0
  142. package/dist/view-preset.js.map +1 -0
  143. package/dist/working-calendar.d.ts +14 -0
  144. package/dist/working-calendar.d.ts.map +1 -0
  145. package/dist/working-calendar.js +74 -0
  146. package/dist/working-calendar.js.map +1 -0
  147. package/package.json +37 -0
  148. package/src/band-scale.ts +115 -0
  149. package/src/calendar.ts +244 -0
  150. package/src/capacity.ts +191 -0
  151. package/src/dsl-utils.ts +30 -0
  152. package/src/edge-routing.ts +550 -0
  153. package/src/frame-tab-geometry.ts +165 -0
  154. package/src/header-card-geometry.ts +48 -0
  155. package/src/i18n.ts +124 -0
  156. package/src/include-chrome-geometry.ts +156 -0
  157. package/src/index.ts +116 -0
  158. package/src/item-bar-geometry.ts +222 -0
  159. package/src/lane-utilization.ts +259 -0
  160. package/src/layout-context.ts +182 -0
  161. package/src/layout.ts +1446 -0
  162. package/src/nodes/anchor-node.ts +77 -0
  163. package/src/nodes/footnote-node.ts +60 -0
  164. package/src/nodes/group-node.ts +252 -0
  165. package/src/nodes/include-node.ts +168 -0
  166. package/src/nodes/item-node.ts +171 -0
  167. package/src/nodes/marker-geometry.ts +43 -0
  168. package/src/nodes/milestone-node.ts +263 -0
  169. package/src/nodes/parallel-node.ts +101 -0
  170. package/src/nodes/roadmap-node.ts +957 -0
  171. package/src/nodes/swimlane-node.ts +423 -0
  172. package/src/renderable.ts +68 -0
  173. package/src/row-packer.ts +271 -0
  174. package/src/style-resolution.ts +243 -0
  175. package/src/themes/dark.ts +244 -0
  176. package/src/themes/index.ts +36 -0
  177. package/src/themes/light.ts +251 -0
  178. package/src/themes/shape.ts +230 -0
  179. package/src/themes/shared.ts +369 -0
  180. package/src/time-scale.ts +78 -0
  181. package/src/types.ts +607 -0
  182. package/src/view-preset.ts +180 -0
  183. package/src/working-calendar.ts +91 -0
@@ -0,0 +1,423 @@
1
+ // SwimlaneNode — second Renderable entity in the m2.5c port. Owns the
2
+ // per-lane row-packing decisions (title-tab top-pad collapse, sibling row
3
+ // bumps, previous-title-spill carry-forward, parallels/groups owning
4
+ // fresh rows) and emits a `PositionedSwimlane` plus the band's used
5
+ // height.
6
+ //
7
+ // Step-2 transitional shape:
8
+ // - Item children flow through the injected `deps.sequenceItem`. Once
9
+ // ItemNode's wire-in is complete (step 1 — done) the production
10
+ // `sequenceItem` already routes through ItemNode internally; deferring
11
+ // the recursive Renderable call here keeps the diff small.
12
+ // - Parallel + group children flow through `deps.sequenceOne` until
13
+ // their own nodes land in step 3.
14
+ // - The Renderable interface's `measure(MeasureContext)` is not yet
15
+ // wired through the production pipeline. The composition-root work
16
+ // in step 5 (`RoadmapNode`) will exercise it; meanwhile the legacy
17
+ // two-pass "buildSwimlane returns positioned + usedHeight" shape is
18
+ // preserved.
19
+
20
+ import type {
21
+ EntityProperty,
22
+ GroupBlock,
23
+ ItemDeclaration,
24
+ ParallelBlock,
25
+ SwimlaneDeclaration,
26
+ } from '@nowline/core';
27
+ import { isItemDeclaration } from '@nowline/core';
28
+ import { deriveItemDurationDays } from '../calendar.js';
29
+ import {
30
+ estimateCapacitySuffixWidth,
31
+ formatCapacityNumber,
32
+ parseCapacityValue,
33
+ resolveCapacityIcon,
34
+ } from '../capacity.js';
35
+ import { propValue } from '../dsl-utils.js';
36
+ import { frameTabGeometry } from '../frame-tab-geometry.js';
37
+ import { computeLaneUtilization, resolveLaneUtilizationThresholds } from '../lane-utilization.js';
38
+ import type { LayoutContext, TrackCursor } from '../layout-context.js';
39
+ import { RowPacker } from '../row-packer.js';
40
+ import { resolveStyle } from '../style-resolution.js';
41
+ import { ITEM_INSET_PX, MIN_ITEM_WIDTH } from '../themes/shared.js';
42
+ import type {
43
+ BoundingBox,
44
+ PositionedCapacity,
45
+ PositionedItem,
46
+ PositionedSwimlane,
47
+ PositionedTrackChild,
48
+ } from '../types.js';
49
+
50
+ /**
51
+ * Font size (px) the renderer uses for the lane capacity badge. Mirrors
52
+ * the owner-badge font size in `renderSwimlane` so the two reads as a
53
+ * single info row inside the chiclet. Width estimates here must use the
54
+ * same value or the geometry-collision math diverges from the painted
55
+ * footprint.
56
+ */
57
+ const LANE_CAPACITY_BADGE_FONT_SIZE_PX = 10;
58
+
59
+ /**
60
+ * Compute the lane's PositionedCapacity (or null when no `capacity:` is
61
+ * declared / value is non-positive) plus the px width the badge will
62
+ * occupy inside the frame tab. The width includes the bare badge —
63
+ * leading-separator handling lives in `frameTabGeometry`.
64
+ */
65
+ function resolveLaneCapacity(
66
+ lane: SwimlaneDeclaration,
67
+ style: { capacityIcon: string },
68
+ symbols: LayoutContext['symbols'],
69
+ ): { capacity: PositionedCapacity | null; badgeWidthPx: number } {
70
+ const raw = propValue(lane.properties, 'capacity');
71
+ const value = parseCapacityValue(raw);
72
+ if (value === null) return { capacity: null, badgeWidthPx: 0 };
73
+ const text = formatCapacityNumber(value);
74
+ const icon = resolveCapacityIcon(style.capacityIcon, symbols);
75
+ const badgeWidthPx = estimateCapacitySuffixWidth(text, icon, LANE_CAPACITY_BADGE_FONT_SIZE_PX);
76
+ return {
77
+ capacity: { value, text, icon },
78
+ badgeWidthPx,
79
+ };
80
+ }
81
+
82
+ /** Helpers that SwimlaneNode delegates to until the rest of m2.5c lands. */
83
+ export interface SwimlaneNodeDeps {
84
+ sequenceItem: (
85
+ child: ItemDeclaration,
86
+ cursor: TrackCursor,
87
+ ctx: LayoutContext,
88
+ ownerOverride?: string,
89
+ ) => PositionedItem;
90
+ sequenceOne: (
91
+ child: ItemDeclaration | GroupBlock | ParallelBlock,
92
+ cursor: TrackCursor,
93
+ ctx: LayoutContext,
94
+ ) => PositionedTrackChild;
95
+ resolveChildStart: (
96
+ props: EntityProperty[],
97
+ seqDefault: number,
98
+ laneLeftX: number,
99
+ ctx: LayoutContext,
100
+ ) => number;
101
+ newCursor: (x: number, y: number) => TrackCursor;
102
+ estimateTextWidth: (text: string, fontSize: number) => number;
103
+ /** Predict the extra vertical height an item's wrapped label-chip
104
+ * rows will add to its bar; used to size the row-packer's row
105
+ * pitch ahead of the call to `sequenceItem`. */
106
+ predictItemChipExtraHeight: (item: ItemDeclaration, ctx: LayoutContext) => number;
107
+ }
108
+
109
+ export interface SwimlaneNodeInput {
110
+ lane: SwimlaneDeclaration;
111
+ bandIndex: number;
112
+ }
113
+
114
+ export interface PlacedSwimlaneGeometry {
115
+ positioned: PositionedSwimlane;
116
+ usedHeight: number;
117
+ /** Rightmost x reached by any item or its spilled caption. */
118
+ usedRightX: number;
119
+ }
120
+
121
+ /** Anchor point passed to `SwimlaneNode.place`. */
122
+ export interface SwimlaneOrigin {
123
+ x: number;
124
+ y: number;
125
+ }
126
+
127
+ const TAB_TOP_Y = 10; // matches renderer: tabY = box.y + 10
128
+ const TAB_BOTTOM_Y = 38; // tab (height 22) plus 6 px breathing room
129
+ const TAB_GUTTER_PX = 8;
130
+
131
+ /**
132
+ * Right edge (canvas px) of the lane title tab. Delegates to the
133
+ * shared `frameTabGeometry` helper that the renderer also uses, so
134
+ * the chiclet's collision footprint and its painted footprint stay
135
+ * exactly in sync. Capacity badge and footnote-indicator widths are
136
+ * added when present so the first-item collision math reserves
137
+ * enough horizontal space for the (now-wider) chiclet.
138
+ */
139
+ function computeLaneTabRightX(
140
+ lane: SwimlaneDeclaration,
141
+ capacityBadgeWidthPx: number,
142
+ footnoteIndicatorWidthPx: number,
143
+ ): number {
144
+ const title = lane.title ?? lane.name ?? '';
145
+ if (!title) return 0;
146
+ const ownerRaw = propValue(lane.properties, 'owner');
147
+ // Box is laid out at `box.x = 0` for top-level lanes; the helper
148
+ // adds the standard `FRAME_TAB_OFFSET_FROM_BOX_PX` itself.
149
+ return frameTabGeometry(
150
+ 0,
151
+ title,
152
+ ownerRaw ?? undefined,
153
+ capacityBadgeWidthPx,
154
+ footnoteIndicatorWidthPx,
155
+ ).rightX;
156
+ }
157
+
158
+ /**
159
+ * Footnote indicators (1-based numbers) that name this lane via `on:`,
160
+ * sorted ascending. Empty when the lane has no name or no matching
161
+ * footnote hosts. Computed up-front so the chiclet width reservation
162
+ * and the painted footnote text use the same numbers.
163
+ */
164
+ function collectFootnoteIndicators(lane: SwimlaneDeclaration, ctx: LayoutContext): number[] {
165
+ if (!lane.name) return [];
166
+ const out: number[] = [];
167
+ for (const [fid, host] of ctx.footnoteHosts.entries()) {
168
+ if (host.includes(lane.name)) {
169
+ const n = ctx.footnoteIndex.get(fid);
170
+ if (n !== undefined) out.push(n);
171
+ }
172
+ }
173
+ out.sort((a, b) => a - b);
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Desired starting x for the first non-description child of a lane.
179
+ * Returns undefined when the lane has no chartable children. Used by
180
+ * the top-pad collapse decision (a row whose first item lives past the
181
+ * tab can sit at TAB_TOP_Y; otherwise rows drop below the tab).
182
+ */
183
+ function firstChildStartX(
184
+ lane: SwimlaneDeclaration,
185
+ laneLeftX: number,
186
+ ctx: LayoutContext,
187
+ deps: Pick<SwimlaneNodeDeps, 'resolveChildStart'>,
188
+ ): number | undefined {
189
+ for (const child of lane.content) {
190
+ if (child.$type === 'DescriptionDirective') continue;
191
+ const props = isItemDeclaration(child)
192
+ ? child.properties
193
+ : ((child as ParallelBlock | GroupBlock).properties ?? []);
194
+ return deps.resolveChildStart(props, laneLeftX, laneLeftX, ctx);
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ export class SwimlaneNode {
200
+ constructor(
201
+ public readonly input: SwimlaneNodeInput,
202
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via `const { deps } = this` destructuring inside methods, which the analyzer does not detect.
203
+ private readonly deps: SwimlaneNodeDeps,
204
+ ) {}
205
+
206
+ get id(): string {
207
+ return this.input.lane.name ?? '';
208
+ }
209
+
210
+ place(origin: SwimlaneOrigin, ctx: LayoutContext): PlacedSwimlaneGeometry {
211
+ const { lane, bandIndex } = this.input;
212
+ const { deps } = this;
213
+ const style = resolveStyle('swimlane', lane.properties, ctx.styleCtx);
214
+ const laneLeftX = origin.x;
215
+ const step = ctx.bandScale.step();
216
+
217
+ // Each swimlane root opens a fresh flow — its direct children
218
+ // chain in time, so a milestone's `after:` predecessors that
219
+ // share a swimlane collapse to the latest one in file order
220
+ // (file order encodes the dependency on its own). Restored at
221
+ // the bottom of `place()`.
222
+ const previousFlowKey = ctx.currentFlowKey;
223
+ ctx.currentFlowKey = `lane:${lane.name ?? bandIndex}`;
224
+
225
+ // Resolve lane capacity early — its width feeds into the
226
+ // chiclet's right-edge collision calculation, which determines
227
+ // whether the first row sits at TAB_TOP_Y or TAB_BOTTOM_Y.
228
+ const { capacity, badgeWidthPx } = resolveLaneCapacity(lane, style, ctx.symbols);
229
+ // Footnote indicators (the small "1, 2" red text in the upper
230
+ // right of the chiclet) need to be reserved in the chiclet's
231
+ // width too, otherwise they paint on top of the owner / badge
232
+ // for shrink-wrapped chiclets. Computed up front from the
233
+ // pre-resolved footnote index/host map.
234
+ const footnoteIndicators = collectFootnoteIndicators(lane, ctx);
235
+ const footnoteIndicatorWidthPx =
236
+ footnoteIndicators.length > 0
237
+ ? deps.estimateTextWidth(
238
+ footnoteIndicators.join(','),
239
+ LANE_CAPACITY_BADGE_FONT_SIZE_PX,
240
+ )
241
+ : 0;
242
+ // Title-tab geometry (mirrors the renderer; see renderSwimlane).
243
+ const tabRightX = computeLaneTabRightX(lane, badgeWidthPx, footnoteIndicatorWidthPx);
244
+ // First-row Y: when the first child's desired x is past the title
245
+ // tab, top-align with the tab and reclaim ~28 px per lane.
246
+ // Otherwise drop below the tab.
247
+ const firstChildDesiredX = firstChildStartX(lane, laneLeftX, ctx, deps);
248
+ const canAlignFirstRowWithTab =
249
+ !lane.title ||
250
+ firstChildDesiredX === undefined ||
251
+ firstChildDesiredX >= tabRightX + TAB_GUTTER_PX;
252
+ const startY = origin.y + (canAlignFirstRowWithTab ? TAB_TOP_Y : TAB_BOTTOM_Y);
253
+
254
+ // Topmost-fit row pack. See `RowPacker` for the full contract.
255
+ // The packer owns the rows; we feed it children in DSL order and
256
+ // it returns each child's resolved (rowIndex, y).
257
+ const packer = new RowPacker({
258
+ laneLeftX,
259
+ originY: startY,
260
+ minRowHeight: step,
261
+ slackCorridors: ctx.slackCorridors,
262
+ });
263
+ let timeCursorX = laneLeftX;
264
+
265
+ const children: PositionedTrackChild[] = [];
266
+ for (const child of lane.content) {
267
+ if (child.$type === 'DescriptionDirective') continue;
268
+
269
+ if (!isItemDeclaration(child)) {
270
+ const blockProps = (child as ParallelBlock | GroupBlock).properties ?? [];
271
+ const blockStart = deps.resolveChildStart(blockProps, timeCursorX, laneLeftX, ctx);
272
+ const { rowIndex, y: blockY } = packer.placeBlock();
273
+ const cursor = deps.newCursor(blockStart, blockY);
274
+ const positioned = deps.sequenceOne(
275
+ child as ItemDeclaration | GroupBlock | ParallelBlock,
276
+ cursor,
277
+ ctx,
278
+ );
279
+ children.push(positioned);
280
+ const blockEnd = positioned.box.x + positioned.box.width;
281
+ const blockHeight = Math.max(step, cursor.height);
282
+ packer.commitBlock({
283
+ rowIndex,
284
+ placed: positioned,
285
+ blockHeight,
286
+ blockEnd,
287
+ });
288
+ timeCursorX = Math.max(timeCursorX, blockEnd);
289
+ continue;
290
+ }
291
+
292
+ const props = (child as ItemDeclaration).properties;
293
+ const desiredStart = deps.resolveChildStart(props, timeCursorX, laneLeftX, ctx);
294
+ // Predict the item's logical extent so the row-pack can decide
295
+ // BEFORE handing off to sequenceItem. The arithmetic mirrors
296
+ // the duration → width math in `sequenceItem` (see
297
+ // packages/layout/src/layout.ts).
298
+ const durationDays = deriveItemDurationDays(props, ctx.sizes, ctx.cal);
299
+ const naturalWidth = Math.max(MIN_ITEM_WIDTH, durationDays * ctx.timeline.pixelsPerDay);
300
+ const desiredEnd = desiredStart + naturalWidth;
301
+ const childId = (child as ItemDeclaration).name ?? '';
302
+
303
+ const chipExtra = deps.predictItemChipExtraHeight(child as ItemDeclaration, ctx);
304
+ const predictedHeight = step + chipExtra;
305
+ const { rowIndex, y: rowY } = packer.placeItem({
306
+ childId,
307
+ desiredStart,
308
+ desiredEnd,
309
+ // Row pitch = `step()` + extra chip-row height. Keeps
310
+ // the inter-row visible gap (= step - bandwidth) intact
311
+ // when an item's labels wrap and grow the bar.
312
+ predictedHeight,
313
+ });
314
+
315
+ const cursor = deps.newCursor(desiredStart, rowY);
316
+ const positioned = deps.sequenceItem(child as ItemDeclaration, cursor, ctx);
317
+ children.push(positioned);
318
+
319
+ // Item end in LOGICAL space (one ITEM_INSET_PX past the visual
320
+ // bar's right edge). The next chained item starts here and
321
+ // lands edge-to-edge in time, with a 2 × ITEM_INSET_PX visible
322
+ // gutter between bars.
323
+ const itemLogicalEnd = positioned.box.x + positioned.box.width + ITEM_INSET_PX;
324
+ timeCursorX = Math.max(timeCursorX, itemLogicalEnd);
325
+
326
+ let spillReservation: number | null = null;
327
+ const hasAnySpill =
328
+ positioned.textSpills ||
329
+ positioned.chipsOutside ||
330
+ positioned.dotSpills ||
331
+ positioned.iconSpills ||
332
+ positioned.footnoteSpills;
333
+ if (hasAnySpill) {
334
+ // `decorationsRightX` already aggregates the spilled
335
+ // dot / icon / footnote / caption right edges from
336
+ // `sequenceItem`; the chip column tracks separately
337
+ // via `chipsRightX`. Add a 6-px buffer so the next
338
+ // chained item's bar leaves a visible gutter past
339
+ // the spilled cluster instead of butting against it.
340
+ const farRight = Math.max(
341
+ positioned.decorationsRightX,
342
+ positioned.chipsOutside ? positioned.chipsRightX : 0,
343
+ );
344
+ spillReservation = farRight + 6;
345
+ }
346
+
347
+ packer.commitItem({
348
+ rowIndex,
349
+ placed: positioned,
350
+ logicalEnd: itemLogicalEnd,
351
+ spillReservation,
352
+ rowHeight: predictedHeight,
353
+ });
354
+ }
355
+
356
+ // `packer.usedHeight()` measures from `startY` (the first row,
357
+ // below the title tab); the swimlane band spans from `origin.y`
358
+ // (the band top, above the tab). Add the tab offset back so the
359
+ // band height covers everything the user sees.
360
+ const tabOffset = startY - origin.y;
361
+ const bandHeight = Math.max(step + 32, tabOffset + packer.usedHeight() + 16);
362
+ // Include the chiclet's right edge in the lane's reported
363
+ // right-extent so empty lanes still report a non-zero right edge
364
+ // — `buildIncludeRegions` uses this to size the include's dashed
365
+ // bracket around its content, and a lane with only a chiclet
366
+ // (no items) should still fit visibly inside that bracket.
367
+ const usedRightX = Math.max(packer.usedRightX(), tabRightX);
368
+ const box: BoundingBox = {
369
+ x: 0,
370
+ y: origin.y,
371
+ width: ctx.chartRightX,
372
+ height: bandHeight,
373
+ };
374
+
375
+ // Owner display string: id → title for teams/people; falls back to id.
376
+ const ownerRaw = propValue(lane.properties, 'owner');
377
+ let ownerDisplay: string | undefined;
378
+ if (ownerRaw) {
379
+ const team = ctx.teams.get(ownerRaw);
380
+ const person = ctx.persons.get(ownerRaw);
381
+ ownerDisplay = team?.title ?? person?.title ?? ownerRaw;
382
+ }
383
+
384
+ // Lane utilization underline (m12). Computed *after* children are
385
+ // positioned so the contributors carry their final box.x / box.width.
386
+ // Returns null when the lane has no `capacity:`, no contributing
387
+ // items, or has opted out of every color band — the renderer reads
388
+ // null as "paint nothing".
389
+ const utilization = capacity
390
+ ? (() => {
391
+ const { warn, over } = resolveLaneUtilizationThresholds(
392
+ lane,
393
+ ctx.styleCtx.defaults,
394
+ );
395
+ return computeLaneUtilization({
396
+ children,
397
+ capacityValue: capacity.value,
398
+ warnFraction: warn,
399
+ overFraction: over,
400
+ });
401
+ })()
402
+ : null;
403
+
404
+ ctx.currentFlowKey = previousFlowKey;
405
+ return {
406
+ positioned: {
407
+ id: lane.name,
408
+ title: lane.title ?? lane.name ?? '',
409
+ box,
410
+ bandIndex,
411
+ children,
412
+ nested: [],
413
+ style,
414
+ owner: ownerDisplay,
415
+ footnoteIndicators,
416
+ capacity,
417
+ utilization,
418
+ },
419
+ usedHeight: bandHeight,
420
+ usedRightX,
421
+ };
422
+ }
423
+ }
@@ -0,0 +1,68 @@
1
+ // Renderable — measure/place tree primitive that the m2.5c rewrite of
2
+ // `layout.ts` is built on. Each entity (item, swimlane, group, ...)
3
+ // implements this interface and reports its intrinsic size; parents
4
+ // stack children using BandScale and the children's reported heights.
5
+ //
6
+ // The interface intentionally separates two phases:
7
+ //
8
+ // 1. measure(ctx) -> IntrinsicSize
9
+ // Pure, idempotent. Computes width + height the node WANTS given
10
+ // the available scales and resolved style. No side effects.
11
+ //
12
+ // 2. place(origin, ctx) -> TPositioned
13
+ // Anchors the node at `origin` and emits its positioned form.
14
+ // Recursively places children inside this call.
15
+ //
16
+ // `place` may invoke `measure` again — implementations should make
17
+ // `measure` cheap. The production nodes (`ItemNode`, `SwimlaneNode`,
18
+ // etc. under `nodes/`) follow this pattern: place re-measures, but
19
+ // measure is O(content) without I/O.
20
+
21
+ import type { BandScale } from './band-scale.js';
22
+ import type { TimeScale } from './time-scale.js';
23
+ import type { ResolvedStyle } from './types.js';
24
+
25
+ export interface Point {
26
+ x: number;
27
+ y: number;
28
+ }
29
+
30
+ export interface IntrinsicSize {
31
+ /**
32
+ * Width the node wants. For time-driven entities (items, the
33
+ * timeline header) this comes from `TimeScale.forward(end) -
34
+ * forward(start)`; for static entities it's content-driven.
35
+ */
36
+ width: number;
37
+ /**
38
+ * Height the node wants. Bubbles up to the parent BandScale,
39
+ * which in v2 uses it to size band slots instead of a fixed
40
+ * `ITEM_ROW_HEIGHT` constant.
41
+ */
42
+ height: number;
43
+ }
44
+
45
+ export interface MeasureContext {
46
+ time: TimeScale;
47
+ bands: BandScale;
48
+ style: ResolvedStyle;
49
+ }
50
+
51
+ export interface PlaceContext extends MeasureContext {
52
+ /**
53
+ * Horizontal extent of the band background that owns this node.
54
+ * Used by swimlanes to draw their full-width tab + band fill;
55
+ * defaults to `time.range` when absent.
56
+ */
57
+ bandX?: number;
58
+ bandWidth?: number;
59
+ }
60
+
61
+ export interface Renderable<TPositioned> {
62
+ /** Stable id for the node (matches the DSL entity id). */
63
+ id: string;
64
+ /** Compute intrinsic size — pure and idempotent. */
65
+ measure(ctx: MeasureContext): IntrinsicSize;
66
+ /** Anchor + emit positioned form. May recurse into children. */
67
+ place(origin: Point, ctx: PlaceContext): TPositioned;
68
+ }