@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
package/src/layout.ts
ADDED
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityProperty,
|
|
3
|
+
GroupBlock,
|
|
4
|
+
ItemDeclaration,
|
|
5
|
+
LabelDeclaration,
|
|
6
|
+
NowlineFile,
|
|
7
|
+
ParallelBlock,
|
|
8
|
+
ResolveResult,
|
|
9
|
+
SwimlaneDeclaration,
|
|
10
|
+
} from '@nowline/core';
|
|
11
|
+
import { isGroupBlock, isItemDeclaration, isParallelBlock } from '@nowline/core';
|
|
12
|
+
import {
|
|
13
|
+
addDays,
|
|
14
|
+
daysBetween,
|
|
15
|
+
deriveItemDurationDays,
|
|
16
|
+
deriveTotalEffortDays,
|
|
17
|
+
resolveDuration,
|
|
18
|
+
} from './calendar.js';
|
|
19
|
+
import {
|
|
20
|
+
estimateCapacitySuffixWidth,
|
|
21
|
+
formatCapacityNumber,
|
|
22
|
+
parseCapacityValue,
|
|
23
|
+
resolveCapacityIcon,
|
|
24
|
+
} from './capacity.js';
|
|
25
|
+
import { parseDate, propValue, propValues } from './dsl-utils.js';
|
|
26
|
+
import {
|
|
27
|
+
ChannelGrid,
|
|
28
|
+
collectRoutingObstacles,
|
|
29
|
+
type EdgeRouteRequest,
|
|
30
|
+
routeChannelEdges,
|
|
31
|
+
} from './edge-routing.js';
|
|
32
|
+
import {
|
|
33
|
+
HEADER_AUTHOR_FONT_SIZE_PX,
|
|
34
|
+
HEADER_AUTHOR_LINE_HEIGHT_PX,
|
|
35
|
+
HEADER_CARD_PADDING_BOTTOM,
|
|
36
|
+
HEADER_CARD_PADDING_TOP,
|
|
37
|
+
HEADER_CARD_PADDING_X,
|
|
38
|
+
HEADER_TITLE_FONT_SIZE_PX,
|
|
39
|
+
HEADER_TITLE_LINE_HEIGHT_PX,
|
|
40
|
+
HEADER_TITLE_TO_AUTHOR_GAP_PX,
|
|
41
|
+
} from './header-card-geometry.js';
|
|
42
|
+
import { localeStrings } from './i18n.js';
|
|
43
|
+
import {
|
|
44
|
+
ITEM_CAPTION_INSET_X_PX,
|
|
45
|
+
ITEM_CAPTION_META_BASELINE_OFFSET_PX,
|
|
46
|
+
ITEM_CAPTION_SPILL_GAP_PX,
|
|
47
|
+
ITEM_CAPTION_TITLE_FONT_SIZE_PX,
|
|
48
|
+
ITEM_DECORATION_SPILL_GAP_PX,
|
|
49
|
+
ITEM_FOOTNOTE_INDICATOR_STEP_PX,
|
|
50
|
+
ITEM_LINK_ICON_TILE_SIZE_PX,
|
|
51
|
+
ITEM_STATUS_DOT_RADIUS_PX,
|
|
52
|
+
LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX,
|
|
53
|
+
LABEL_CHIP_GAP_BETWEEN_PX,
|
|
54
|
+
LABEL_CHIP_HEIGHT_PX,
|
|
55
|
+
LABEL_CHIP_ROW_STEP_PX,
|
|
56
|
+
MIN_BAR_WIDTH_FOR_DOT_PX,
|
|
57
|
+
MIN_BAR_WIDTH_FOR_FOOTNOTE_PX,
|
|
58
|
+
MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX,
|
|
59
|
+
packSpillChips,
|
|
60
|
+
} from './item-bar-geometry.js';
|
|
61
|
+
import { type LayoutContext, newCursor, type TrackCursor } from './layout-context.js';
|
|
62
|
+
import { GroupNode } from './nodes/group-node.js';
|
|
63
|
+
import { ItemNode } from './nodes/item-node.js';
|
|
64
|
+
import { ParallelNode } from './nodes/parallel-node.js';
|
|
65
|
+
import { RoadmapNode } from './nodes/roadmap-node.js';
|
|
66
|
+
import { SwimlaneNode } from './nodes/swimlane-node.js';
|
|
67
|
+
import { resolveLabelChipStyle, resolveStyle, type StyleContext } from './style-resolution.js';
|
|
68
|
+
import type { ThemeName } from './themes/index.js';
|
|
69
|
+
import {
|
|
70
|
+
GUTTER_PX,
|
|
71
|
+
HEADER_BESIDE_MAX_WIDTH_PX,
|
|
72
|
+
HEADER_BESIDE_MIN_WIDTH_PX,
|
|
73
|
+
ITEM_INSET_PX,
|
|
74
|
+
MIN_ITEM_WIDTH,
|
|
75
|
+
NOW_PILL_LABEL_FONT_SIZE_PX,
|
|
76
|
+
NOW_PILL_LABEL_INSET_X_PX,
|
|
77
|
+
NOW_PILL_WIDTH_PX,
|
|
78
|
+
PADDING_PX,
|
|
79
|
+
PROGRESS_STRIP_HEIGHT_PX,
|
|
80
|
+
} from './themes/shared.js';
|
|
81
|
+
import type {
|
|
82
|
+
BoundingBox,
|
|
83
|
+
LinkIconKind,
|
|
84
|
+
Point,
|
|
85
|
+
PositionedCapacity,
|
|
86
|
+
PositionedDependencyEdge,
|
|
87
|
+
PositionedGroup,
|
|
88
|
+
PositionedIncludeRegion,
|
|
89
|
+
PositionedItem,
|
|
90
|
+
PositionedLabelChip,
|
|
91
|
+
PositionedNowline,
|
|
92
|
+
PositionedParallel,
|
|
93
|
+
PositionedRoadmap,
|
|
94
|
+
PositionedSwimlane,
|
|
95
|
+
PositionedTrackChild,
|
|
96
|
+
StatusKind,
|
|
97
|
+
} from './types.js';
|
|
98
|
+
import type { ViewPreset } from './view-preset.js';
|
|
99
|
+
import { daysPerUnit } from './working-calendar.js';
|
|
100
|
+
|
|
101
|
+
export interface LayoutOptions {
|
|
102
|
+
theme?: ThemeName;
|
|
103
|
+
today?: Date;
|
|
104
|
+
width?: number; // total SVG width in px; default 1280
|
|
105
|
+
/**
|
|
106
|
+
* BCP-47 tag controlling axis labels, the now-pill string, and the
|
|
107
|
+
* quarter prefix. Resolved by the caller (CLI flag → env vars). When
|
|
108
|
+
* undefined, layout falls back to the file's `nowline v1 locale:` and
|
|
109
|
+
* then to `en-US`. See `specs/localization.md`.
|
|
110
|
+
*/
|
|
111
|
+
locale?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type LayoutResult = PositionedRoadmap;
|
|
115
|
+
|
|
116
|
+
function statusFromProp(raw: string | undefined): StatusKind {
|
|
117
|
+
switch (raw) {
|
|
118
|
+
case 'done':
|
|
119
|
+
case 'completed':
|
|
120
|
+
return 'done';
|
|
121
|
+
case 'in-progress':
|
|
122
|
+
case 'active':
|
|
123
|
+
return 'in-progress';
|
|
124
|
+
case 'at-risk':
|
|
125
|
+
case 'blocked':
|
|
126
|
+
case 'planned':
|
|
127
|
+
return raw;
|
|
128
|
+
case undefined:
|
|
129
|
+
return 'planned';
|
|
130
|
+
default:
|
|
131
|
+
return 'neutral';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseProgressFraction(raw: string | undefined): number {
|
|
136
|
+
if (!raw) return 0;
|
|
137
|
+
const m = /^(\d{1,3})%$/.exec(raw);
|
|
138
|
+
if (!m) return 0;
|
|
139
|
+
return Math.max(0, Math.min(100, parseInt(m[1], 10))) / 100;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve a `size:NAME` value to its size declaration's effort literal, used
|
|
143
|
+
// by displays that want the literal duration string (not days). Returns the
|
|
144
|
+
// original string for raw literals (`1w`, `3d`) and undefined for missing
|
|
145
|
+
// values. m5 will adjust callers to apply capacity-aware derivation when the
|
|
146
|
+
// caller wants the calendar duration; this helper continues to expose the
|
|
147
|
+
// raw effort/duration literal for chips, captions, and tooltips.
|
|
148
|
+
function resolveDurationLiteral(
|
|
149
|
+
raw: string | undefined,
|
|
150
|
+
ctx: { sizes: Map<string, import('./types.js').ResolvedSize> },
|
|
151
|
+
): string | undefined {
|
|
152
|
+
if (!raw) return undefined;
|
|
153
|
+
if (/^\d+(?:\.\d+)?[dwmqy]$/.test(raw) || /^\d+%$/.test(raw)) return raw;
|
|
154
|
+
return ctx.sizes.get(raw)?.effortLiteral ?? raw;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Resolve a person/team id to its declared title when present (id otherwise).
|
|
158
|
+
function resolveActorDisplay(
|
|
159
|
+
raw: string | undefined,
|
|
160
|
+
ctx: {
|
|
161
|
+
teams: Map<string, import('@nowline/core').TeamDeclaration>;
|
|
162
|
+
persons: Map<string, import('@nowline/core').PersonDeclaration>;
|
|
163
|
+
},
|
|
164
|
+
): string | undefined {
|
|
165
|
+
if (!raw) return undefined;
|
|
166
|
+
return ctx.teams.get(raw)?.title ?? ctx.persons.get(raw)?.title ?? raw;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseLinkIcon(link: string | undefined): { icon: LinkIconKind; href?: string } {
|
|
170
|
+
if (!link) return { icon: 'none' };
|
|
171
|
+
const lower = link.toLowerCase();
|
|
172
|
+
if (lower.includes('linear.app')) return { icon: 'linear', href: link };
|
|
173
|
+
if (lower.includes('github.com')) return { icon: 'github', href: link };
|
|
174
|
+
if (lower.includes('atlassian.net') || lower.includes('jira.')) {
|
|
175
|
+
return { icon: 'jira', href: link };
|
|
176
|
+
}
|
|
177
|
+
return { icon: 'generic', href: link };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Bake a named/hex color from a label's `bg:` or `fg:` into the chip style.
|
|
181
|
+
function buildLabelChip(
|
|
182
|
+
label: LabelDeclaration,
|
|
183
|
+
ctx: StyleContext,
|
|
184
|
+
x: number,
|
|
185
|
+
y: number,
|
|
186
|
+
maxWidth?: number,
|
|
187
|
+
): PositionedLabelChip {
|
|
188
|
+
const style = resolveLabelChipStyle(label, ctx);
|
|
189
|
+
// Prefer the short name (id) when it exists — chips inside an item bar
|
|
190
|
+
// are tight; the long title risks overflowing.
|
|
191
|
+
const text = label.name ?? label.title ?? '';
|
|
192
|
+
const padKey = style.padding === 'none' ? 'xs' : style.padding;
|
|
193
|
+
const pad = PADDING_PX[padKey as keyof typeof PADDING_PX];
|
|
194
|
+
let width = Math.max(20, Math.round(text.length * 5.5 + pad * 2));
|
|
195
|
+
if (maxWidth !== undefined) width = Math.min(width, maxWidth);
|
|
196
|
+
return {
|
|
197
|
+
text,
|
|
198
|
+
style,
|
|
199
|
+
box: { x, y, width, height: LABEL_CHIP_HEIGHT_PX },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Sequence a set of nodes into a single horizontal track. `parallelInside`
|
|
204
|
+
// indicates the caller is inside a ParallelBlock and each child occupies a
|
|
205
|
+
// fresh sub-track (caller passes a new cursor per call).
|
|
206
|
+
function sequenceItem(
|
|
207
|
+
node: ItemDeclaration,
|
|
208
|
+
cursor: TrackCursor,
|
|
209
|
+
ctx: LayoutContext,
|
|
210
|
+
ownerOverride?: string,
|
|
211
|
+
): PositionedItem {
|
|
212
|
+
const props = node.properties;
|
|
213
|
+
const style = resolveStyle('item', props, ctx.styleCtx);
|
|
214
|
+
// Sizing precedence (specs/dsl.md § "Sizing precedence"):
|
|
215
|
+
// 1. `duration:LITERAL` wins as the calendar duration — `size:NAME`
|
|
216
|
+
// becomes a pure annotation (chip only).
|
|
217
|
+
// 2. Otherwise, `size:NAME` derives `effort ÷ capacity` (default
|
|
218
|
+
// capacity = 1).
|
|
219
|
+
// The validator requires one of the two on every item.
|
|
220
|
+
const sizeRef = propValue(props, 'size');
|
|
221
|
+
const sizeResolved = sizeRef ? (ctx.sizes.get(sizeRef) ?? null) : null;
|
|
222
|
+
const durationDays = deriveItemDurationDays(props, ctx.sizes, ctx.cal);
|
|
223
|
+
// Total work in single-engineer days, used below to normalize a literal
|
|
224
|
+
// `remaining:` value into a progress fraction. Stays per-engineer
|
|
225
|
+
// regardless of the item's `capacity:` so a `remaining:1w` always means
|
|
226
|
+
// "one engineer-week of work left".
|
|
227
|
+
const totalEffortDays = deriveTotalEffortDays(props, ctx.sizes, ctx.cal);
|
|
228
|
+
const afterRaw = propValues(props, 'after');
|
|
229
|
+
const beforeRaw = propValue(props, 'before');
|
|
230
|
+
const dateRaw = propValue(props, 'date');
|
|
231
|
+
const remainingDays = resolveDuration(propValue(props, 'remaining'), ctx.sizes, ctx.cal);
|
|
232
|
+
|
|
233
|
+
// Resolve start x: explicit date > after-chain > cursor position
|
|
234
|
+
let startX = cursor.x;
|
|
235
|
+
const explicitDate = parseDate(dateRaw);
|
|
236
|
+
if (explicitDate) {
|
|
237
|
+
const xd = ctx.scale.forwardWithinDomain(explicitDate);
|
|
238
|
+
if (xd !== null) startX = xd;
|
|
239
|
+
} else if (afterRaw.length > 0) {
|
|
240
|
+
let maxEnd = cursor.x;
|
|
241
|
+
for (const ref of afterRaw) {
|
|
242
|
+
const endX = ctx.entityRightEdges.get(ref);
|
|
243
|
+
if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
|
|
244
|
+
}
|
|
245
|
+
startX = Math.max(cursor.x, maxEnd);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const naturalWidth = Math.max(MIN_ITEM_WIDTH, durationDays * ctx.timeline.pixelsPerDay);
|
|
249
|
+
// Logical extent — what the item "owns" in time (used for chaining,
|
|
250
|
+
// `after:` lookups, dependency-arrow attach points).
|
|
251
|
+
const logicalLeft = startX;
|
|
252
|
+
const logicalRight = startX + naturalWidth;
|
|
253
|
+
|
|
254
|
+
const linkRaw = propValue(props, 'link');
|
|
255
|
+
const linkInfo = parseLinkIcon(linkRaw);
|
|
256
|
+
const hasLinkIcon = linkInfo.icon !== 'none';
|
|
257
|
+
|
|
258
|
+
// Pre-compute the chip row geometry — every chip renders at its
|
|
259
|
+
// NATURAL text-fit width on a single horizontal row, never
|
|
260
|
+
// truncated. We only need the total row width here so we can
|
|
261
|
+
// decide whether the row fits inside the bar; concrete chip
|
|
262
|
+
// (x, y) placement comes after ItemNode resolves the visible
|
|
263
|
+
// bar box below.
|
|
264
|
+
//
|
|
265
|
+
// Chips sit at the bar's bottom (just above the progress strip)
|
|
266
|
+
// and the link icon (when present) sits in the bar's UPPER-LEFT
|
|
267
|
+
// corner, so they no longer share a vertical band — chips have
|
|
268
|
+
// the full caption-inset-bounded inner width regardless of
|
|
269
|
+
// whether a link icon is rendered. The link-icon column's
|
|
270
|
+
// horizontal cost is borne by the caption (title/meta) inset
|
|
271
|
+
// instead, see `ItemNode`.
|
|
272
|
+
const visualWidthPredict = Math.max(MIN_ITEM_WIDTH, naturalWidth - 2 * ITEM_INSET_PX);
|
|
273
|
+
const labelIds = propValues(props, 'labels');
|
|
274
|
+
const chipSamples: { id: LabelDeclaration; width: number }[] = [];
|
|
275
|
+
for (const labelId of labelIds) {
|
|
276
|
+
const label = ctx.labels.get(labelId);
|
|
277
|
+
if (!label) continue;
|
|
278
|
+
const sample = buildLabelChip(label, ctx.styleCtx, 0, 0);
|
|
279
|
+
chipSamples.push({ id: label, width: sample.box.width });
|
|
280
|
+
}
|
|
281
|
+
let chipRowWidth = 0;
|
|
282
|
+
for (let i = 0; i < chipSamples.length; i += 1) {
|
|
283
|
+
if (i > 0) chipRowWidth += LABEL_CHIP_GAP_BETWEEN_PX;
|
|
284
|
+
chipRowWidth += chipSamples[i].width;
|
|
285
|
+
}
|
|
286
|
+
const chipInsideAvailWidth = Math.max(0, visualWidthPredict - 2 * ITEM_CAPTION_INSET_X_PX);
|
|
287
|
+
const chipsOutside = chipSamples.length > 0 && chipRowWidth > chipInsideAvailWidth;
|
|
288
|
+
|
|
289
|
+
// Handle `before:` — item must end by the named anchor/milestone x.
|
|
290
|
+
let hasOverflow = false;
|
|
291
|
+
let overflowBox: BoundingBox | undefined;
|
|
292
|
+
let overflowAnchorId: string | undefined;
|
|
293
|
+
if (beforeRaw) {
|
|
294
|
+
const beforeX = ctx.entityLeftEdges.get(beforeRaw);
|
|
295
|
+
if (beforeX !== undefined) {
|
|
296
|
+
if (logicalRight > beforeX) {
|
|
297
|
+
hasOverflow = true;
|
|
298
|
+
overflowBox = {
|
|
299
|
+
x: beforeX,
|
|
300
|
+
y: cursor.y,
|
|
301
|
+
width: logicalRight - beforeX,
|
|
302
|
+
height: ctx.bandScale.bandwidth(),
|
|
303
|
+
};
|
|
304
|
+
overflowAnchorId = beforeRaw;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Progress fraction
|
|
310
|
+
const statusRaw = propValue(props, 'status');
|
|
311
|
+
const status = statusFromProp(statusRaw);
|
|
312
|
+
let progress = parseProgressFraction(statusRaw);
|
|
313
|
+
if (progress === 0 && status === 'done') progress = 1;
|
|
314
|
+
const remainingPctMatch = /^(\d{1,3})%$/.exec(propValue(props, 'remaining') ?? '');
|
|
315
|
+
if (progress === 0 && status === 'in-progress' && remainingPctMatch) {
|
|
316
|
+
const pct = Math.max(0, Math.min(100, parseInt(remainingPctMatch[1], 10))) / 100;
|
|
317
|
+
progress = 1 - pct;
|
|
318
|
+
}
|
|
319
|
+
if (progress === 0 && status === 'in-progress' && remainingDays > 0 && totalEffortDays > 0) {
|
|
320
|
+
// `remaining:` literal is single-engineer days; `totalEffortDays`
|
|
321
|
+
// is also single-engineer days, so the ratio is unit-correct.
|
|
322
|
+
// Clamp to [0, 1] — the renderer paints 100% remaining when the
|
|
323
|
+
// author overshot, matching the spec's "warn-and-clamp" overflow
|
|
324
|
+
// behavior. (Validation defers the warn to layout-time today; a
|
|
325
|
+
// future diagnostics channel can surface it back to the user.)
|
|
326
|
+
progress = Math.max(0, Math.min(1, 1 - remainingDays / totalEffortDays));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Apply the status-tinted item background when the resolved bg is still
|
|
330
|
+
// theme-default. Authors who set explicit `bg:` keep their override.
|
|
331
|
+
// Per m2d handoff Resolution 3: layout owns this so the renderer stays
|
|
332
|
+
// palette-dumb.
|
|
333
|
+
const STATUS_TINT_LIGHT: Record<StatusKind, string> = {
|
|
334
|
+
done: '#ecfdf5',
|
|
335
|
+
'in-progress': '#eff6ff',
|
|
336
|
+
'at-risk': '#fffbeb',
|
|
337
|
+
blocked: '#fee2e2',
|
|
338
|
+
planned: '#f8fafc',
|
|
339
|
+
neutral: '#f8fafc',
|
|
340
|
+
};
|
|
341
|
+
const STATUS_TINT_DARK: Record<StatusKind, string> = {
|
|
342
|
+
done: '#052e16',
|
|
343
|
+
'in-progress': '#172554',
|
|
344
|
+
'at-risk': '#422006',
|
|
345
|
+
blocked: '#7f1d1d',
|
|
346
|
+
planned: '#1e293b',
|
|
347
|
+
neutral: '#1e293b',
|
|
348
|
+
};
|
|
349
|
+
const STATUS_BORDER: Record<StatusKind, string> = {
|
|
350
|
+
done: ctx.styleCtx.theme.status.done,
|
|
351
|
+
'in-progress': ctx.styleCtx.theme.status.inProgress,
|
|
352
|
+
'at-risk': ctx.styleCtx.theme.status.atRisk,
|
|
353
|
+
blocked: ctx.styleCtx.theme.status.blocked,
|
|
354
|
+
planned: ctx.styleCtx.theme.status.planned,
|
|
355
|
+
neutral: ctx.styleCtx.theme.status.neutral,
|
|
356
|
+
};
|
|
357
|
+
const isLight = ctx.styleCtx.theme.name === 'light';
|
|
358
|
+
const themeDefaultBg = isLight ? '#ffffff' : '#0f172a';
|
|
359
|
+
const themeDefaultFg = '#94a3b8';
|
|
360
|
+
if (style.bg === themeDefaultBg) {
|
|
361
|
+
style.bg = isLight ? STATUS_TINT_LIGHT[status] : STATUS_TINT_DARK[status];
|
|
362
|
+
}
|
|
363
|
+
if (style.fg === themeDefaultFg) {
|
|
364
|
+
style.fg = STATUS_BORDER[status];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Pre-format the secondary line shown inside the item bar.
|
|
368
|
+
//
|
|
369
|
+
// Driver-only meta line (`rendering.md` § Item size chip): exactly one
|
|
370
|
+
// leading token — the explicit non-empty `duration:LITERAL` when set,
|
|
371
|
+
// otherwise the size chip when `size:` drives. Never both; bar width
|
|
372
|
+
// already encodes derived calendar span for sized items.
|
|
373
|
+
const explicitDurationLiteral = propValue(props, 'duration');
|
|
374
|
+
const durationDrives =
|
|
375
|
+
!!explicitDurationLiteral && /^\d+(?:\.\d+)?[dwmqy]$/.test(explicitDurationLiteral);
|
|
376
|
+
const sizeChipText = sizeResolved ? (sizeResolved.title ?? sizeResolved.name) : '';
|
|
377
|
+
const driverToken: string | undefined = durationDrives
|
|
378
|
+
? explicitDurationLiteral
|
|
379
|
+
: sizeChipText || undefined;
|
|
380
|
+
const remainingRaw = propValue(props, 'remaining');
|
|
381
|
+
const remainingLiteral = resolveDurationLiteral(remainingRaw, ctx);
|
|
382
|
+
const ownerDisplay = resolveActorDisplay(ownerOverride ?? propValue(props, 'owner'), ctx);
|
|
383
|
+
const metaHead = (): string => [driverToken, ownerDisplay].filter(Boolean).join(' ');
|
|
384
|
+
let metaText: string | undefined;
|
|
385
|
+
if (status === 'in-progress' && remainingLiteral) {
|
|
386
|
+
const head = metaHead();
|
|
387
|
+
metaText = head
|
|
388
|
+
? `${head} — ${remainingLiteral} remaining`
|
|
389
|
+
: `${remainingLiteral} remaining`;
|
|
390
|
+
} else if (status === 'in-progress' && progress > 0 && progress < 1) {
|
|
391
|
+
const pct = Math.round((1 - progress) * 100);
|
|
392
|
+
const head = metaHead();
|
|
393
|
+
metaText = head ? `${head} — ${pct}% remaining` : `${pct}% remaining`;
|
|
394
|
+
} else if (ownerDisplay || driverToken) {
|
|
395
|
+
metaText = metaHead() || undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Capacity suffix — appended after metaText at render time. Layout's
|
|
399
|
+
// job here is to (a) parse the value out of `capacity:`, (b) format
|
|
400
|
+
// the display number, (c) resolve `capacity-icon` to either a
|
|
401
|
+
// built-in name or a literal string the renderer can paint directly,
|
|
402
|
+
// and (d) feed the suffix's estimated width into ItemNode so spill
|
|
403
|
+
// detection accounts for `2w 5×` rather than just `2w`. The suffix
|
|
404
|
+
// disappears entirely when capacity is missing or non-positive.
|
|
405
|
+
const capacityRaw = propValue(props, 'capacity');
|
|
406
|
+
const capacityValue = parseCapacityValue(capacityRaw);
|
|
407
|
+
let capacity: PositionedCapacity | null = null;
|
|
408
|
+
let capacityTrailingWidth = 0;
|
|
409
|
+
if (capacityValue !== null) {
|
|
410
|
+
const capacityText = formatCapacityNumber(capacityValue);
|
|
411
|
+
const capacityIcon = resolveCapacityIcon(style.capacityIcon, ctx.symbols);
|
|
412
|
+
capacity = { value: capacityValue, text: capacityText, icon: capacityIcon };
|
|
413
|
+
const META_FONT_SIZE_PX_LOCAL = 11;
|
|
414
|
+
// Add a small leading separator (a single space's worth) only when
|
|
415
|
+
// the suffix sits next to existing meta text, so `m 5×` has air
|
|
416
|
+
// between the driver token and the count. Standalone suffix needs no
|
|
417
|
+
// leading separator.
|
|
418
|
+
const separatorWidth = metaText ? estimateTextWidth(' ', META_FONT_SIZE_PX_LOCAL) : 0;
|
|
419
|
+
capacityTrailingWidth =
|
|
420
|
+
separatorWidth +
|
|
421
|
+
estimateCapacitySuffixWidth(capacityText, capacityIcon, META_FONT_SIZE_PX_LOCAL);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Visual bar + caption-spill decision delegated to ItemNode. Logical
|
|
425
|
+
// extent (used by chaining and `after:` lookups) stays on
|
|
426
|
+
// logicalLeft/logicalRight; ItemNode computes the inset visual box and
|
|
427
|
+
// whether the title+meta line overflows the bar's inner padded width.
|
|
428
|
+
const titleStr = node.title ?? node.name ?? '';
|
|
429
|
+
const placed = new ItemNode({
|
|
430
|
+
id: node.name ?? '',
|
|
431
|
+
title: titleStr,
|
|
432
|
+
logicalLeftX: logicalLeft,
|
|
433
|
+
logicalRightX: logicalRight,
|
|
434
|
+
metaText,
|
|
435
|
+
metaTrailingWidth: capacityTrailingWidth,
|
|
436
|
+
hasLinkIcon,
|
|
437
|
+
}).place({ x: logicalLeft, y: cursor.y }, { time: ctx.scale, bands: ctx.bandScale, style });
|
|
438
|
+
const itemBox = placed.box;
|
|
439
|
+
const bandwidth = ctx.bandScale.bandwidth();
|
|
440
|
+
|
|
441
|
+
// Narrow-bar decoration spill — when a bar is too narrow to host
|
|
442
|
+
// the dot, link icon, or footnote with its full inset, those
|
|
443
|
+
// glyphs render in the same spill column as the (already-
|
|
444
|
+
// spilling) caption text, in reading order
|
|
445
|
+
// `[bar][dot][icon][title][footnote#…][meta]`. Each decoration's
|
|
446
|
+
// threshold is independent (a 20-px-wide bar can host the dot
|
|
447
|
+
// but not the icon, etc.); see the `MIN_BAR_WIDTH_FOR_*`
|
|
448
|
+
// constants in `item-bar-geometry`.
|
|
449
|
+
//
|
|
450
|
+
// Forcing `textSpills` when `iconSpills` keeps the icon and
|
|
451
|
+
// title visually adjacent — otherwise a spilled icon would
|
|
452
|
+
// float at `bar.right + 6` while the title stayed inside the
|
|
453
|
+
// bar, breaking the icon→title affordance.
|
|
454
|
+
const dotSpills = itemBox.width < MIN_BAR_WIDTH_FOR_DOT_PX;
|
|
455
|
+
const iconSpills = hasLinkIcon && itemBox.width < MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX;
|
|
456
|
+
const footnoteSpillsForNarrow = itemBox.width < MIN_BAR_WIDTH_FOR_FOOTNOTE_PX;
|
|
457
|
+
const textSpills = placed.textSpills || iconSpills;
|
|
458
|
+
|
|
459
|
+
// Label chips lay out left → right at natural text width.
|
|
460
|
+
//
|
|
461
|
+
// INSIDE the bar (chipsOutside === false): single row,
|
|
462
|
+
// left-aligned at the caption inset, anchored just above the
|
|
463
|
+
// bottom progress strip.
|
|
464
|
+
//
|
|
465
|
+
// OUTSIDE the bar (chipsOutside === true): the whole chip set
|
|
466
|
+
// moves to the spill column at `bar.right + 6`. Within the
|
|
467
|
+
// column, chips pack into rows capped at the bar's visual
|
|
468
|
+
// width — see `packSpillChips`. Row 0 sits at the same y the
|
|
469
|
+
// single-row would have used; subsequent rows stack DOWNWARD by
|
|
470
|
+
// one `LABEL_CHIP_ROW_STEP_PX`.
|
|
471
|
+
//
|
|
472
|
+
// When chips spill, the BAR ITSELF GROWS DOWNWARD so the chip
|
|
473
|
+
// column reads as enclosed by the bar — the painted footprint
|
|
474
|
+
// of the bar is `bandwidth + chipBarExtra` and the bottom
|
|
475
|
+
// progress strip moves with the new bottom edge. Chip Y is
|
|
476
|
+
// anchored to the ORIGINAL bandwidth (relative to the bar's
|
|
477
|
+
// top), not to the grown box.height, so row 0 stays where a
|
|
478
|
+
// single-row chip would naturally render and rows 1..N grow
|
|
479
|
+
// downward into the new bar area.
|
|
480
|
+
//
|
|
481
|
+
// When the caption ALSO spills (`textSpills && chipsOutside`),
|
|
482
|
+
// row 0's y drops below the meta baseline so the spilled stack
|
|
483
|
+
// reads `title → meta → chip-row-0 → chip-row-1 → ...` at a
|
|
484
|
+
// single column inside the (now-taller) bar.
|
|
485
|
+
let chipPack: ReturnType<typeof packSpillChips<LabelDeclaration>> | null = null;
|
|
486
|
+
if (chipsOutside) {
|
|
487
|
+
chipPack = packSpillChips(chipSamples, itemBox.width);
|
|
488
|
+
}
|
|
489
|
+
const chipRowCount = chipPack ? chipPack.rows.length : chipSamples.length > 0 ? 1 : 0;
|
|
490
|
+
// Capacity suffix renders on the same line as metaText. Treat the meta
|
|
491
|
+
// line as present whenever EITHER metaText OR a capacity suffix will
|
|
492
|
+
// paint, so chip-row pitch reserves the right amount of vertical space.
|
|
493
|
+
const hasMeta = metaText !== undefined || capacity !== null;
|
|
494
|
+
const chipBarExtra = computeChipBarExtra(
|
|
495
|
+
chipsOutside,
|
|
496
|
+
textSpills,
|
|
497
|
+
chipRowCount,
|
|
498
|
+
bandwidth,
|
|
499
|
+
hasMeta,
|
|
500
|
+
);
|
|
501
|
+
if (chipBarExtra > 0) {
|
|
502
|
+
itemBox.height = bandwidth + chipBarExtra;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const labelChips: PositionedLabelChip[] = [];
|
|
506
|
+
const baseChipY =
|
|
507
|
+
itemBox.y +
|
|
508
|
+
bandwidth -
|
|
509
|
+
PROGRESS_STRIP_HEIGHT_PX -
|
|
510
|
+
LABEL_CHIP_HEIGHT_PX -
|
|
511
|
+
LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
|
|
512
|
+
const captionStackChipY =
|
|
513
|
+
itemBox.y + ITEM_CAPTION_META_BASELINE_OFFSET_PX + LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
|
|
514
|
+
// Inside-bar chips with meta need to clear the meta baseline —
|
|
515
|
+
// the natural `baseChipY` (anchored to bar bottom) sits above
|
|
516
|
+
// the meta line at typical bandwidths, so the chip rect would
|
|
517
|
+
// overlap the meta text vertically. Use whichever Y is lower.
|
|
518
|
+
// Outside-bar chips already reuse `captionStackChipY` when the
|
|
519
|
+
// caption ALSO spills; with caption inside we don't need to
|
|
520
|
+
// shift them since they're horizontally separated from the meta.
|
|
521
|
+
const stackBelowMeta =
|
|
522
|
+
(chipsOutside && textSpills) || (!chipsOutside && hasMeta && chipSamples.length > 0);
|
|
523
|
+
const chipRow0Y = stackBelowMeta ? Math.max(baseChipY, captionStackChipY) : baseChipY;
|
|
524
|
+
const chipStartX = chipsOutside
|
|
525
|
+
? itemBox.x + itemBox.width + ITEM_CAPTION_SPILL_GAP_PX
|
|
526
|
+
: itemBox.x + ITEM_CAPTION_INSET_X_PX;
|
|
527
|
+
|
|
528
|
+
let chipsRightX = chipStartX;
|
|
529
|
+
if (chipPack) {
|
|
530
|
+
for (let r = 0; r < chipPack.rows.length; r += 1) {
|
|
531
|
+
const rowY = chipRow0Y + r * LABEL_CHIP_ROW_STEP_PX;
|
|
532
|
+
let rowCursorX = chipStartX;
|
|
533
|
+
for (const sample of chipPack.rows[r]) {
|
|
534
|
+
const chip = buildLabelChip(sample.id, ctx.styleCtx, rowCursorX, rowY);
|
|
535
|
+
labelChips.push(chip);
|
|
536
|
+
rowCursorX += chip.box.width + LABEL_CHIP_GAP_BETWEEN_PX;
|
|
537
|
+
}
|
|
538
|
+
const rowRight = rowCursorX - LABEL_CHIP_GAP_BETWEEN_PX;
|
|
539
|
+
if (rowRight > chipsRightX) chipsRightX = rowRight;
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
let rowCursorX = chipStartX;
|
|
543
|
+
for (const sample of chipSamples) {
|
|
544
|
+
const chip = buildLabelChip(sample.id, ctx.styleCtx, rowCursorX, chipRow0Y);
|
|
545
|
+
labelChips.push(chip);
|
|
546
|
+
rowCursorX += chip.box.width + LABEL_CHIP_GAP_BETWEEN_PX;
|
|
547
|
+
}
|
|
548
|
+
chipsRightX = chipSamples.length > 0 ? rowCursorX - LABEL_CHIP_GAP_BETWEEN_PX : chipStartX;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Footnote superscript indicators. Per `specs/dsl.md`, footnotes
|
|
552
|
+
// attach via the `on:` property on the footnote declaration only —
|
|
553
|
+
// there is no forward `footnote:` property on the host entity. Walk
|
|
554
|
+
// `footnoteHosts` (built from each footnote's `on:` list) and emit
|
|
555
|
+
// a superscript for every footnote that names this item.
|
|
556
|
+
const footnoteIndicatorSet = new Set<number>();
|
|
557
|
+
if (node.name) {
|
|
558
|
+
for (const [fid, hosts] of ctx.footnoteHosts.entries()) {
|
|
559
|
+
if (hosts.includes(node.name)) {
|
|
560
|
+
const n = ctx.footnoteIndex.get(fid);
|
|
561
|
+
if (n !== undefined) footnoteIndicatorSet.add(n);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const footnoteIndicators = [...footnoteIndicatorSet].sort((a, b) => a - b);
|
|
566
|
+
|
|
567
|
+
const owner = ownerDisplay ?? ownerOverride ?? propValue(props, 'owner');
|
|
568
|
+
const description = node.description?.text;
|
|
569
|
+
|
|
570
|
+
// Footnote glyphs only need to spill when there's at least one
|
|
571
|
+
// indicator AND the bar is too narrow to host them at the inset-
|
|
572
|
+
// right anchor. Compute the final boolean here once we know the
|
|
573
|
+
// indicator count.
|
|
574
|
+
const footnoteSpills = footnoteIndicators.length > 0 && footnoteSpillsForNarrow;
|
|
575
|
+
|
|
576
|
+
// Spill-column x positions for the decorations. The cluster
|
|
577
|
+
// mirrors the in-bar reading order so users see the same visual
|
|
578
|
+
// hierarchy whether everything fits inside or trails off to the
|
|
579
|
+
// right:
|
|
580
|
+
//
|
|
581
|
+
// In-bar (default): [icon] [title] [¹²] [dot]
|
|
582
|
+
// Spilled (narrow): [bar] [icon?] [title][¹²?] [dot?]
|
|
583
|
+
//
|
|
584
|
+
// The dot lives at the trailing edge in BOTH cases — pushing it
|
|
585
|
+
// to the LEFT of the title (with the title trailing it) read as
|
|
586
|
+
// the dot belonging to the next item, not this one. A missing
|
|
587
|
+
// decoration just collapses out of the row; e.g. an item with
|
|
588
|
+
// no link AND a too-narrow bar gives `[bar] [title] [dot]`.
|
|
589
|
+
//
|
|
590
|
+
// `decorationsRightX` is the furthest right edge any spilled
|
|
591
|
+
// glyph reaches; the row-packer uses it (alongside spilled-chip
|
|
592
|
+
// width) to reserve x-extent so the next chained item bumps to
|
|
593
|
+
// a fresh row instead of landing under the spilled cluster.
|
|
594
|
+
const SPILL_COLUMN_X0 = itemBox.x + itemBox.width + ITEM_CAPTION_SPILL_GAP_PX;
|
|
595
|
+
let spillCursor = SPILL_COLUMN_X0;
|
|
596
|
+
// Advance the cursor by `gap` IFF something has already been
|
|
597
|
+
// placed in the column — keeps the cluster from leaving a
|
|
598
|
+
// dangling gap past its final glyph (which would over-reserve
|
|
599
|
+
// x-extent and shift downstream items).
|
|
600
|
+
let needGap = false;
|
|
601
|
+
let iconSpillX: number | null = null;
|
|
602
|
+
if (iconSpills) {
|
|
603
|
+
if (needGap) spillCursor += ITEM_DECORATION_SPILL_GAP_PX;
|
|
604
|
+
iconSpillX = spillCursor;
|
|
605
|
+
spillCursor = iconSpillX + ITEM_LINK_ICON_TILE_SIZE_PX;
|
|
606
|
+
needGap = true;
|
|
607
|
+
}
|
|
608
|
+
let captionSpillWidth = 0;
|
|
609
|
+
if (textSpills) {
|
|
610
|
+
if (needGap) spillCursor += ITEM_DECORATION_SPILL_GAP_PX;
|
|
611
|
+
const titleW = estimateTextWidth(titleStr, ITEM_CAPTION_TITLE_FONT_SIZE_PX);
|
|
612
|
+
// Spill column width is the wider of the title and the *full* meta
|
|
613
|
+
// line (text + capacity suffix). `capacityTrailingWidth` is 0 when
|
|
614
|
+
// no capacity suffix is rendered, so this stays a no-op for items
|
|
615
|
+
// without `capacity:`.
|
|
616
|
+
const metaW = (metaText ? estimateTextWidth(metaText, 11) : 0) + capacityTrailingWidth;
|
|
617
|
+
captionSpillWidth = Math.max(titleW, metaW);
|
|
618
|
+
spillCursor += captionSpillWidth;
|
|
619
|
+
needGap = true;
|
|
620
|
+
}
|
|
621
|
+
let footnoteSpillStartX: number | null = null;
|
|
622
|
+
if (footnoteSpills) {
|
|
623
|
+
if (needGap) spillCursor += ITEM_DECORATION_SPILL_GAP_PX;
|
|
624
|
+
footnoteSpillStartX = spillCursor;
|
|
625
|
+
spillCursor += footnoteIndicators.length * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
626
|
+
needGap = true;
|
|
627
|
+
}
|
|
628
|
+
let dotSpillCx: number | null = null;
|
|
629
|
+
if (dotSpills) {
|
|
630
|
+
if (needGap) spillCursor += ITEM_DECORATION_SPILL_GAP_PX;
|
|
631
|
+
dotSpillCx = spillCursor + ITEM_STATUS_DOT_RADIUS_PX;
|
|
632
|
+
spillCursor = dotSpillCx + ITEM_STATUS_DOT_RADIUS_PX;
|
|
633
|
+
needGap = true;
|
|
634
|
+
}
|
|
635
|
+
const decorationsRightX = Math.max(itemBox.x + itemBox.width, spillCursor);
|
|
636
|
+
|
|
637
|
+
const id = node.name;
|
|
638
|
+
if (id) {
|
|
639
|
+
// Entity edges live in LOGICAL space so chained items / `after:`
|
|
640
|
+
// references / dependency-arrow attach points sit on the column
|
|
641
|
+
// boundary, not on the visually inset bar edge. The visible 12 px
|
|
642
|
+
// gutter between bars then becomes a clean attach corridor.
|
|
643
|
+
ctx.entityLeftEdges.set(id, logicalLeft);
|
|
644
|
+
ctx.entityRightEdges.set(id, logicalRight);
|
|
645
|
+
ctx.entityMidpoints.set(id, {
|
|
646
|
+
x: (logicalLeft + logicalRight) / 2,
|
|
647
|
+
y: itemBox.y + itemBox.height / 2,
|
|
648
|
+
});
|
|
649
|
+
// Visual edges — where dependency arrows actually attach. These
|
|
650
|
+
// sit ITEM_INSET_PX inside the column boundaries so the arrows
|
|
651
|
+
// emerge from the painted bar edge instead of the inter-column
|
|
652
|
+
// gutter. See LayoutContext.entityVisualLeftX/RightX.
|
|
653
|
+
ctx.entityVisualLeftX.set(id, itemBox.x);
|
|
654
|
+
ctx.entityVisualRightX.set(id, itemBox.x + itemBox.width);
|
|
655
|
+
// Dependency-arrow source point. Default = the bar's right
|
|
656
|
+
// edge at row midpoint. When the caption spills past the
|
|
657
|
+
// bar's right edge (`textSpills`), the spilled title /
|
|
658
|
+
// meta occupy the area immediately right of the bar at
|
|
659
|
+
// row midline. Keep X on the bar's right edge so the
|
|
660
|
+
// arrow visually leaves the bar's side, but drop Y to the
|
|
661
|
+
// vertical center of the bottom progress strip so the
|
|
662
|
+
// arrow runs UNDERNEATH the spilled text rather than
|
|
663
|
+
// through it. Mirrors the slack-arrow attach below.
|
|
664
|
+
const arrowSource: Point = textSpills
|
|
665
|
+
? {
|
|
666
|
+
x: itemBox.x + itemBox.width,
|
|
667
|
+
y: itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2,
|
|
668
|
+
}
|
|
669
|
+
: {
|
|
670
|
+
x: itemBox.x + itemBox.width,
|
|
671
|
+
y: itemBox.y + itemBox.height / 2,
|
|
672
|
+
};
|
|
673
|
+
ctx.itemArrowSource.set(id, arrowSource);
|
|
674
|
+
ctx.itemFlowKey.set(id, ctx.currentFlowKey);
|
|
675
|
+
// Slack-arrow attach Y. Defaults to the bar's row midpoint; when
|
|
676
|
+
// the caption spills past the bar's right edge, drop to the
|
|
677
|
+
// progress-strip's vertical center so the arrow aligns with the
|
|
678
|
+
// bottom-edge progress bar instead of running through the
|
|
679
|
+
// adjacent title/meta text. The `/ 2` keeps the attach point on
|
|
680
|
+
// the strip's vertical center if `PROGRESS_STRIP_HEIGHT_PX` is
|
|
681
|
+
// ever bumped.
|
|
682
|
+
const slackAttachY = textSpills
|
|
683
|
+
? itemBox.y + itemBox.height - PROGRESS_STRIP_HEIGHT_PX / 2
|
|
684
|
+
: itemBox.y + itemBox.height / 2;
|
|
685
|
+
ctx.itemSlackAttachY.set(id, slackAttachY);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
cursor.x = logicalRight;
|
|
689
|
+
cursor.maxX = Math.max(cursor.maxX, cursor.x);
|
|
690
|
+
// The next row in a parallel/group/lane starts at
|
|
691
|
+
// `cursor.y + cursor.height`. Default pitch is `bandScale.step()`
|
|
692
|
+
// (bandwidth + inter-row gap). When the bar grew to enclose a
|
|
693
|
+
// spilled chip column, the pitch grows by the SAME amount so the
|
|
694
|
+
// inter-row gap stays constant — the next row's bar starts
|
|
695
|
+
// `step − bandwidth` px below the (now-taller) bar bottom.
|
|
696
|
+
cursor.height = Math.max(cursor.height, ctx.bandScale.step() + chipBarExtra);
|
|
697
|
+
|
|
698
|
+
const result: PositionedItem = {
|
|
699
|
+
kind: 'item',
|
|
700
|
+
id,
|
|
701
|
+
title: titleStr,
|
|
702
|
+
box: itemBox,
|
|
703
|
+
status,
|
|
704
|
+
progressFraction: progress,
|
|
705
|
+
footnoteIndicators,
|
|
706
|
+
labelChips,
|
|
707
|
+
chipsOutside,
|
|
708
|
+
chipsRightX,
|
|
709
|
+
linkIcon: linkInfo.icon,
|
|
710
|
+
linkHref: linkInfo.href,
|
|
711
|
+
hasOverflow,
|
|
712
|
+
overflowBox,
|
|
713
|
+
overflowAnchorId,
|
|
714
|
+
owner,
|
|
715
|
+
description,
|
|
716
|
+
metaText,
|
|
717
|
+
textSpills,
|
|
718
|
+
dotSpills,
|
|
719
|
+
iconSpills,
|
|
720
|
+
footnoteSpills,
|
|
721
|
+
dotSpillCx,
|
|
722
|
+
iconSpillX,
|
|
723
|
+
footnoteSpillStartX,
|
|
724
|
+
decorationsRightX,
|
|
725
|
+
capacity,
|
|
726
|
+
size: sizeResolved,
|
|
727
|
+
style,
|
|
728
|
+
};
|
|
729
|
+
return result;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function sequenceParallel(
|
|
733
|
+
node: ParallelBlock,
|
|
734
|
+
cursor: TrackCursor,
|
|
735
|
+
ctx: LayoutContext,
|
|
736
|
+
): PositionedParallel {
|
|
737
|
+
return new ParallelNode(node, { sequenceOne, newCursor }).place(cursor, ctx);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function sequenceGroup(node: GroupBlock, cursor: TrackCursor, ctx: LayoutContext): PositionedGroup {
|
|
741
|
+
return new GroupNode(node, {
|
|
742
|
+
sequenceItem,
|
|
743
|
+
sequenceOne,
|
|
744
|
+
resolveChildStart,
|
|
745
|
+
newCursor,
|
|
746
|
+
estimateTextWidth,
|
|
747
|
+
predictItemChipExtraHeight,
|
|
748
|
+
}).place(cursor, ctx);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function sequenceOne(
|
|
752
|
+
node: ItemDeclaration | GroupBlock | ParallelBlock,
|
|
753
|
+
cursor: TrackCursor,
|
|
754
|
+
ctx: LayoutContext,
|
|
755
|
+
): PositionedTrackChild {
|
|
756
|
+
if (isItemDeclaration(node)) return sequenceItem(node, cursor, ctx);
|
|
757
|
+
if (isParallelBlock(node)) return sequenceParallel(node, cursor, ctx);
|
|
758
|
+
if (isGroupBlock(node)) return sequenceGroup(node, cursor, ctx);
|
|
759
|
+
throw new Error(
|
|
760
|
+
`Unknown swimlane child type: ${(node as { $type?: string }).$type ?? 'unknown'}`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Rough px-width estimate for sans-serif text. Intentionally pessimistic
|
|
765
|
+
// (uses ~0.58 em per char) so we err toward "doesn't fit" and trigger a
|
|
766
|
+
// row bump rather than draw an item with a clipped title.
|
|
767
|
+
function estimateTextWidth(text: string, fontSize: number): number {
|
|
768
|
+
return text.length * fontSize * 0.58;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Compute the extra vertical px the bar grows when its spilled chip
|
|
773
|
+
* column would otherwise extend below the (single-row) bottom. The
|
|
774
|
+
* bar's painted footprint becomes `bandwidth + chipBarExtra`, the
|
|
775
|
+
* progress strip rides the new bottom, and chip rows pack inside
|
|
776
|
+
* the taller bar (anchored from the bar TOP so row 0 doesn't shift
|
|
777
|
+
* when the bar grows).
|
|
778
|
+
*
|
|
779
|
+
* Returns 0 when chips fit inside the bar, when there are no chips,
|
|
780
|
+
* or when the spilled column happens to fit inside `bandwidth` (a
|
|
781
|
+
* single row with the caption inside, for instance).
|
|
782
|
+
*
|
|
783
|
+
* The same number is the row-pitch increase the swimlane / group
|
|
784
|
+
* row-packer needs to reserve so the next row clears the taller
|
|
785
|
+
* bar — `cursor.height = step + chipBarExtra` and the predict
|
|
786
|
+
* helper returns this verbatim.
|
|
787
|
+
*/
|
|
788
|
+
function computeChipBarExtra(
|
|
789
|
+
chipsOutside: boolean,
|
|
790
|
+
captionSpills: boolean,
|
|
791
|
+
chipRowCount: number,
|
|
792
|
+
bandwidth: number,
|
|
793
|
+
hasMeta: boolean,
|
|
794
|
+
): number {
|
|
795
|
+
if (chipRowCount === 0) return 0;
|
|
796
|
+
// Row 0 anchor relative to the bar's TOP — three regimes:
|
|
797
|
+
//
|
|
798
|
+
// 1. chipsOutside + captionSpills → chips stack below the
|
|
799
|
+
// spilled meta line (`captionStackTop`).
|
|
800
|
+
// 2. chips INSIDE the bar AND meta is present → chip top must
|
|
801
|
+
// clear the meta baseline; the natural `baseTop` sits
|
|
802
|
+
// ABOVE the meta line at default bandwidth (=56), so we
|
|
803
|
+
// take whichever is lower of base/captionStack.
|
|
804
|
+
// 3. otherwise (in-bar w/o meta, or chipsOutside w/o caption
|
|
805
|
+
// spill) → row 0 hugs the bar bottom at `baseTop`.
|
|
806
|
+
//
|
|
807
|
+
// Cases (2) and (3-with-multi-row-spill) can both grow the bar;
|
|
808
|
+
// case (3-with-single-row-inside-no-meta) never grows.
|
|
809
|
+
const baseTop =
|
|
810
|
+
bandwidth -
|
|
811
|
+
PROGRESS_STRIP_HEIGHT_PX -
|
|
812
|
+
LABEL_CHIP_HEIGHT_PX -
|
|
813
|
+
LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
|
|
814
|
+
const captionStackTop =
|
|
815
|
+
ITEM_CAPTION_META_BASELINE_OFFSET_PX + LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX;
|
|
816
|
+
let chipRow0Top: number;
|
|
817
|
+
if (chipsOutside && captionSpills) {
|
|
818
|
+
chipRow0Top = captionStackTop;
|
|
819
|
+
} else if (!chipsOutside && hasMeta) {
|
|
820
|
+
chipRow0Top = Math.max(baseTop, captionStackTop);
|
|
821
|
+
} else {
|
|
822
|
+
chipRow0Top = baseTop;
|
|
823
|
+
}
|
|
824
|
+
const lastRowBottomTop =
|
|
825
|
+
chipRow0Top + (chipRowCount - 1) * LABEL_CHIP_ROW_STEP_PX + LABEL_CHIP_HEIGHT_PX;
|
|
826
|
+
// The bar must be tall enough to fit `lastRowBottomTop` plus a
|
|
827
|
+
// GAP above the progress strip plus the progress strip itself.
|
|
828
|
+
const requiredHeight =
|
|
829
|
+
lastRowBottomTop + LABEL_CHIP_GAP_ABOVE_PROGRESS_STRIP_PX + PROGRESS_STRIP_HEIGHT_PX;
|
|
830
|
+
return Math.max(0, requiredHeight - bandwidth);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Predict an item's bar growth (and therefore row-pitch growth) for
|
|
835
|
+
* a multi-row spilled chip column BEFORE the bar is sequenced. Used
|
|
836
|
+
* by the swimlane / group row-packer so neighboring rows on later
|
|
837
|
+
* rows are positioned correctly without a retroactive shift.
|
|
838
|
+
*
|
|
839
|
+
* Mirrors the chip-pack + caption-spill arithmetic in
|
|
840
|
+
* `sequenceItem` so prediction and placement agree byte-for-byte.
|
|
841
|
+
*/
|
|
842
|
+
function predictItemChipExtraHeight(item: ItemDeclaration, ctx: LayoutContext): number {
|
|
843
|
+
const props = item.properties;
|
|
844
|
+
const labelIds = propValues(props, 'labels');
|
|
845
|
+
if (labelIds.length === 0) return 0;
|
|
846
|
+
const durationDays = deriveItemDurationDays(props, ctx.sizes, ctx.cal);
|
|
847
|
+
const naturalWidth = Math.max(MIN_ITEM_WIDTH, durationDays * ctx.timeline.pixelsPerDay);
|
|
848
|
+
const visualWidth = Math.max(MIN_ITEM_WIDTH, naturalWidth - 2 * ITEM_INSET_PX);
|
|
849
|
+
const samples: { id: LabelDeclaration; width: number }[] = [];
|
|
850
|
+
for (const labelId of labelIds) {
|
|
851
|
+
const label = ctx.labels.get(labelId);
|
|
852
|
+
if (!label) continue;
|
|
853
|
+
const sample = buildLabelChip(label, ctx.styleCtx, 0, 0);
|
|
854
|
+
samples.push({ id: label, width: sample.box.width });
|
|
855
|
+
}
|
|
856
|
+
if (samples.length === 0) return 0;
|
|
857
|
+
let chipRowWidth = 0;
|
|
858
|
+
for (let i = 0; i < samples.length; i += 1) {
|
|
859
|
+
if (i > 0) chipRowWidth += LABEL_CHIP_GAP_BETWEEN_PX;
|
|
860
|
+
chipRowWidth += samples[i].width;
|
|
861
|
+
}
|
|
862
|
+
const insideAvail = Math.max(0, visualWidth - 2 * ITEM_CAPTION_INSET_X_PX);
|
|
863
|
+
const chipsOutside = chipRowWidth > insideAvail;
|
|
864
|
+
|
|
865
|
+
// `hasMeta` mirrors `metaText !== undefined` in `sequenceItem`,
|
|
866
|
+
// plus the capacity suffix (which renders on the same meta line).
|
|
867
|
+
// metaText is set whenever an item declares a duration, owner,
|
|
868
|
+
// or remaining — so we just check those four props. Status
|
|
869
|
+
// strings (in-progress) only matter when paired with one of
|
|
870
|
+
// these, so this is an upper bound (false-positives still grow
|
|
871
|
+
// the bar by exactly the same amount as the renderer would, so
|
|
872
|
+
// they stay byte-stable).
|
|
873
|
+
const hasMeta =
|
|
874
|
+
propValue(props, 'duration') !== undefined ||
|
|
875
|
+
propValue(props, 'size') !== undefined ||
|
|
876
|
+
propValue(props, 'owner') !== undefined ||
|
|
877
|
+
propValue(props, 'remaining') !== undefined ||
|
|
878
|
+
propValue(props, 'capacity') !== undefined;
|
|
879
|
+
|
|
880
|
+
const titleStr = item.title ?? item.name ?? '';
|
|
881
|
+
const titleW = titleStr ? estimateTextWidth(titleStr, ITEM_CAPTION_TITLE_FONT_SIZE_PX) : 0;
|
|
882
|
+
const captionSpills = titleW > insideAvail;
|
|
883
|
+
const pack = chipsOutside ? packSpillChips(samples, visualWidth) : null;
|
|
884
|
+
const chipRowCount = pack ? pack.rows.length : samples.length > 0 ? 1 : 0;
|
|
885
|
+
return computeChipBarExtra(
|
|
886
|
+
chipsOutside,
|
|
887
|
+
captionSpills,
|
|
888
|
+
chipRowCount,
|
|
889
|
+
ctx.bandScale.bandwidth(),
|
|
890
|
+
hasMeta,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Resolve the desired startX for a swimlane child, honoring `date:` (fixed
|
|
895
|
+
// pin) > `start:` (fixed pin) > `after:` (chain after refs) > sequential
|
|
896
|
+
// default (continue from `seqDefault`, which is the lane's rightmost time
|
|
897
|
+
// cursor across all rows).
|
|
898
|
+
function resolveChildStart(
|
|
899
|
+
props: EntityProperty[],
|
|
900
|
+
seqDefault: number,
|
|
901
|
+
laneLeftX: number,
|
|
902
|
+
ctx: LayoutContext,
|
|
903
|
+
): number {
|
|
904
|
+
const explicitDate =
|
|
905
|
+
parseDate(propValue(props, 'date')) ?? parseDate(propValue(props, 'start'));
|
|
906
|
+
if (explicitDate) {
|
|
907
|
+
const xd = ctx.scale.forwardWithinDomain(explicitDate);
|
|
908
|
+
if (xd !== null) return xd;
|
|
909
|
+
}
|
|
910
|
+
const afterRefs = propValues(props, 'after');
|
|
911
|
+
if (afterRefs.length > 0) {
|
|
912
|
+
let maxEnd = laneLeftX;
|
|
913
|
+
for (const ref of afterRefs) {
|
|
914
|
+
const endX = ctx.entityRightEdges.get(ref);
|
|
915
|
+
if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
|
|
916
|
+
}
|
|
917
|
+
return Math.max(laneLeftX, maxEnd);
|
|
918
|
+
}
|
|
919
|
+
return seqDefault;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function _buildSwimlane(
|
|
923
|
+
lane: SwimlaneDeclaration,
|
|
924
|
+
y: number,
|
|
925
|
+
bandIndex: number,
|
|
926
|
+
ctx: LayoutContext,
|
|
927
|
+
): { positioned: PositionedSwimlane; usedHeight: number } {
|
|
928
|
+
return new SwimlaneNode(
|
|
929
|
+
{ lane, bandIndex },
|
|
930
|
+
{
|
|
931
|
+
sequenceItem,
|
|
932
|
+
sequenceOne,
|
|
933
|
+
resolveChildStart,
|
|
934
|
+
newCursor,
|
|
935
|
+
estimateTextWidth,
|
|
936
|
+
predictItemChipExtraHeight,
|
|
937
|
+
},
|
|
938
|
+
).place({ x: ctx.timeline.originX, y }, ctx);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Card-sizing constants for beside-mode headers live in
|
|
942
|
+
// `header-card-geometry.ts` so the renderer can paint with the same
|
|
943
|
+
// numbers the layout sized against. Title and author both wrap at
|
|
944
|
+
// MAX_CONTENT_WIDTH (= MAX header width minus 2 * padding). The card
|
|
945
|
+
// hugs its content in the MIN..MAX range and grows vertically when
|
|
946
|
+
// wrapping is needed.
|
|
947
|
+
//
|
|
948
|
+
// Left margin between the canvas's left edge and the visible card. The
|
|
949
|
+
// matching right-side breathing room is owned by `GUTTER_PX` (the canonical
|
|
950
|
+
// content gutter, applied between `chartLeftX` and `originX`), so the gap
|
|
951
|
+
// from the card's right edge to the timeline strip is the same as the gap
|
|
952
|
+
// between two adjacent items.
|
|
953
|
+
const HEADER_CARD_OUTER_PAD = 6;
|
|
954
|
+
|
|
955
|
+
interface SizedHeader {
|
|
956
|
+
titleLines: string[];
|
|
957
|
+
authorLines: string[];
|
|
958
|
+
cardWidth: number;
|
|
959
|
+
cardHeight: number;
|
|
960
|
+
boxWidth: number;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Word-wrap `text` so that no line wider than `maxWidth` (in px). Long single
|
|
964
|
+
// words are kept on their own line even if they overflow — we never split a
|
|
965
|
+
// word in the middle.
|
|
966
|
+
function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
|
|
967
|
+
if (!text) return [];
|
|
968
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
969
|
+
if (words.length === 0) return [];
|
|
970
|
+
const lines: string[] = [];
|
|
971
|
+
let cur = '';
|
|
972
|
+
for (const word of words) {
|
|
973
|
+
const trial = cur ? `${cur} ${word}` : word;
|
|
974
|
+
if (cur && estimateTextWidth(trial, fontSize) > maxWidth) {
|
|
975
|
+
lines.push(cur);
|
|
976
|
+
cur = word;
|
|
977
|
+
} else {
|
|
978
|
+
cur = trial;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (cur) lines.push(cur);
|
|
982
|
+
return lines;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function sizeBesideHeader(title: string, author: string | undefined): SizedHeader {
|
|
986
|
+
const maxContentWidth = HEADER_BESIDE_MAX_WIDTH_PX - 2 * HEADER_CARD_PADDING_X;
|
|
987
|
+
const titleLines = wrapText(title, maxContentWidth, HEADER_TITLE_FONT_SIZE_PX);
|
|
988
|
+
const authorLines = wrapText(author ?? '', maxContentWidth, HEADER_AUTHOR_FONT_SIZE_PX);
|
|
989
|
+
|
|
990
|
+
let widest = 0;
|
|
991
|
+
for (const line of titleLines)
|
|
992
|
+
widest = Math.max(widest, estimateTextWidth(line, HEADER_TITLE_FONT_SIZE_PX));
|
|
993
|
+
for (const line of authorLines)
|
|
994
|
+
widest = Math.max(widest, estimateTextWidth(line, HEADER_AUTHOR_FONT_SIZE_PX));
|
|
995
|
+
|
|
996
|
+
const naturalCardWidth = widest + 2 * HEADER_CARD_PADDING_X;
|
|
997
|
+
// `HEADER_BESIDE_{MIN,MAX}_WIDTH_PX` bound the **boxWidth** (= cardWidth
|
|
998
|
+
// + left outer pad). Subtract one outer pad to derive the cardWidth
|
|
999
|
+
// bounds.
|
|
1000
|
+
const cardWidth = Math.max(
|
|
1001
|
+
HEADER_BESIDE_MIN_WIDTH_PX - HEADER_CARD_OUTER_PAD,
|
|
1002
|
+
Math.min(HEADER_BESIDE_MAX_WIDTH_PX - HEADER_CARD_OUTER_PAD, naturalCardWidth),
|
|
1003
|
+
);
|
|
1004
|
+
|
|
1005
|
+
const titleBlockHeight =
|
|
1006
|
+
titleLines.length > 0 ? (titleLines.length - 1) * HEADER_TITLE_LINE_HEIGHT_PX : 0;
|
|
1007
|
+
const authorBlockHeight =
|
|
1008
|
+
authorLines.length > 0
|
|
1009
|
+
? HEADER_TITLE_TO_AUTHOR_GAP_PX +
|
|
1010
|
+
(authorLines.length - 1) * HEADER_AUTHOR_LINE_HEIGHT_PX
|
|
1011
|
+
: 0;
|
|
1012
|
+
const cardHeight =
|
|
1013
|
+
HEADER_CARD_PADDING_TOP + titleBlockHeight + authorBlockHeight + HEADER_CARD_PADDING_BOTTOM;
|
|
1014
|
+
|
|
1015
|
+
// `boxWidth` only includes the LEFT outer pad — the right-side breathing
|
|
1016
|
+
// room between the card and the chart is owned by `GUTTER_PX` in
|
|
1017
|
+
// `RoadmapNode`. So `boxWidth` doubles as `chartLeftX` (the card's right
|
|
1018
|
+
// edge in canvas coordinates).
|
|
1019
|
+
const boxWidth = cardWidth + HEADER_CARD_OUTER_PAD;
|
|
1020
|
+
return { titleLines, authorLines, cardWidth, cardHeight, boxWidth };
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Compute a sensible [startDate, endDate] window.
|
|
1024
|
+
//
|
|
1025
|
+
// Precedence:
|
|
1026
|
+
// 1. Explicit `length:` on the roadmap declaration wins.
|
|
1027
|
+
// 2. Otherwise we derive the end day from the actual content extent
|
|
1028
|
+
// (latest item end, anchor date, milestone date/after, and today's
|
|
1029
|
+
// now-line if it falls past the content). This keeps the rendered
|
|
1030
|
+
// chart from defaulting to a 180-day desert when the content only
|
|
1031
|
+
// spans a few weeks.
|
|
1032
|
+
// 3. As a last resort (no content + no length), fall back to a small
|
|
1033
|
+
// 4-week placeholder so an empty roadmap still draws a sensible axis.
|
|
1034
|
+
function computeDateWindow(
|
|
1035
|
+
file: NowlineFile,
|
|
1036
|
+
ctx: {
|
|
1037
|
+
cal: import('./calendar.js').CalendarConfig;
|
|
1038
|
+
sizes: Map<string, import('./types.js').ResolvedSize>;
|
|
1039
|
+
},
|
|
1040
|
+
resolved: ResolveResult,
|
|
1041
|
+
today: Date | undefined,
|
|
1042
|
+
scale: ViewPreset,
|
|
1043
|
+
): { startDate: Date; endDate: Date } {
|
|
1044
|
+
const roadmap = file.roadmapDecl;
|
|
1045
|
+
const props = roadmap?.properties ?? [];
|
|
1046
|
+
const startRaw = propValue(props, 'start');
|
|
1047
|
+
// Spec (`specs/dsl.md`): "A roadmap with no `start:` and no dates is
|
|
1048
|
+
// purely relative — renderers choose their own reference date (e.g.
|
|
1049
|
+
// the day of rendering)." Use the caller's `today` (the resolved
|
|
1050
|
+
// `--now`) when present so the start lines up with the now-line, and
|
|
1051
|
+
// fall back to actual today's UTC midnight otherwise. Either default
|
|
1052
|
+
// is dangerous — output drifts day-to-day — but it's strictly better
|
|
1053
|
+
// than the legacy "Jan 1 of the current year" fallback that drifted
|
|
1054
|
+
// every January 1 by 365 days at once. Authors should set `start:`
|
|
1055
|
+
// for any roadmap they want to be reproducible.
|
|
1056
|
+
const startDate = parseDate(startRaw) ?? defaultStartDate(today);
|
|
1057
|
+
const lengthRaw = propValue(props, 'length');
|
|
1058
|
+
if (lengthRaw) {
|
|
1059
|
+
const days = literalDays(lengthRaw, ctx.cal);
|
|
1060
|
+
if (days > 0) {
|
|
1061
|
+
return { startDate, endDate: addDays(startDate, days) };
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
const contentDays = computeContentEndDay(resolved, ctx, startDate, today);
|
|
1065
|
+
const tickDays = daysPerUnit(scale.unit, ctx.cal);
|
|
1066
|
+
// Round up to the smallest tick boundary that is `>= contentDays`. When
|
|
1067
|
+
// the latest content lands exactly on a tick boundary the chart ends
|
|
1068
|
+
// exactly there (no extra trailing tick); otherwise we extend to the
|
|
1069
|
+
// next tick so the right edge always sits on a labelled column.
|
|
1070
|
+
const padded =
|
|
1071
|
+
contentDays > 0 ? Math.ceil(contentDays / tickDays) * tickDays : 4 * ctx.cal.daysPerWeek;
|
|
1072
|
+
return { startDate, endDate: addDays(startDate, Math.max(1, padded)) };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Reference date used when a roadmap omits `start:`. Prefers the
|
|
1077
|
+
* caller-supplied `today` (UTC midnight already, when it comes from
|
|
1078
|
+
* the CLI); falls back to actual today's UTC midnight so the layout
|
|
1079
|
+
* still produces a valid window for direct API callers that don't pass
|
|
1080
|
+
* a `today`. Date components are taken in UTC to match `parseDate`.
|
|
1081
|
+
*/
|
|
1082
|
+
function defaultStartDate(today: Date | undefined): Date {
|
|
1083
|
+
const ref = today ?? new Date();
|
|
1084
|
+
return new Date(Date.UTC(ref.getUTCFullYear(), ref.getUTCMonth(), ref.getUTCDate()));
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function literalDays(literal: string, cal: import('./calendar.js').CalendarConfig): number {
|
|
1088
|
+
const m = /^(\d+(?:\.\d+)?)([dwmqy])$/.exec(literal);
|
|
1089
|
+
if (!m) return 0;
|
|
1090
|
+
const n = parseFloat(m[1]);
|
|
1091
|
+
switch (m[2]) {
|
|
1092
|
+
case 'd':
|
|
1093
|
+
return n;
|
|
1094
|
+
case 'w':
|
|
1095
|
+
return n * cal.daysPerWeek;
|
|
1096
|
+
case 'm':
|
|
1097
|
+
return n * cal.daysPerMonth;
|
|
1098
|
+
case 'q':
|
|
1099
|
+
return n * cal.daysPerQuarter;
|
|
1100
|
+
case 'y':
|
|
1101
|
+
return n * cal.daysPerYear;
|
|
1102
|
+
default:
|
|
1103
|
+
return 0;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Walk every dated/sequenced entity in the resolved content and return the
|
|
1108
|
+
// latest day-offset from `startDate`. Mirrors the sequencer's start-rules
|
|
1109
|
+
// (date: > start: > after: > previous-in-lane) without producing positions.
|
|
1110
|
+
function computeContentEndDay(
|
|
1111
|
+
resolved: ResolveResult,
|
|
1112
|
+
ctx: {
|
|
1113
|
+
cal: import('./calendar.js').CalendarConfig;
|
|
1114
|
+
sizes: Map<string, import('./types.js').ResolvedSize>;
|
|
1115
|
+
},
|
|
1116
|
+
startDate: Date,
|
|
1117
|
+
today: Date | undefined,
|
|
1118
|
+
): number {
|
|
1119
|
+
const itemEnd = new Map<string, number>();
|
|
1120
|
+
const anchorEnd = new Map<string, number>();
|
|
1121
|
+
const milestoneEnd = new Map<string, number>();
|
|
1122
|
+
let maxDay = 0;
|
|
1123
|
+
|
|
1124
|
+
const refEndDay = (ref: string): number => {
|
|
1125
|
+
if (itemEnd.has(ref)) return itemEnd.get(ref)!;
|
|
1126
|
+
if (anchorEnd.has(ref)) return anchorEnd.get(ref)!;
|
|
1127
|
+
if (milestoneEnd.has(ref)) return milestoneEnd.get(ref)!;
|
|
1128
|
+
return 0;
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
// Pre-seed anchors fixed by `date:` so items that reference them get a
|
|
1132
|
+
// valid end-day during the lane walk.
|
|
1133
|
+
for (const anchor of resolved.content.anchors.values()) {
|
|
1134
|
+
const d = parseDate(propValue(anchor.properties, 'date'));
|
|
1135
|
+
if (d && anchor.name) {
|
|
1136
|
+
const day = daysBetween(startDate, d);
|
|
1137
|
+
anchorEnd.set(anchor.name, day);
|
|
1138
|
+
maxDay = Math.max(maxDay, day);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const walkLane = (children: SwimlaneDeclaration['content'], baselineEnd: number): number => {
|
|
1143
|
+
let prevEnd = baselineEnd;
|
|
1144
|
+
for (const child of children) {
|
|
1145
|
+
if (child.$type === 'DescriptionDirective') continue;
|
|
1146
|
+
prevEnd = walkNode(child as ItemDeclaration | GroupBlock | ParallelBlock, prevEnd);
|
|
1147
|
+
maxDay = Math.max(maxDay, prevEnd);
|
|
1148
|
+
}
|
|
1149
|
+
return prevEnd;
|
|
1150
|
+
};
|
|
1151
|
+
|
|
1152
|
+
const walkNode = (
|
|
1153
|
+
node: ItemDeclaration | GroupBlock | ParallelBlock,
|
|
1154
|
+
prevEnd: number,
|
|
1155
|
+
): number => {
|
|
1156
|
+
if (isItemDeclaration(node)) {
|
|
1157
|
+
const dur = deriveItemDurationDays(node.properties, ctx.sizes, ctx.cal);
|
|
1158
|
+
const dateProp = parseDate(propValue(node.properties, 'date'));
|
|
1159
|
+
const startProp = parseDate(propValue(node.properties, 'start'));
|
|
1160
|
+
const afterRefs = propValues(node.properties, 'after');
|
|
1161
|
+
let start = prevEnd;
|
|
1162
|
+
if (dateProp) {
|
|
1163
|
+
start = daysBetween(startDate, dateProp);
|
|
1164
|
+
} else if (startProp) {
|
|
1165
|
+
start = daysBetween(startDate, startProp);
|
|
1166
|
+
} else if (afterRefs.length > 0) {
|
|
1167
|
+
start = Math.max(prevEnd, ...afterRefs.map(refEndDay));
|
|
1168
|
+
}
|
|
1169
|
+
const end = start + dur;
|
|
1170
|
+
if (node.name) itemEnd.set(node.name, end);
|
|
1171
|
+
return end;
|
|
1172
|
+
}
|
|
1173
|
+
if (isParallelBlock(node)) {
|
|
1174
|
+
// All children share the parallel's start; the block's effective
|
|
1175
|
+
// end is the maximum child end.
|
|
1176
|
+
let parallelEnd = prevEnd;
|
|
1177
|
+
for (const child of node.content) {
|
|
1178
|
+
if (child.$type === 'DescriptionDirective') continue;
|
|
1179
|
+
const childEnd = walkNode(child as ItemDeclaration | GroupBlock, prevEnd);
|
|
1180
|
+
parallelEnd = Math.max(parallelEnd, childEnd);
|
|
1181
|
+
}
|
|
1182
|
+
return parallelEnd;
|
|
1183
|
+
}
|
|
1184
|
+
if (isGroupBlock(node)) {
|
|
1185
|
+
return walkLane(node.content as SwimlaneDeclaration['content'], prevEnd);
|
|
1186
|
+
}
|
|
1187
|
+
return prevEnd;
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
for (const lane of resolved.content.swimlanes.values()) {
|
|
1191
|
+
walkLane(lane.content, 0);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Milestones (after items so `after:` references can resolve).
|
|
1195
|
+
for (const ms of resolved.content.milestones.values()) {
|
|
1196
|
+
const d = parseDate(propValue(ms.properties, 'date'));
|
|
1197
|
+
if (d) {
|
|
1198
|
+
const day = daysBetween(startDate, d);
|
|
1199
|
+
if (ms.name) milestoneEnd.set(ms.name, day);
|
|
1200
|
+
maxDay = Math.max(maxDay, day);
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
const after = propValues(ms.properties, 'after');
|
|
1204
|
+
if (after.length > 0) {
|
|
1205
|
+
const day = Math.max(0, ...after.map(refEndDay));
|
|
1206
|
+
if (ms.name) milestoneEnd.set(ms.name, day);
|
|
1207
|
+
maxDay = Math.max(maxDay, day);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Isolated includes contribute their own content extent against the
|
|
1212
|
+
// shared timeline.
|
|
1213
|
+
for (const region of resolved.content.isolatedRegions) {
|
|
1214
|
+
const nestedMax = computeContentEndDay(
|
|
1215
|
+
{
|
|
1216
|
+
config: region.config,
|
|
1217
|
+
content: region.content,
|
|
1218
|
+
diagnostics: [],
|
|
1219
|
+
processedFiles: new Set(),
|
|
1220
|
+
},
|
|
1221
|
+
ctx,
|
|
1222
|
+
startDate,
|
|
1223
|
+
undefined,
|
|
1224
|
+
);
|
|
1225
|
+
maxDay = Math.max(maxDay, nestedMax);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (today) {
|
|
1229
|
+
const t = daysBetween(startDate, today);
|
|
1230
|
+
if (t > 0) maxDay = Math.max(maxDay, t);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
return maxDay;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function buildDependencies(
|
|
1237
|
+
items: Map<string, ItemDeclaration>,
|
|
1238
|
+
swimlanes: PositionedSwimlane[],
|
|
1239
|
+
includes: PositionedIncludeRegion[],
|
|
1240
|
+
ctx: LayoutContext,
|
|
1241
|
+
): PositionedDependencyEdge[] {
|
|
1242
|
+
// m2g+: collect every painted item bar + every visible parallel /
|
|
1243
|
+
// group bracket once, hand to the channel router so it can drop
|
|
1244
|
+
// vertical legs in clean inter-column gutters and nudge away from
|
|
1245
|
+
// bracket strokes that would otherwise be hugged.
|
|
1246
|
+
const grid = new ChannelGrid(collectRoutingObstacles(swimlanes, includes));
|
|
1247
|
+
|
|
1248
|
+
interface Pending {
|
|
1249
|
+
fromId: string;
|
|
1250
|
+
toId: string;
|
|
1251
|
+
}
|
|
1252
|
+
const requests: EdgeRouteRequest[] = [];
|
|
1253
|
+
const pending: Pending[] = [];
|
|
1254
|
+
|
|
1255
|
+
for (const [id, item] of items) {
|
|
1256
|
+
const afters = propValues(item.properties, 'after');
|
|
1257
|
+
const targetMid = ctx.entityMidpoints.get(id);
|
|
1258
|
+
const targetVisualLeftX = ctx.entityVisualLeftX.get(id);
|
|
1259
|
+
if (!targetMid || targetVisualLeftX === undefined) continue;
|
|
1260
|
+
// The arrow always TERMINATES at the target item's left
|
|
1261
|
+
// visual edge, vertically centered on the bar.
|
|
1262
|
+
const targetPoint: Point = { x: targetVisualLeftX, y: targetMid.y };
|
|
1263
|
+
for (const pred of afters) {
|
|
1264
|
+
// Source point depends on what kind of predecessor `pred`
|
|
1265
|
+
// is. For ITEMS we use the per-item arrow source point
|
|
1266
|
+
// — (visualRight, midY) by default, dropping to
|
|
1267
|
+
// (visualRight, bar.bottom - PROGRESS_STRIP_HEIGHT/2) when
|
|
1268
|
+
// the caption spilled past the right edge so the arrow
|
|
1269
|
+
// exits below the spilled title / meta text. For ANCHORS
|
|
1270
|
+
// / MILESTONES we attach to the marker's vertical CUT
|
|
1271
|
+
// LINE at the TARGET item's row mid-Y — the dashed/solid
|
|
1272
|
+
// cut line already drops through the chart and reads as
|
|
1273
|
+
// the arrow's stem, so a short horizontal stub from the
|
|
1274
|
+
// line into the bar's left edge is the cleanest
|
|
1275
|
+
// connection.
|
|
1276
|
+
const itemSource = ctx.itemArrowSource.get(pred);
|
|
1277
|
+
let from: Point;
|
|
1278
|
+
const isMarkerPred = itemSource === undefined;
|
|
1279
|
+
if (itemSource) {
|
|
1280
|
+
from = itemSource;
|
|
1281
|
+
} else {
|
|
1282
|
+
const markerMid = ctx.entityMidpoints.get(pred);
|
|
1283
|
+
if (!markerMid) continue;
|
|
1284
|
+
from = { x: markerMid.x, y: targetMid.y };
|
|
1285
|
+
}
|
|
1286
|
+
// Skip same-row contiguous chains for ITEM → ITEM only:
|
|
1287
|
+
// when the target sits immediately to the right of the
|
|
1288
|
+
// source on the same row, the spatial flow already
|
|
1289
|
+
// conveys ordering and an arrow is redundant noise.
|
|
1290
|
+
// MARKER → ITEM stubs always draw (the cut line is the
|
|
1291
|
+
// stem; the short stub completes the visual connection
|
|
1292
|
+
// even when the bar is right next to the cut line).
|
|
1293
|
+
if (
|
|
1294
|
+
!isMarkerPred &&
|
|
1295
|
+
Math.abs(from.y - targetPoint.y) < 0.5 &&
|
|
1296
|
+
targetPoint.x - from.x < 20
|
|
1297
|
+
) {
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
requests.push({
|
|
1301
|
+
fromId: pred,
|
|
1302
|
+
toId: id,
|
|
1303
|
+
from,
|
|
1304
|
+
to: targetPoint,
|
|
1305
|
+
isMarkerSource: isMarkerPred,
|
|
1306
|
+
});
|
|
1307
|
+
pending.push({ fromId: pred, toId: id });
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const routed = routeChannelEdges(requests, grid);
|
|
1312
|
+
const out: PositionedDependencyEdge[] = [];
|
|
1313
|
+
for (let i = 0; i < routed.length; i++) {
|
|
1314
|
+
const r = routed[i];
|
|
1315
|
+
out.push({
|
|
1316
|
+
fromId: pending[i].fromId,
|
|
1317
|
+
toId: pending[i].toId,
|
|
1318
|
+
waypoints: r.waypoints,
|
|
1319
|
+
kind: r.underBar ? 'underBar' : 'normal',
|
|
1320
|
+
style: resolveStyle('item', [], ctx.styleCtx),
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return out;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function buildNowline(
|
|
1327
|
+
today: Date | undefined,
|
|
1328
|
+
ctx: LayoutContext,
|
|
1329
|
+
locale: string,
|
|
1330
|
+
): PositionedNowline | null {
|
|
1331
|
+
if (!today) return null;
|
|
1332
|
+
const x = ctx.scale.forwardWithinDomain(today);
|
|
1333
|
+
if (x === null) return null;
|
|
1334
|
+
// Pill row is the band reserved at the very top of the timeline area.
|
|
1335
|
+
// The line drops from the bottom of the pill (top of the date headers)
|
|
1336
|
+
// through any marker row and into the chart, so the pill and line stay
|
|
1337
|
+
// visually connected.
|
|
1338
|
+
const pillTopY = ctx.timeline.box.y;
|
|
1339
|
+
const lineTopY = ctx.timeline.tickPanelY;
|
|
1340
|
+
// Pill width is locale-aware: en-US's `'now'` (3 chars) fits the
|
|
1341
|
+
// 36 px default trivially; longer strings like fr's `'maint.'`
|
|
1342
|
+
// (6 chars) need a wider pill to avoid clipping. Floor at the
|
|
1343
|
+
// default so en-US output stays byte-stable; grow when the
|
|
1344
|
+
// measured label needs it. The 2× inset matches the renderer's
|
|
1345
|
+
// flag-mode label inset (`NOW_PILL_LABEL_INSET_X_PX`) and gives
|
|
1346
|
+
// the same visual padding to centered-mode strings.
|
|
1347
|
+
const label = localeStrings(locale).nowLabel;
|
|
1348
|
+
const labelTextWidth = estimateTextWidth(label, NOW_PILL_LABEL_FONT_SIZE_PX);
|
|
1349
|
+
const pillWidth = Math.max(
|
|
1350
|
+
NOW_PILL_WIDTH_PX,
|
|
1351
|
+
Math.ceil(labelTextWidth + 2 * NOW_PILL_LABEL_INSET_X_PX),
|
|
1352
|
+
);
|
|
1353
|
+
// The "chart's left edge" the pill must clear is `chartLeftX` (in
|
|
1354
|
+
// beside-mode, the right edge of the header card; in above-mode,
|
|
1355
|
+
// the canvas left edge at x=0). originX = chartLeftX + GUTTER_PX,
|
|
1356
|
+
// so we recover chartLeftX as `originX - GUTTER_PX`.
|
|
1357
|
+
const chartLeftX = ctx.timeline.originX - GUTTER_PX;
|
|
1358
|
+
const halfPill = pillWidth / 2;
|
|
1359
|
+
let pillMode: 'center' | 'flag-right' | 'flag-left';
|
|
1360
|
+
if (x - halfPill < chartLeftX) {
|
|
1361
|
+
// Centered pill would intrude into the header card / past the
|
|
1362
|
+
// canvas left edge — anchor the pill's LEFT side to the line
|
|
1363
|
+
// and let it extend right into the chart.
|
|
1364
|
+
pillMode = 'flag-right';
|
|
1365
|
+
} else if (x + halfPill > ctx.chartRightX) {
|
|
1366
|
+
// Centered pill would clip past the canvas right edge — anchor
|
|
1367
|
+
// the pill's RIGHT side to the line and let it extend left.
|
|
1368
|
+
pillMode = 'flag-left';
|
|
1369
|
+
} else {
|
|
1370
|
+
pillMode = 'center';
|
|
1371
|
+
}
|
|
1372
|
+
// Bottom-most Y the now-line should reach. When a mirrored bottom
|
|
1373
|
+
// tick panel exists, thread the line through it (so the line ties
|
|
1374
|
+
// the two date strips together visually). Otherwise stop at the
|
|
1375
|
+
// last swimlane — never extend into the footnote area below.
|
|
1376
|
+
const bottomTickPanelY = ctx.timeline.bottomTickPanelY;
|
|
1377
|
+
const bottomTickPanelHeight = ctx.timeline.bottomTickPanelHeight ?? 0;
|
|
1378
|
+
const lineBottomY =
|
|
1379
|
+
bottomTickPanelY !== undefined && bottomTickPanelHeight > 0
|
|
1380
|
+
? bottomTickPanelY + bottomTickPanelHeight
|
|
1381
|
+
: ctx.swimlaneBottomY;
|
|
1382
|
+
return {
|
|
1383
|
+
x,
|
|
1384
|
+
topY: lineTopY,
|
|
1385
|
+
bottomY: lineBottomY,
|
|
1386
|
+
pillTopY,
|
|
1387
|
+
pillMode,
|
|
1388
|
+
label,
|
|
1389
|
+
pillWidth,
|
|
1390
|
+
style: resolveStyle('item', [], ctx.styleCtx),
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Mutable layout-time context shared across helpers.
|
|
1395
|
+
|
|
1396
|
+
// Traverse the full content tree to build an `items` map keyed by id.
|
|
1397
|
+
function collectItems(swimlanes: SwimlaneDeclaration[]): Map<string, ItemDeclaration> {
|
|
1398
|
+
const out = new Map<string, ItemDeclaration>();
|
|
1399
|
+
const walk = (node: ItemDeclaration | GroupBlock | ParallelBlock): void => {
|
|
1400
|
+
if (isItemDeclaration(node)) {
|
|
1401
|
+
if (node.name) out.set(node.name, node);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
if (isParallelBlock(node)) {
|
|
1405
|
+
for (const child of node.content) {
|
|
1406
|
+
if (child.$type === 'DescriptionDirective') continue;
|
|
1407
|
+
walk(child as ItemDeclaration | GroupBlock);
|
|
1408
|
+
}
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (isGroupBlock(node)) {
|
|
1412
|
+
for (const child of node.content) {
|
|
1413
|
+
if (child.$type === 'DescriptionDirective') continue;
|
|
1414
|
+
walk(child as ItemDeclaration | GroupBlock | ParallelBlock);
|
|
1415
|
+
}
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
for (const lane of swimlanes) {
|
|
1420
|
+
for (const child of lane.content) {
|
|
1421
|
+
if (child.$type === 'DescriptionDirective') continue;
|
|
1422
|
+
walk(child as ItemDeclaration | GroupBlock | ParallelBlock);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
return out;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
export function layoutRoadmap(
|
|
1429
|
+
file: NowlineFile,
|
|
1430
|
+
resolved: ResolveResult,
|
|
1431
|
+
options: LayoutOptions = {},
|
|
1432
|
+
): LayoutResult {
|
|
1433
|
+
return new RoadmapNode().place(file, resolved, options, {
|
|
1434
|
+
sequenceItem,
|
|
1435
|
+
sequenceOne,
|
|
1436
|
+
resolveChildStart,
|
|
1437
|
+
newCursor,
|
|
1438
|
+
estimateTextWidth,
|
|
1439
|
+
predictItemChipExtraHeight,
|
|
1440
|
+
computeDateWindow,
|
|
1441
|
+
sizeBesideHeader,
|
|
1442
|
+
collectItems,
|
|
1443
|
+
buildDependencies,
|
|
1444
|
+
buildNowline,
|
|
1445
|
+
});
|
|
1446
|
+
}
|