@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,77 @@
1
+ // AnchorNode — Renderable for a single anchor + a `buildAnchors` loop
2
+ // helper. Each anchor sits in the marker row of the timeline and emits
3
+ // a downward cut line through the chart. Marker-row placement (row +
4
+ // label box + left/right label side) is decided up-front in
5
+ // `roadmap-node.ts` (`packMarkerRow`) so anchors and milestones share
6
+ // the same row stack — see `LayoutContext.markerRowPlacements`.
7
+
8
+ import type { AnchorDeclaration } from '@nowline/core';
9
+ import { parseDate, propValue } from '../dsl-utils.js';
10
+ import type { LayoutContext } from '../layout-context.js';
11
+ import { resolveStyle } from '../style-resolution.js';
12
+ import type { Point, PositionedAnchor } from '../types.js';
13
+ import {
14
+ MARKER_DIAMOND_RADIUS_PX,
15
+ MARKER_LABEL_GAP_PX,
16
+ MARKER_LABEL_HEIGHT_PX,
17
+ } from './marker-geometry.js';
18
+
19
+ export class AnchorNode {
20
+ constructor(
21
+ public readonly id: string,
22
+ public readonly anchor: AnchorDeclaration,
23
+ ) {}
24
+
25
+ /**
26
+ * Returns null when the anchor has no `date:` or its date falls
27
+ * outside the chart's domain (skipped silently — same as the legacy
28
+ * `buildAnchors` continue-paths).
29
+ */
30
+ place(ctx: LayoutContext, _milestoneXs: Set<number>): PositionedAnchor | null {
31
+ const dateRaw = propValue(this.anchor.properties, 'date');
32
+ const date = parseDate(dateRaw);
33
+ if (!date) return null;
34
+ const x = ctx.scale.forwardWithinDomain(date);
35
+ if (x === null) return null;
36
+ const style = resolveStyle('anchor', this.anchor.properties, ctx.styleCtx);
37
+ const placement = ctx.markerRowPlacements.get(this.id);
38
+ const y = placement?.centerY ?? ctx.timeline.markerRow.y;
39
+ const center: Point = { x, y };
40
+ const labelBox = placement?.labelBox ?? {
41
+ x: x + MARKER_DIAMOND_RADIUS_PX + MARKER_LABEL_GAP_PX,
42
+ y: y - 4,
43
+ width: 0,
44
+ height: MARKER_LABEL_HEIGHT_PX,
45
+ };
46
+ const labelSide = placement?.labelSide ?? 'right';
47
+ ctx.entityLeftEdges.set(this.id, x);
48
+ ctx.entityRightEdges.set(this.id, x);
49
+ ctx.entityMidpoints.set(this.id, center);
50
+ return {
51
+ id: this.id,
52
+ title: this.anchor.title ?? this.id,
53
+ center,
54
+ radius: MARKER_DIAMOND_RADIUS_PX,
55
+ style,
56
+ predecessorPoints: [],
57
+ cutTopY: ctx.chartTopY,
58
+ cutBottomY: ctx.swimlaneBottomY,
59
+ bumpedUp: (placement?.rowIndex ?? 0) > 0,
60
+ labelBox,
61
+ labelSide,
62
+ };
63
+ }
64
+ }
65
+
66
+ export function buildAnchors(
67
+ anchors: Map<string, AnchorDeclaration>,
68
+ ctx: LayoutContext,
69
+ milestoneXs: Set<number>,
70
+ ): PositionedAnchor[] {
71
+ const out: PositionedAnchor[] = [];
72
+ for (const [id, a] of anchors) {
73
+ const positioned = new AnchorNode(id, a).place(ctx, milestoneXs);
74
+ if (positioned) out.push(positioned);
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,60 @@
1
+ // FootnoteNode + buildFootnotes — footnote area at the bottom of the
2
+ // chart. Each footnote gets a stable index (1-based, sorted by id) and
3
+ // records its `on:` host references so item / swimlane sequencing can
4
+ // emit the matching superscript indicators. The panel grows to fit:
5
+ // 28 px header + (entries × FOOTNOTE_ROW_HEIGHT) + 16 px padding.
6
+
7
+ import type { FootnoteDeclaration } from '@nowline/core';
8
+ import { propValues } from '../dsl-utils.js';
9
+ import type { LayoutContext } from '../layout-context.js';
10
+ import { resolveStyle } from '../style-resolution.js';
11
+ import {
12
+ FOOTNOTE_HEADER_HEIGHT_PX,
13
+ FOOTNOTE_PANEL_PADDING_PX,
14
+ FOOTNOTE_ROW_HEIGHT,
15
+ } from '../themes/shared.js';
16
+ import type { BoundingBox, PositionedFootnoteArea, PositionedFootnoteEntry } from '../types.js';
17
+
18
+ export interface BuiltFootnotes {
19
+ area: PositionedFootnoteArea;
20
+ index: Map<string, number>;
21
+ hosts: Map<string, string[]>;
22
+ }
23
+
24
+ export function buildFootnotes(
25
+ footnotes: Map<string, FootnoteDeclaration>,
26
+ ctx: LayoutContext,
27
+ chartBottomY: number,
28
+ ): BuiltFootnotes {
29
+ const entries: PositionedFootnoteEntry[] = [];
30
+ const index = new Map<string, number>();
31
+ const hosts = new Map<string, string[]>();
32
+ const ordered = [...footnotes.entries()].sort(([a], [b]) => a.localeCompare(b));
33
+ ordered.forEach(([id, f], i) => {
34
+ const n = i + 1;
35
+ index.set(id, n);
36
+ hosts.set(id, propValues(f.properties, 'on'));
37
+ entries.push({
38
+ number: n,
39
+ title: f.title ?? id,
40
+ description: f.description?.text,
41
+ style: resolveStyle('footnote', f.properties, ctx.styleCtx),
42
+ });
43
+ });
44
+ const box: BoundingBox = {
45
+ x: FOOTNOTE_PANEL_PADDING_PX,
46
+ y: chartBottomY + FOOTNOTE_PANEL_PADDING_PX,
47
+ width: ctx.chartRightX - 2 * FOOTNOTE_PANEL_PADDING_PX,
48
+ height:
49
+ entries.length === 0
50
+ ? 0
51
+ : FOOTNOTE_HEADER_HEIGHT_PX +
52
+ entries.length * FOOTNOTE_ROW_HEIGHT +
53
+ FOOTNOTE_PANEL_PADDING_PX,
54
+ };
55
+ return {
56
+ area: { box, entries },
57
+ index,
58
+ hosts,
59
+ };
60
+ }
@@ -0,0 +1,252 @@
1
+ // GroupNode — Renderable for a `group { ... }` block. Sequences
2
+ // children inside the group's content area using the same row-pack
3
+ // engine `SwimlaneNode` uses, so an item whose desired start collides
4
+ // with a sibling's `rightEdge`, caption `spillX`, or a slack-arrow
5
+ // corridor bumps to a new row inside the group. The group's reported
6
+ // `box.height` grows with the row stack so parent containers stack
7
+ // against the actual painted extent.
8
+ //
9
+ // Filled-style groups paint a title chiclet flush in the upper-left
10
+ // corner of the box; the layout reserves vertical top padding equal
11
+ // to the chiclet height plus a small gutter before the first inner
12
+ // row begins.
13
+
14
+ import type { EntityProperty, GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
15
+ import { isItemDeclaration } from '@nowline/core';
16
+ import { deriveItemDurationDays } from '../calendar.js';
17
+ import type { LayoutContext, TrackCursor } from '../layout-context.js';
18
+ import { RowPacker } from '../row-packer.js';
19
+ import { resolveStyle } from '../style-resolution.js';
20
+ import {
21
+ GROUP_BOTTOM_PAD_PX,
22
+ GROUP_BRACKET_LABEL_OVERHANG_PX,
23
+ GROUP_TITLE_TAB_GUTTER_PX,
24
+ GROUP_TITLE_TAB_HEIGHT_PX,
25
+ ITEM_INSET_PX,
26
+ MIN_ITEM_WIDTH,
27
+ TRACK_BLOCK_TAIL_GUTTER_PX,
28
+ } from '../themes/shared.js';
29
+ import type {
30
+ BoundingBox,
31
+ PositionedGroup,
32
+ PositionedItem,
33
+ PositionedTrackChild,
34
+ } from '../types.js';
35
+
36
+ export interface GroupNodeDeps {
37
+ sequenceItem: (
38
+ child: ItemDeclaration,
39
+ cursor: TrackCursor,
40
+ ctx: LayoutContext,
41
+ ownerOverride?: string,
42
+ ) => PositionedItem;
43
+ sequenceOne: (
44
+ child: ItemDeclaration | GroupBlock | ParallelBlock,
45
+ cursor: TrackCursor,
46
+ ctx: LayoutContext,
47
+ ) => PositionedTrackChild;
48
+ resolveChildStart: (
49
+ props: EntityProperty[],
50
+ seqDefault: number,
51
+ laneLeftX: number,
52
+ ctx: LayoutContext,
53
+ ) => number;
54
+ newCursor: (x: number, y: number) => TrackCursor;
55
+ estimateTextWidth: (text: string, fontSize: number) => number;
56
+ /** Predict the extra vertical height an item's wrapped label-chip
57
+ * rows will add to its bar; used to size the row-packer's row
58
+ * pitch ahead of the call to `sequenceItem`. */
59
+ predictItemChipExtraHeight: (item: ItemDeclaration, ctx: LayoutContext) => number;
60
+ }
61
+
62
+ export class GroupNode {
63
+ constructor(
64
+ public readonly node: GroupBlock,
65
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: accessed via `const { deps } = this` destructuring inside methods, which the analyzer does not detect.
66
+ private readonly deps: GroupNodeDeps,
67
+ ) {}
68
+
69
+ get id(): string {
70
+ return this.node.name ?? '';
71
+ }
72
+
73
+ /**
74
+ * Sequence children inside the group's content area, advance the
75
+ * parent `cursor` past the group's right edge plus
76
+ * `TRACK_BLOCK_TAIL_GUTTER_PX` of breathing room, and return a
77
+ * `PositionedGroup` whose `box.height` covers the chiclet pad,
78
+ * every row-packed child row, and the bottom pad.
79
+ */
80
+ place(cursor: TrackCursor, ctx: LayoutContext): PositionedGroup {
81
+ const { node } = this;
82
+ const { deps } = this;
83
+ const style = resolveStyle('group', node.properties, ctx.styleCtx);
84
+ const startX = cursor.x;
85
+ const title = node.title ?? node.name;
86
+
87
+ // Group children chain in time inside the group, so they
88
+ // form one sub-flow under the parent's flow path. See
89
+ // `LayoutContext.currentFlowKey`.
90
+ const previousFlowKey = ctx.currentFlowKey;
91
+ ctx.currentFlowKey = `${previousFlowKey}/group:${node.name ?? 'g'}`;
92
+ // Mirrors `renderGroup`'s `hasFill` decision so the painted box
93
+ // and the layout's reservation agree on whether a chiclet exists.
94
+ const hasChiclet = style.bg !== 'none' && style.bg !== '#ffffff' && Boolean(title);
95
+ const topPad = hasChiclet ? GROUP_TITLE_TAB_HEIGHT_PX + GROUP_TITLE_TAB_GUTTER_PX : 0;
96
+ const bottomPad = hasChiclet ? GROUP_BOTTOM_PAD_PX : 0;
97
+ // Bracket-style groups paint their label at `box.y - 2`, so the
98
+ // label glyph overhangs ABOVE box.y. Without an explicit
99
+ // reservation, two bracket-titled groups stacked in a parallel
100
+ // collide: the previous sibling's bracket-foot ends at its
101
+ // box.bottom, and the next sibling's label visual top sits just
102
+ // above box.y — they touch in the gap. Shift `box.y` down by
103
+ // the overhang amount so the glyph lands in space we own.
104
+ const bracketLabelOverhang = !hasChiclet && title ? GROUP_BRACKET_LABEL_OVERHANG_PX : 0;
105
+ const startY = cursor.y + bracketLabelOverhang;
106
+
107
+ const step = ctx.bandScale.step();
108
+ const groupContentLeftX = startX;
109
+ const packer = new RowPacker({
110
+ laneLeftX: groupContentLeftX,
111
+ originY: startY + topPad,
112
+ minRowHeight: step,
113
+ slackCorridors: ctx.slackCorridors,
114
+ });
115
+ let timeCursorX = groupContentLeftX;
116
+
117
+ const children: PositionedTrackChild[] = [];
118
+ for (const child of node.content) {
119
+ if (child.$type === 'DescriptionDirective') continue;
120
+
121
+ if (!isItemDeclaration(child)) {
122
+ const blockProps = (child as ParallelBlock | GroupBlock).properties ?? [];
123
+ const blockStart = deps.resolveChildStart(
124
+ blockProps,
125
+ timeCursorX,
126
+ groupContentLeftX,
127
+ ctx,
128
+ );
129
+ const { rowIndex, y: blockY } = packer.placeBlock();
130
+ const innerCursor = deps.newCursor(blockStart, blockY);
131
+ const positioned = deps.sequenceOne(
132
+ child as ItemDeclaration | GroupBlock | ParallelBlock,
133
+ innerCursor,
134
+ ctx,
135
+ );
136
+ children.push(positioned);
137
+ const blockEnd = positioned.box.x + positioned.box.width;
138
+ const blockHeight = Math.max(step, innerCursor.height);
139
+ packer.commitBlock({
140
+ rowIndex,
141
+ placed: positioned,
142
+ blockHeight,
143
+ blockEnd,
144
+ });
145
+ timeCursorX = Math.max(timeCursorX, blockEnd);
146
+ continue;
147
+ }
148
+
149
+ const props = (child as ItemDeclaration).properties;
150
+ const desiredStart = deps.resolveChildStart(props, timeCursorX, groupContentLeftX, ctx);
151
+ // Predict logical extent so the row-packer can bump on
152
+ // collision before we hand off to `sequenceItem`. Mirrors
153
+ // SwimlaneNode's pre-flight width math.
154
+ const durationDays = deriveItemDurationDays(props, ctx.sizes, ctx.cal);
155
+ const naturalWidth = Math.max(MIN_ITEM_WIDTH, durationDays * ctx.timeline.pixelsPerDay);
156
+ const desiredEnd = desiredStart + naturalWidth;
157
+ const childId = (child as ItemDeclaration).name ?? '';
158
+
159
+ const chipExtra = deps.predictItemChipExtraHeight(child as ItemDeclaration, ctx);
160
+ const predictedHeight = step + chipExtra;
161
+ const { rowIndex, y: rowY } = packer.placeItem({
162
+ childId,
163
+ desiredStart,
164
+ desiredEnd,
165
+ // Row pitch = `step()` + extra chip-row height. Keeps
166
+ // the inter-row visible gap (= step - bandwidth) intact
167
+ // when an item's labels wrap and grow the bar.
168
+ predictedHeight,
169
+ });
170
+
171
+ const innerCursor = deps.newCursor(desiredStart, rowY);
172
+ const positioned = deps.sequenceItem(child as ItemDeclaration, innerCursor, ctx);
173
+ children.push(positioned);
174
+
175
+ const itemLogicalEnd = positioned.box.x + positioned.box.width + ITEM_INSET_PX;
176
+ timeCursorX = Math.max(timeCursorX, itemLogicalEnd);
177
+
178
+ let spillReservation: number | null = null;
179
+ const hasAnySpill =
180
+ positioned.textSpills ||
181
+ positioned.chipsOutside ||
182
+ positioned.dotSpills ||
183
+ positioned.iconSpills ||
184
+ positioned.footnoteSpills;
185
+ if (hasAnySpill) {
186
+ const farRight = Math.max(
187
+ positioned.decorationsRightX,
188
+ positioned.chipsOutside ? positioned.chipsRightX : 0,
189
+ );
190
+ spillReservation = farRight + 6;
191
+ }
192
+
193
+ packer.commitItem({
194
+ rowIndex,
195
+ placed: positioned,
196
+ logicalEnd: itemLogicalEnd,
197
+ spillReservation,
198
+ rowHeight: predictedHeight,
199
+ });
200
+ }
201
+
202
+ const innerHeight = Math.max(step, packer.usedHeight());
203
+ // The group's painted box hugs everything its children put on
204
+ // screen, including caption text that spills past a bar's right
205
+ // edge. `usedRightX` is the rightmost extent any row reached —
206
+ // either a bar's logical right or a caption's reserved spill —
207
+ // so the orange tint visually "owns" the spilled title/meta.
208
+ //
209
+ // The cursor channel (`cursor.x` / `cursor.maxX`) stays on the
210
+ // COMPACT `timeCursorX` (bar logical ends only). Bubbling the
211
+ // wide `visualRightX` upward would propagate through
212
+ // `ParallelNode.maxRight → parallel.box.width → swimlane
213
+ // blockEnd → timeCursorX`, pushing every subsequent sibling
214
+ // right by the spill width — including siblings on entirely
215
+ // different rows of the parent swimlane. The painted box and
216
+ // the logical cursor advance are intentionally decoupled here.
217
+ const visualRightX = Math.max(timeCursorX, packer.usedRightX());
218
+ const box: BoundingBox = {
219
+ x: startX,
220
+ y: startY,
221
+ width: visualRightX - startX,
222
+ height: topPad + innerHeight + bottomPad,
223
+ };
224
+ cursor.x = timeCursorX + TRACK_BLOCK_TAIL_GUTTER_PX;
225
+ cursor.maxX = Math.max(cursor.maxX, cursor.x);
226
+ // The painted `box.height` is the group's tight visual
227
+ // footprint (chiclet + content + bottomPad). The cursor
228
+ // advance, however, must include one inter-row gap
229
+ // (`step - bandwidth`) so a sibling stacked below in a
230
+ // `parallel` (or any track-pack consumer) doesn't butt
231
+ // right up against the group's bottom edge. Items naturally
232
+ // include that gap because they report `cursor.height = step`
233
+ // (bandwidth + gap); groups need to add it explicitly since
234
+ // their painted height is gap-less.
235
+ const interRowGap = ctx.bandScale.step() - ctx.bandScale.bandwidth();
236
+ cursor.height = Math.max(cursor.height, bracketLabelOverhang + box.height + interRowGap);
237
+ const id = node.name;
238
+ if (id) {
239
+ ctx.entityLeftEdges.set(id, box.x);
240
+ ctx.entityRightEdges.set(id, box.x + box.width);
241
+ }
242
+ ctx.currentFlowKey = previousFlowKey;
243
+ return {
244
+ kind: 'group',
245
+ id,
246
+ title,
247
+ box,
248
+ children,
249
+ style,
250
+ };
251
+ }
252
+ }
@@ -0,0 +1,168 @@
1
+ // IncludeNode + buildIncludeRegions — render isolated `include {}`
2
+ // regions stacked under the main swimlanes. Each region runs its own
3
+ // SwimlaneNode pass against the parent's TimeScale so dates align
4
+ // vertically with the tick row above the region. The label tab is
5
+ // reserved 18 px above the first region; subsequent regions are
6
+ // separated by GAP_BETWEEN_REGIONS px.
7
+
8
+ import type {
9
+ EntityProperty,
10
+ GroupBlock,
11
+ IsolatedRegion,
12
+ ItemDeclaration,
13
+ ParallelBlock,
14
+ } from '@nowline/core';
15
+ import { resolveSizes } from '../calendar.js';
16
+ import { includeChromeGeometry } from '../include-chrome-geometry.js';
17
+ import type { LayoutContext, TrackCursor } from '../layout-context.js';
18
+ import { resolveStyle } from '../style-resolution.js';
19
+ import type {
20
+ BoundingBox,
21
+ PositionedIncludeRegion,
22
+ PositionedItem,
23
+ PositionedSwimlane,
24
+ PositionedTrackChild,
25
+ } from '../types.js';
26
+ import { SwimlaneNode } from './swimlane-node.js';
27
+
28
+ const TAB_RESERVE = 18;
29
+ const REGION_INSET_TOP = 14;
30
+ const REGION_INSET_BOTTOM = 14;
31
+ const GAP_BETWEEN_REGIONS = 16;
32
+
33
+ // Visual breathing room added past the rightmost element when sizing
34
+ // the include's bounding box. Keeps the dashed bracket from butting up
35
+ // against an item's right edge while still trimming the wide
36
+ // chart-width whitespace left over from full-width sizing.
37
+ const INCLUDE_CONTENT_RIGHT_PAD_PX = 32;
38
+
39
+ export interface IncludeNodeDeps {
40
+ sequenceItem: (
41
+ child: ItemDeclaration,
42
+ cursor: TrackCursor,
43
+ ctx: LayoutContext,
44
+ ownerOverride?: string,
45
+ ) => PositionedItem;
46
+ sequenceOne: (
47
+ child: ItemDeclaration | GroupBlock | ParallelBlock,
48
+ cursor: TrackCursor,
49
+ ctx: LayoutContext,
50
+ ) => PositionedTrackChild;
51
+ resolveChildStart: (
52
+ props: EntityProperty[],
53
+ seqDefault: number,
54
+ laneLeftX: number,
55
+ ctx: LayoutContext,
56
+ ) => number;
57
+ newCursor: (x: number, y: number) => TrackCursor;
58
+ estimateTextWidth: (text: string, fontSize: number) => number;
59
+ predictItemChipExtraHeight: (item: ItemDeclaration, ctx: LayoutContext) => number;
60
+ }
61
+
62
+ export function buildIncludeRegions(
63
+ regions: IsolatedRegion[],
64
+ ctx: LayoutContext,
65
+ startY: number,
66
+ deps: IncludeNodeDeps,
67
+ ): { regions: PositionedIncludeRegion[]; endY: number } {
68
+ let y = startY + TAB_RESERVE;
69
+ const out: PositionedIncludeRegion[] = [];
70
+ let isFirst = true;
71
+ for (const region of regions) {
72
+ if (!isFirst) y += GAP_BETWEEN_REGIONS;
73
+ isFirst = false;
74
+ const label = region.content.roadmap?.title ?? region.sourcePath;
75
+ const innerStartY = y + REGION_INSET_TOP;
76
+ const childCtx: LayoutContext = {
77
+ cal: ctx.cal,
78
+ styleCtx: {
79
+ theme: ctx.styleCtx.theme,
80
+ styles: region.config.styles,
81
+ defaults: region.config.defaults,
82
+ labels: region.content.labels,
83
+ },
84
+ // Each include region declares its own sizes; resolve them under
85
+ // the parent's calendar so child sized items render at the same
86
+ // pixels-per-day scale as the host roadmap.
87
+ sizes: resolveSizes(region.content.sizes, ctx.cal),
88
+ labels: region.content.labels,
89
+ teams: region.content.teams,
90
+ persons: region.content.persons,
91
+ symbols: region.config.symbols,
92
+ footnoteIndex: new Map(),
93
+ footnoteHosts: new Map(),
94
+ timeline: ctx.timeline,
95
+ scale: ctx.scale,
96
+ calendar: ctx.calendar,
97
+ bandScale: ctx.bandScale,
98
+ entityLeftEdges: new Map(),
99
+ entityRightEdges: new Map(),
100
+ entityMidpoints: new Map(),
101
+ entityVisualLeftX: new Map(),
102
+ entityVisualRightX: new Map(),
103
+ itemArrowSource: new Map(),
104
+ itemFlowKey: new Map(),
105
+ currentFlowKey: '',
106
+ itemSlackAttachY: new Map(),
107
+ slackCorridors: [],
108
+ markerRowPlacements: new Map(),
109
+ chartTopY: innerStartY,
110
+ chartBottomY: innerStartY,
111
+ swimlaneBottomY: innerStartY,
112
+ chartRightX: ctx.chartRightX,
113
+ };
114
+ const nestedSwimlanes: PositionedSwimlane[] = [];
115
+ let cursorY = innerStartY;
116
+ let bandIndex = 0;
117
+ let nestedContentRightX = childCtx.timeline.originX;
118
+ for (const lane of region.content.swimlanes.values()) {
119
+ const { positioned, usedHeight, usedRightX } = new SwimlaneNode(
120
+ { lane, bandIndex },
121
+ deps,
122
+ ).place({ x: childCtx.timeline.originX, y: cursorY }, childCtx);
123
+ nestedSwimlanes.push(positioned);
124
+ cursorY += usedHeight;
125
+ bandIndex++;
126
+ if (usedRightX > nestedContentRightX) nestedContentRightX = usedRightX;
127
+ }
128
+ const innerEndY = cursorY;
129
+ // Floor the region height to one row's bandwidth so an empty or
130
+ // tiny include still presents as a visible band — `bandwidth()`
131
+ // tracks whatever the host theme uses for swimlane row height.
132
+ const regionHeight = Math.max(
133
+ ctx.bandScale.bandwidth(),
134
+ innerEndY - y + REGION_INSET_BOTTOM,
135
+ );
136
+ // Shrink-wrap the include's bounding box to fit chrome + content
137
+ // (with a small right pad) instead of stretching to the full
138
+ // chart width. An include that reaches past the timeline still
139
+ // gets clamped to the chart's natural right edge so it never
140
+ // extends into the attribution / right-margin area.
141
+ const boxX = 0;
142
+ const { chromeRightX } = includeChromeGeometry(boxX, label, region.sourcePath);
143
+ const naturalRightX =
144
+ Math.max(chromeRightX, nestedContentRightX) + INCLUDE_CONTENT_RIGHT_PAD_PX;
145
+ const boxWidth = Math.min(ctx.chartRightX - boxX, naturalRightX - boxX);
146
+ const box: BoundingBox = {
147
+ x: boxX,
148
+ y,
149
+ width: boxWidth,
150
+ height: regionHeight,
151
+ };
152
+ // Mirror the shrunk width onto each nested swimlane band so the
153
+ // tinted background fits inside the dashed bracket instead of
154
+ // bleeding past it on the right.
155
+ for (const lane of nestedSwimlanes) {
156
+ lane.box.width = boxWidth;
157
+ }
158
+ out.push({
159
+ sourcePath: region.sourcePath,
160
+ label,
161
+ box,
162
+ nestedSwimlanes,
163
+ style: resolveStyle('swimlane', [], ctx.styleCtx),
164
+ });
165
+ y += regionHeight;
166
+ }
167
+ return { regions: out, endY: y };
168
+ }