@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.
- package/LICENSE +190 -0
- package/README.md +103 -0
- package/dist/band-scale.d.ts +56 -0
- package/dist/band-scale.d.ts.map +1 -0
- package/dist/band-scale.js +86 -0
- package/dist/band-scale.js.map +1 -0
- package/dist/calendar.d.ts +79 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +210 -0
- package/dist/calendar.js.map +1 -0
- package/dist/capacity.d.ts +72 -0
- package/dist/capacity.d.ts.map +1 -0
- package/dist/capacity.js +163 -0
- package/dist/capacity.js.map +1 -0
- package/dist/dsl-utils.d.ts +5 -0
- package/dist/dsl-utils.d.ts.map +1 -0
- package/dist/dsl-utils.js +28 -0
- package/dist/dsl-utils.js.map +1 -0
- package/dist/edge-routing.d.ts +89 -0
- package/dist/edge-routing.d.ts.map +1 -0
- package/dist/edge-routing.js +435 -0
- package/dist/edge-routing.js.map +1 -0
- package/dist/frame-tab-geometry.d.ts +78 -0
- package/dist/frame-tab-geometry.d.ts.map +1 -0
- package/dist/frame-tab-geometry.js +115 -0
- package/dist/frame-tab-geometry.js.map +1 -0
- package/dist/header-card-geometry.d.ts +29 -0
- package/dist/header-card-geometry.d.ts.map +1 -0
- package/dist/header-card-geometry.js +41 -0
- package/dist/header-card-geometry.js.map +1 -0
- package/dist/i18n.d.ts +48 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +114 -0
- package/dist/i18n.js.map +1 -0
- package/dist/include-chrome-geometry.d.ts +86 -0
- package/dist/include-chrome-geometry.d.ts.map +1 -0
- package/dist/include-chrome-geometry.js +104 -0
- package/dist/include-chrome-geometry.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/item-bar-geometry.d.ts +127 -0
- package/dist/item-bar-geometry.d.ts.map +1 -0
- package/dist/item-bar-geometry.js +173 -0
- package/dist/item-bar-geometry.js.map +1 -0
- package/dist/lane-utilization.d.ts +90 -0
- package/dist/lane-utilization.d.ts.map +1 -0
- package/dist/lane-utilization.js +206 -0
- package/dist/lane-utilization.js.map +1 -0
- package/dist/layout-context.d.ts +143 -0
- package/dist/layout-context.d.ts.map +1 -0
- package/dist/layout-context.js +8 -0
- package/dist/layout-context.js.map +1 -0
- package/dist/layout.d.ts +18 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +1213 -0
- package/dist/layout.js.map +1 -0
- package/dist/nodes/anchor-node.d.ts +16 -0
- package/dist/nodes/anchor-node.d.ts.map +1 -0
- package/dist/nodes/anchor-node.js +68 -0
- package/dist/nodes/anchor-node.js.map +1 -0
- package/dist/nodes/footnote-node.d.ts +10 -0
- package/dist/nodes/footnote-node.d.ts.map +1 -0
- package/dist/nodes/footnote-node.js +41 -0
- package/dist/nodes/footnote-node.js.map +1 -0
- package/dist/nodes/group-node.d.ts +29 -0
- package/dist/nodes/group-node.d.ts.map +1 -0
- package/dist/nodes/group-node.js +187 -0
- package/dist/nodes/group-node.js.map +1 -0
- package/dist/nodes/include-node.d.ts +16 -0
- package/dist/nodes/include-node.d.ts.map +1 -0
- package/dist/nodes/include-node.js +117 -0
- package/dist/nodes/include-node.js.map +1 -0
- package/dist/nodes/item-node.d.ts +51 -0
- package/dist/nodes/item-node.d.ts.map +1 -0
- package/dist/nodes/item-node.js +108 -0
- package/dist/nodes/item-node.js.map +1 -0
- package/dist/nodes/marker-geometry.d.ts +22 -0
- package/dist/nodes/marker-geometry.d.ts.map +1 -0
- package/dist/nodes/marker-geometry.js +38 -0
- package/dist/nodes/marker-geometry.js.map +1 -0
- package/dist/nodes/milestone-node.d.ts +48 -0
- package/dist/nodes/milestone-node.d.ts.map +1 -0
- package/dist/nodes/milestone-node.js +210 -0
- package/dist/nodes/milestone-node.js.map +1 -0
- package/dist/nodes/parallel-node.d.ts +21 -0
- package/dist/nodes/parallel-node.d.ts.map +1 -0
- package/dist/nodes/parallel-node.js +80 -0
- package/dist/nodes/parallel-node.js.map +1 -0
- package/dist/nodes/roadmap-node.d.ts +76 -0
- package/dist/nodes/roadmap-node.d.ts.map +1 -0
- package/dist/nodes/roadmap-node.js +788 -0
- package/dist/nodes/roadmap-node.js.map +1 -0
- package/dist/nodes/swimlane-node.d.ts +38 -0
- package/dist/nodes/swimlane-node.d.ts.map +1 -0
- package/dist/nodes/swimlane-node.js +308 -0
- package/dist/nodes/swimlane-node.js.map +1 -0
- package/dist/renderable.d.ts +44 -0
- package/dist/renderable.d.ts.map +1 -0
- package/dist/renderable.js +21 -0
- package/dist/renderable.js.map +1 -0
- package/dist/row-packer.d.ts +125 -0
- package/dist/row-packer.d.ts.map +1 -0
- package/dist/row-packer.js +169 -0
- package/dist/row-packer.js.map +1 -0
- package/dist/style-resolution.d.ts +14 -0
- package/dist/style-resolution.d.ts.map +1 -0
- package/dist/style-resolution.js +191 -0
- package/dist/style-resolution.js.map +1 -0
- package/dist/themes/dark.d.ts +4 -0
- package/dist/themes/dark.d.ts.map +1 -0
- package/dist/themes/dark.js +241 -0
- package/dist/themes/dark.js.map +1 -0
- package/dist/themes/index.d.ts +15 -0
- package/dist/themes/index.d.ts.map +1 -0
- package/dist/themes/index.js +30 -0
- package/dist/themes/index.js.map +1 -0
- package/dist/themes/light.d.ts +4 -0
- package/dist/themes/light.d.ts.map +1 -0
- package/dist/themes/light.js +248 -0
- package/dist/themes/light.js.map +1 -0
- package/dist/themes/shape.d.ts +194 -0
- package/dist/themes/shape.d.ts.map +1 -0
- package/dist/themes/shape.js +6 -0
- package/dist/themes/shape.js.map +1 -0
- package/dist/themes/shared.d.ts +145 -0
- package/dist/themes/shared.d.ts.map +1 -0
- package/dist/themes/shared.js +310 -0
- package/dist/themes/shared.js.map +1 -0
- package/dist/time-scale.d.ts +39 -0
- package/dist/time-scale.d.ts.map +1 -0
- package/dist/time-scale.js +62 -0
- package/dist/time-scale.js.map +1 -0
- package/dist/types.d.ts +483 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/view-preset.d.ts +23 -0
- package/dist/view-preset.d.ts.map +1 -0
- package/dist/view-preset.js +146 -0
- package/dist/view-preset.js.map +1 -0
- package/dist/working-calendar.d.ts +14 -0
- package/dist/working-calendar.d.ts.map +1 -0
- package/dist/working-calendar.js +74 -0
- package/dist/working-calendar.js.map +1 -0
- package/package.json +37 -0
- package/src/band-scale.ts +115 -0
- package/src/calendar.ts +244 -0
- package/src/capacity.ts +191 -0
- package/src/dsl-utils.ts +30 -0
- package/src/edge-routing.ts +550 -0
- package/src/frame-tab-geometry.ts +165 -0
- package/src/header-card-geometry.ts +48 -0
- package/src/i18n.ts +124 -0
- package/src/include-chrome-geometry.ts +156 -0
- package/src/index.ts +116 -0
- package/src/item-bar-geometry.ts +222 -0
- package/src/lane-utilization.ts +259 -0
- package/src/layout-context.ts +182 -0
- package/src/layout.ts +1446 -0
- package/src/nodes/anchor-node.ts +77 -0
- package/src/nodes/footnote-node.ts +60 -0
- package/src/nodes/group-node.ts +252 -0
- package/src/nodes/include-node.ts +168 -0
- package/src/nodes/item-node.ts +171 -0
- package/src/nodes/marker-geometry.ts +43 -0
- package/src/nodes/milestone-node.ts +263 -0
- package/src/nodes/parallel-node.ts +101 -0
- package/src/nodes/roadmap-node.ts +957 -0
- package/src/nodes/swimlane-node.ts +423 -0
- package/src/renderable.ts +68 -0
- package/src/row-packer.ts +271 -0
- package/src/style-resolution.ts +243 -0
- package/src/themes/dark.ts +244 -0
- package/src/themes/index.ts +36 -0
- package/src/themes/light.ts +251 -0
- package/src/themes/shape.ts +230 -0
- package/src/themes/shared.ts +369 -0
- package/src/time-scale.ts +78 -0
- package/src/types.ts +607 -0
- package/src/view-preset.ts +180 -0
- 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
|
+
}
|