@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,788 @@
|
|
|
1
|
+
// RoadmapNode — the m2.5c composition root. Walks the per-entity
|
|
2
|
+
// Renderable nodes (ItemNode via sequenceItem, SwimlaneNode, ParallelNode
|
|
3
|
+
// via sequenceOne, AnchorNode/MilestoneNode via build*, footnote/include
|
|
4
|
+
// helpers) to produce the final `PositionedRoadmap` model.
|
|
5
|
+
//
|
|
6
|
+
// RoadmapNode does NOT import the sequencer helpers or the orchestration
|
|
7
|
+
// helpers (`computeDateWindow`, `sizeBesideHeader`, `collectItems`,
|
|
8
|
+
// `buildDependencies`, `buildNowline`) at module-init time. They live in
|
|
9
|
+
// `layout.ts` and are passed in via the `deps` argument. This avoids a
|
|
10
|
+
// runtime cycle while keeping the composition logic in a dedicated file.
|
|
11
|
+
import { defaultRowBand } from '../band-scale.js';
|
|
12
|
+
import { daysBetween, resolveCalendar, resolveSizes } from '../calendar.js';
|
|
13
|
+
import { parseDate, propValue, propValues } from '../dsl-utils.js';
|
|
14
|
+
import { resolveLocale } from '../i18n.js';
|
|
15
|
+
import { resolveStyle } from '../style-resolution.js';
|
|
16
|
+
import { themes } from '../themes/index.js';
|
|
17
|
+
import { ATTRIBUTION_GLYPH_HEIGHT, ATTRIBUTION_GLYPH_WIDTH, GUTTER_PX, HEADER_ABOVE_HEIGHT_PX, NOW_PILL_HEIGHT_PX, SPACING_PX, TIMELINE_TICK_PANEL_HEIGHT_PX, } from '../themes/shared.js';
|
|
18
|
+
import { TimeScale } from '../time-scale.js';
|
|
19
|
+
import { buildHeaderTicks, resolveScale } from '../view-preset.js';
|
|
20
|
+
import { fromCalendarConfig } from '../working-calendar.js';
|
|
21
|
+
import { buildAnchors } from './anchor-node.js';
|
|
22
|
+
import { buildFootnotes } from './footnote-node.js';
|
|
23
|
+
import { buildIncludeRegions } from './include-node.js';
|
|
24
|
+
import { MARKER_BOLD_WIDTH_FACTOR, MARKER_DIAMOND_RADIUS_PX, MARKER_LABEL_GAP_PX, MARKER_LABEL_HEIGHT_PX, MARKER_ROW_CENTER_OFFSET_PX, MARKER_ROW_PITCH_PX, } from './marker-geometry.js';
|
|
25
|
+
import { buildMilestones, collectMilestonePredecessors, lastPredecessorPerFlow, } from './milestone-node.js';
|
|
26
|
+
import { SwimlaneNode } from './swimlane-node.js';
|
|
27
|
+
const HEADER_CARD_TOP_INSET = 4;
|
|
28
|
+
/**
|
|
29
|
+
* Single mutator for the chart's right-edge extent. Every layout
|
|
30
|
+
* artifact that wants to push the canvas wider passes the absolute X
|
|
31
|
+
* it wants the canvas to contain, with any breathing-room margin
|
|
32
|
+
* (typically `GUTTER_PX`) baked in. Concentrating growth here makes
|
|
33
|
+
* it trivial to grep for every contributor — today: item caption
|
|
34
|
+
* spills; future: anchor/milestone label spills, footnote panels
|
|
35
|
+
* wider than the chart, etc.
|
|
36
|
+
*
|
|
37
|
+
* Initial seed lives at the start of `place(...)` (the natural date
|
|
38
|
+
* window with `GUTTER_PX` insets on each side); everything that
|
|
39
|
+
* becomes known after the swimlane pass calls through here.
|
|
40
|
+
*
|
|
41
|
+
* The now-pill is intentionally NOT a contributor — when the line
|
|
42
|
+
* lands close to either edge, `buildNowline` switches the pill to
|
|
43
|
+
* "flag" mode (squared edge against the line, rounded edge into the
|
|
44
|
+
* chart), so the pill always fits inside the natural canvas.
|
|
45
|
+
*/
|
|
46
|
+
function growChartRightX(ctx, rightX) {
|
|
47
|
+
if (rightX > ctx.chartRightX)
|
|
48
|
+
ctx.chartRightX = rightX;
|
|
49
|
+
}
|
|
50
|
+
export class RoadmapNode {
|
|
51
|
+
place(file, resolved, options, deps) {
|
|
52
|
+
const themeName = options.theme ?? 'light';
|
|
53
|
+
const theme = themes[themeName];
|
|
54
|
+
const width = options.width ?? 1280;
|
|
55
|
+
const locale = resolveLocale(options.locale, directiveLocale(file));
|
|
56
|
+
const cal = resolveCalendar(file, resolved.config.calendar);
|
|
57
|
+
const scale = resolveScale(file, resolved.config.scale);
|
|
58
|
+
// Build the resolved-size map once per layout. Sized items look up
|
|
59
|
+
// through this map (instead of the raw `SizeDeclaration`s the
|
|
60
|
+
// include-resolver collected) so item sequencing doesn't pay the
|
|
61
|
+
// literal-to-days conversion every time.
|
|
62
|
+
const sizes = resolveSizes(resolved.content.sizes, cal);
|
|
63
|
+
const styleCtx = {
|
|
64
|
+
theme,
|
|
65
|
+
styles: resolved.config.styles,
|
|
66
|
+
defaults: resolved.config.defaults,
|
|
67
|
+
labels: resolved.content.labels,
|
|
68
|
+
};
|
|
69
|
+
// Date window + header geometry. Window is content-aware: when
|
|
70
|
+
// `length:` is omitted we derive the end day from the latest
|
|
71
|
+
// dated/sequenced entity (item, anchor, milestone, today's
|
|
72
|
+
// now-line) instead of defaulting to a 180-day desert.
|
|
73
|
+
const { startDate, endDate } = deps.computeDateWindow(file, { cal, sizes }, resolved, options.today, scale);
|
|
74
|
+
// Determine header position + timeline placement via `default roadmap`
|
|
75
|
+
// / theme. Raw style props (including `timeline-position` and
|
|
76
|
+
// `minor-grid`) are banned on the roadmap declaration itself, so the
|
|
77
|
+
// values come from the level-2 `default roadmap` line in config.
|
|
78
|
+
const headerStyle = resolveStyle('roadmap', file.roadmapDecl?.properties ?? [], styleCtx);
|
|
79
|
+
const isBeside = headerStyle.headerPosition === 'beside';
|
|
80
|
+
// `top` (default) keeps the legacy single-strip header; `bottom`
|
|
81
|
+
// suppresses the top tick panel (now-pill and marker row stay
|
|
82
|
+
// at the top because anchors / milestones live there); `both`
|
|
83
|
+
// mirrors the strip at the chart bottom too. See specs/dsl.md
|
|
84
|
+
// and specs/rendering.md § Timeline Scale.
|
|
85
|
+
const showTopTickPanel = headerStyle.timelinePosition !== 'bottom';
|
|
86
|
+
const showBottomTickPanel = headerStyle.timelinePosition === 'bottom' || headerStyle.timelinePosition === 'both';
|
|
87
|
+
// Pre-size the beside-mode header card. Width = max line width +
|
|
88
|
+
// padding, clamped to MIN..MAX with word-wrap once the title
|
|
89
|
+
// exceeds MAX. Above-mode keeps the existing fixed-strip
|
|
90
|
+
// geometry (full canvas width, fixed height).
|
|
91
|
+
const titleStr = file.roadmapDecl?.title ?? file.roadmapDecl?.name ?? '';
|
|
92
|
+
const authorStr = propValue(file.roadmapDecl?.properties ?? [], 'author');
|
|
93
|
+
const sizedHeader = deps.sizeBesideHeader(titleStr, authorStr);
|
|
94
|
+
const headerBox = isBeside
|
|
95
|
+
? { x: 0, y: 0, width: sizedHeader.boxWidth, height: 0 }
|
|
96
|
+
: { x: 0, y: 0, width: 0, height: HEADER_ABOVE_HEIGHT_PX };
|
|
97
|
+
const chartLeftX = isBeside ? sizedHeader.boxWidth : 0;
|
|
98
|
+
const chartTopY = isBeside ? 8 : HEADER_ABOVE_HEIGHT_PX + 8;
|
|
99
|
+
// `options.width` is treated as a *maximum* canvas width. The
|
|
100
|
+
// chart sizes to natural content width (date window × ppd) plus
|
|
101
|
+
// chrome padding, capped at the max — no floor. The two
|
|
102
|
+
// `GUTTER_PX` insets keep the header card and attribution
|
|
103
|
+
// wordmark from butting against the canvas edges.
|
|
104
|
+
const calendar = fromCalendarConfig(cal);
|
|
105
|
+
const ppd = scale.pixelsPerUnit / calendar.daysPerUnit(scale.unit);
|
|
106
|
+
const spanDays = Math.max(1, daysBetween(startDate, endDate));
|
|
107
|
+
const naturalWidth = spanDays * ppd;
|
|
108
|
+
const originX = chartLeftX + GUTTER_PX;
|
|
109
|
+
const totalChartWidth = naturalWidth;
|
|
110
|
+
const desiredCanvas = chartLeftX + GUTTER_PX + totalChartWidth + GUTTER_PX;
|
|
111
|
+
const chartRightX = Math.min(width, desiredCanvas);
|
|
112
|
+
// Header layout (top → bottom):
|
|
113
|
+
// 1. Now-pill row (16 px) — only when there's a now-line
|
|
114
|
+
// 2. Tick-label panel (24 px) — always
|
|
115
|
+
// 3. Marker row (≥26 px) — sized to the packed row count
|
|
116
|
+
// 4. 8 px gap, then the chart begins
|
|
117
|
+
const willHaveNowline = options.today !== undefined && options.today >= startDate && options.today <= endDate;
|
|
118
|
+
const hasMarkerEntities = resolved.content.anchors.size + resolved.content.milestones.size > 0;
|
|
119
|
+
const pillRowHeight = willHaveNowline ? NOW_PILL_HEIGHT_PX : 0;
|
|
120
|
+
// When `timeline-position:bottom` is set, the top tick panel
|
|
121
|
+
// collapses to height 0 — the now-pill and marker row stack
|
|
122
|
+
// directly without the date strip between them.
|
|
123
|
+
const tickPanelHeight = showTopTickPanel ? TIMELINE_TICK_PANEL_HEIGHT_PX : 0;
|
|
124
|
+
// Build the time scale up front — packMarkerRow needs it to
|
|
125
|
+
// resolve date-pinned entity x positions before we can size the
|
|
126
|
+
// marker row band.
|
|
127
|
+
const timeScale = new TimeScale({
|
|
128
|
+
domain: [startDate, endDate],
|
|
129
|
+
range: [originX, originX + naturalWidth],
|
|
130
|
+
calendar,
|
|
131
|
+
});
|
|
132
|
+
// Initial canvas extent = natural date window + canonical
|
|
133
|
+
// gutters. Item caption spills are unknown until the swimlane
|
|
134
|
+
// pass runs and grow the canvas via `growChartRightX`. The
|
|
135
|
+
// now-pill doesn't contribute here — `buildNowline` flips it to
|
|
136
|
+
// flag mode whenever a centered pill would clip an edge.
|
|
137
|
+
const finalChartRightX = Math.max(chartRightX, originX + totalChartWidth + GUTTER_PX);
|
|
138
|
+
// Resolve each date-pinned anchor and milestone's x. Used both for
|
|
139
|
+
// the marker-row pack and for `after:` resolution downstream — an
|
|
140
|
+
// item with `after:kickoff` would otherwise see an empty
|
|
141
|
+
// entityRightEdges entry and silently fall through to `cursor.x`
|
|
142
|
+
// (= the chart origin). After-only milestones still resolve later
|
|
143
|
+
// once their predecessors are known.
|
|
144
|
+
const datePinnedEntries = [];
|
|
145
|
+
for (const [id, anchor] of resolved.content.anchors) {
|
|
146
|
+
const date = parseDate(propValue(anchor.properties, 'date'));
|
|
147
|
+
if (!date)
|
|
148
|
+
continue;
|
|
149
|
+
const x = timeScale.forwardWithinDomain(date);
|
|
150
|
+
if (x === null)
|
|
151
|
+
continue;
|
|
152
|
+
datePinnedEntries.push({
|
|
153
|
+
id,
|
|
154
|
+
centerX: x,
|
|
155
|
+
radius: MARKER_DIAMOND_RADIUS_PX,
|
|
156
|
+
title: anchor.title ?? id,
|
|
157
|
+
fontSize: 10,
|
|
158
|
+
bold: false,
|
|
159
|
+
date,
|
|
160
|
+
isMilestone: false,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const [id, milestone] of resolved.content.milestones) {
|
|
164
|
+
const date = parseDate(propValue(milestone.properties, 'date'));
|
|
165
|
+
if (!date)
|
|
166
|
+
continue;
|
|
167
|
+
const x = timeScale.forwardWithinDomain(date);
|
|
168
|
+
if (x === null)
|
|
169
|
+
continue;
|
|
170
|
+
datePinnedEntries.push({
|
|
171
|
+
id,
|
|
172
|
+
centerX: x,
|
|
173
|
+
radius: MARKER_DIAMOND_RADIUS_PX,
|
|
174
|
+
title: milestone.title ?? id,
|
|
175
|
+
fontSize: 10,
|
|
176
|
+
bold: true,
|
|
177
|
+
date,
|
|
178
|
+
isMilestone: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// Pack the date-pinned markers. Each entity gets a row index
|
|
182
|
+
// (0 = in-row baseline, 1 = bumped down by one step, …) and a
|
|
183
|
+
// label box that may be flipped to the LEFT side when the natural
|
|
184
|
+
// right-side label would overflow `finalChartRightX`. Earlier
|
|
185
|
+
// entries claim row 0; later ones drop to row 1+ when their
|
|
186
|
+
// bounding box (diamond + label) overlaps an already-placed
|
|
187
|
+
// entry. The renderer reads `labelBox.x/y` directly.
|
|
188
|
+
const packed = packMarkerRow(datePinnedEntries, chartLeftX, finalChartRightX, deps.estimateTextWidth);
|
|
189
|
+
const markerRowsCount = hasMarkerEntities ? Math.max(1, packed.rowCount) : 0;
|
|
190
|
+
const markerRowHeight = markerRowsCount * MARKER_ROW_PITCH_PX;
|
|
191
|
+
const headerRowsHeight = pillRowHeight + tickPanelHeight + markerRowHeight;
|
|
192
|
+
const timelineHeightBudget = headerRowsHeight + 8;
|
|
193
|
+
// In beside-mode the header card's BOTTOM aligns with the bottom
|
|
194
|
+
// of the header rows so it visually anchors to the chart's top.
|
|
195
|
+
// When the card is taller than the natural rows + a 4 px top
|
|
196
|
+
// inset, push the timeline down so the card has room without
|
|
197
|
+
// clipping above the canvas.
|
|
198
|
+
const minHeaderRowsBottomForCard = isBeside
|
|
199
|
+
? sizedHeader.cardHeight + HEADER_CARD_TOP_INSET
|
|
200
|
+
: 0;
|
|
201
|
+
const timelineY = Math.max(chartTopY, minHeaderRowsBottomForCard - headerRowsHeight);
|
|
202
|
+
const tickPanelY = timelineY + pillRowHeight;
|
|
203
|
+
const markerRowY = tickPanelY + tickPanelHeight;
|
|
204
|
+
const headerRowsBottomY = markerRowY + markerRowHeight;
|
|
205
|
+
const ticks = buildHeaderTicks(timeScale, scale, calendar, locale);
|
|
206
|
+
const timeline = {
|
|
207
|
+
box: { x: originX, y: timelineY, width: naturalWidth, height: 0 },
|
|
208
|
+
ticks,
|
|
209
|
+
pixelsPerDay: ppd,
|
|
210
|
+
originX,
|
|
211
|
+
startDate,
|
|
212
|
+
endDate,
|
|
213
|
+
labelStyle: resolveStyle('roadmap', [], styleCtx),
|
|
214
|
+
pillRowHeight,
|
|
215
|
+
tickPanelY,
|
|
216
|
+
tickPanelHeight,
|
|
217
|
+
markerRow: {
|
|
218
|
+
y: markerRowY + MARKER_ROW_CENTER_OFFSET_PX,
|
|
219
|
+
height: markerRowHeight,
|
|
220
|
+
collisionY: markerRowY - 8,
|
|
221
|
+
},
|
|
222
|
+
// `bottomTickPanelY` / `bottomTickPanelHeight` are filled in
|
|
223
|
+
// AFTER swimlanes + includes are placed (and after the marker
|
|
224
|
+
// re-pack potentially grows the chart), so the panel sits
|
|
225
|
+
// directly above the footnote area.
|
|
226
|
+
minorGrid: headerStyle.minorGrid,
|
|
227
|
+
};
|
|
228
|
+
// Stitch packed placements together with their final centerY now
|
|
229
|
+
// that markerRowY is known. AnchorNode + MilestoneNode read this
|
|
230
|
+
// map to recover both Y and the resolved label box; after-only
|
|
231
|
+
// milestones (not pre-positioned here) pack against this map at
|
|
232
|
+
// build time so they slot into the same rows where there's room.
|
|
233
|
+
const markerRowPlacements = new Map();
|
|
234
|
+
for (const [id, p] of packed.placements) {
|
|
235
|
+
const centerY = markerRowY + MARKER_ROW_CENTER_OFFSET_PX + p.rowIndex * MARKER_ROW_PITCH_PX;
|
|
236
|
+
markerRowPlacements.set(id, {
|
|
237
|
+
rowIndex: p.rowIndex,
|
|
238
|
+
centerY,
|
|
239
|
+
labelBox: { ...p.labelBox, y: centerY - 4 },
|
|
240
|
+
labelSide: p.labelSide,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const ctx = {
|
|
244
|
+
cal,
|
|
245
|
+
styleCtx,
|
|
246
|
+
sizes,
|
|
247
|
+
labels: resolved.content.labels,
|
|
248
|
+
teams: resolved.content.teams,
|
|
249
|
+
persons: resolved.content.persons,
|
|
250
|
+
symbols: resolved.config.symbols,
|
|
251
|
+
footnoteIndex: new Map(),
|
|
252
|
+
footnoteHosts: new Map(),
|
|
253
|
+
timeline,
|
|
254
|
+
scale: timeScale,
|
|
255
|
+
calendar,
|
|
256
|
+
bandScale: defaultRowBand(),
|
|
257
|
+
entityLeftEdges: new Map(),
|
|
258
|
+
entityRightEdges: new Map(),
|
|
259
|
+
entityMidpoints: new Map(),
|
|
260
|
+
entityVisualLeftX: new Map(),
|
|
261
|
+
entityVisualRightX: new Map(),
|
|
262
|
+
itemArrowSource: new Map(),
|
|
263
|
+
itemFlowKey: new Map(),
|
|
264
|
+
currentFlowKey: '',
|
|
265
|
+
itemSlackAttachY: new Map(),
|
|
266
|
+
slackCorridors: [],
|
|
267
|
+
markerRowPlacements,
|
|
268
|
+
chartTopY: timelineY + timelineHeightBudget,
|
|
269
|
+
chartBottomY: 0,
|
|
270
|
+
swimlaneBottomY: 0,
|
|
271
|
+
chartRightX: finalChartRightX,
|
|
272
|
+
};
|
|
273
|
+
// Seed the entity-edge maps from the date-pinned pack so item
|
|
274
|
+
// `after:kickoff` references resolve before swimlanes run. Mid
|
|
275
|
+
// points use the row-aware centerY so dependency arrows from the
|
|
276
|
+
// anchor land on the diamond's actual row.
|
|
277
|
+
for (const e of datePinnedEntries) {
|
|
278
|
+
const placement = markerRowPlacements.get(e.id);
|
|
279
|
+
if (!placement)
|
|
280
|
+
continue;
|
|
281
|
+
ctx.entityLeftEdges.set(e.id, e.centerX);
|
|
282
|
+
ctx.entityRightEdges.set(e.id, e.centerX);
|
|
283
|
+
ctx.entityMidpoints.set(e.id, { x: e.centerX, y: placement.centerY });
|
|
284
|
+
}
|
|
285
|
+
// Footnotes index must be built before sequencing items reference
|
|
286
|
+
// them.
|
|
287
|
+
const pre = buildFootnotes(resolved.content.footnotes, ctx, 0);
|
|
288
|
+
ctx.footnoteIndex = pre.index;
|
|
289
|
+
ctx.footnoteHosts = pre.hosts;
|
|
290
|
+
// Snapshot the entity maps after pre-positioning so we can reset
|
|
291
|
+
// them between the two swimlane passes. Date-pinned anchors and
|
|
292
|
+
// milestones live in this baseline; item-derived entries get
|
|
293
|
+
// re-added on each pass.
|
|
294
|
+
const baselineEntityLeft = new Map(ctx.entityLeftEdges);
|
|
295
|
+
const baselineEntityRight = new Map(ctx.entityRightEdges);
|
|
296
|
+
const baselineEntityMid = new Map(ctx.entityMidpoints);
|
|
297
|
+
// Item-only maps don't carry baseline entries (date-pinned
|
|
298
|
+
// markers don't populate visual edges or arrow sources), so
|
|
299
|
+
// pass-2 reruns reset them to fresh empties.
|
|
300
|
+
// Build swimlanes (declared order). Inter-band gap comes from
|
|
301
|
+
// the swimlane default style's `spacing` bucket. Default
|
|
302
|
+
// `spacing: none` keeps existing samples byte-stable; bumping to
|
|
303
|
+
// `md` introduces an 8 px gap.
|
|
304
|
+
const laneEntries = [...resolved.content.swimlanes.values()];
|
|
305
|
+
const swimlaneDefaultStyle = resolveStyle('swimlane', [], styleCtx);
|
|
306
|
+
const interBandGapPx = SPACING_PX[swimlaneDefaultStyle.spacing] ?? 0;
|
|
307
|
+
const runSwimlaneLoop = () => {
|
|
308
|
+
const out = [];
|
|
309
|
+
let cursorY = ctx.chartTopY;
|
|
310
|
+
let bIndex = 0;
|
|
311
|
+
let maxRightX = ctx.timeline.originX;
|
|
312
|
+
for (const lane of laneEntries) {
|
|
313
|
+
if (bIndex > 0)
|
|
314
|
+
cursorY += interBandGapPx;
|
|
315
|
+
const { positioned, usedHeight, usedRightX } = new SwimlaneNode({ lane, bandIndex: bIndex }, deps).place({ x: ctx.timeline.originX, y: cursorY }, ctx);
|
|
316
|
+
out.push(positioned);
|
|
317
|
+
cursorY += usedHeight;
|
|
318
|
+
if (usedRightX > maxRightX)
|
|
319
|
+
maxRightX = usedRightX;
|
|
320
|
+
bIndex++;
|
|
321
|
+
}
|
|
322
|
+
return { swimlanes: out, nextY: cursorY, maxRightX };
|
|
323
|
+
};
|
|
324
|
+
// Pass 1 — place items without corridor knowledge.
|
|
325
|
+
let pass = runSwimlaneLoop();
|
|
326
|
+
let swimlanes = pass.swimlanes;
|
|
327
|
+
let y = pass.nextY;
|
|
328
|
+
// Collect slack-arrow corridors from the milestones (mirrors
|
|
329
|
+
// MilestoneNode.place's pred resolution). When the result is
|
|
330
|
+
// non-empty, an item sat inside an arrow's path on pass 1 — rerun
|
|
331
|
+
// the swimlane loop with corridors known so the row-packer can
|
|
332
|
+
// bump the offending items down to a clear row. Bumping never
|
|
333
|
+
// changes an item's x, so corridors stay valid across the rerun;
|
|
334
|
+
// no fixed-point iteration needed.
|
|
335
|
+
const corridors = collectSlackCorridors(resolved.content.milestones, ctx);
|
|
336
|
+
if (corridors.length > 0) {
|
|
337
|
+
ctx.entityLeftEdges = new Map(baselineEntityLeft);
|
|
338
|
+
ctx.entityRightEdges = new Map(baselineEntityRight);
|
|
339
|
+
ctx.entityMidpoints = new Map(baselineEntityMid);
|
|
340
|
+
// itemSlackAttachY only ever holds item entries (markers
|
|
341
|
+
// never write to it), so a fresh map is the right reset —
|
|
342
|
+
// pass 2's items will repopulate. Same applies to the
|
|
343
|
+
// visual-edge / arrow-source / flow-key maps below.
|
|
344
|
+
ctx.itemSlackAttachY = new Map();
|
|
345
|
+
ctx.entityVisualLeftX = new Map();
|
|
346
|
+
ctx.entityVisualRightX = new Map();
|
|
347
|
+
ctx.itemArrowSource = new Map();
|
|
348
|
+
ctx.itemFlowKey = new Map();
|
|
349
|
+
ctx.currentFlowKey = '';
|
|
350
|
+
ctx.slackCorridors = corridors;
|
|
351
|
+
pass = runSwimlaneLoop();
|
|
352
|
+
swimlanes = pass.swimlanes;
|
|
353
|
+
y = pass.nextY;
|
|
354
|
+
}
|
|
355
|
+
// Expand the canvas to fit any caption that spilled past its bar
|
|
356
|
+
// (`textSpills`). Otherwise the long captions on narrow charts —
|
|
357
|
+
// e.g. `examples/minimal.svg` and `tests/text-spills-right.svg`
|
|
358
|
+
// — would land outside the SVG's viewBox and clip in browsers.
|
|
359
|
+
// Markers/anchors built below see the expanded width and pick a
|
|
360
|
+
// less aggressive label side. Routed through `growChartRightX`
|
|
361
|
+
// so this contribution sits beside the now-pill reservation
|
|
362
|
+
// (made at init) in the canvas-extent ledger.
|
|
363
|
+
growChartRightX(ctx, pass.maxRightX + GUTTER_PX);
|
|
364
|
+
// Each swimlane band reads the canvas width once during its
|
|
365
|
+
// place pass, before the spill expansion above. Re-stretch every
|
|
366
|
+
// band so the lane background contains its own spilled captions
|
|
367
|
+
// (text-spills-right's "1w — 50% remaining" extends 22 px past
|
|
368
|
+
// the unstretched lane edge otherwise).
|
|
369
|
+
for (const lane of swimlanes) {
|
|
370
|
+
lane.box.width = ctx.chartRightX;
|
|
371
|
+
}
|
|
372
|
+
// Include regions under the swimlanes. Reserve the 8 px gap +
|
|
373
|
+
// tab-reserve only when there's at least one isolated region —
|
|
374
|
+
// otherwise the now-line and chart bottom would extend past the
|
|
375
|
+
// last swimlane into empty space.
|
|
376
|
+
const isolated = resolved.content.isolatedRegions;
|
|
377
|
+
let includes = [];
|
|
378
|
+
if (isolated.length > 0) {
|
|
379
|
+
const r = buildIncludeRegions(isolated, ctx, y + 8, deps);
|
|
380
|
+
includes = r.regions;
|
|
381
|
+
y = r.endY;
|
|
382
|
+
}
|
|
383
|
+
ctx.chartBottomY = y;
|
|
384
|
+
ctx.swimlaneBottomY = y;
|
|
385
|
+
timeline.box.height = ctx.chartBottomY - timeline.box.y;
|
|
386
|
+
// Milestones first so anchors know which xs are occupied (for
|
|
387
|
+
// collision bumps). Date-pinned milestones consult the
|
|
388
|
+
// pre-pack; after-only milestones get a provisional row=0
|
|
389
|
+
// placement which the re-pack below overwrites once their
|
|
390
|
+
// centerX is known.
|
|
391
|
+
const milestones = buildMilestones(resolved.content.milestones, ctx);
|
|
392
|
+
// Unified marker re-pack. Every marker (date-pinned anchor,
|
|
393
|
+
// date-pinned milestone, after-only milestone) participates
|
|
394
|
+
// with its final centerX — so packMarkerRow can sort by tick
|
|
395
|
+
// and assign rows + label sides bottom-first. This is the
|
|
396
|
+
// single source of truth for `ctx.markerRowPlacements`; the
|
|
397
|
+
// pre-pack only existed to size the marker band before
|
|
398
|
+
// swimlanes ran.
|
|
399
|
+
const allMarkerEntries = datePinnedEntries.map((e) => ({
|
|
400
|
+
id: e.id,
|
|
401
|
+
centerX: e.centerX,
|
|
402
|
+
radius: e.radius,
|
|
403
|
+
title: e.title,
|
|
404
|
+
fontSize: e.fontSize,
|
|
405
|
+
bold: e.bold,
|
|
406
|
+
}));
|
|
407
|
+
for (const m of milestones) {
|
|
408
|
+
if (m.fixed)
|
|
409
|
+
continue;
|
|
410
|
+
allMarkerEntries.push({
|
|
411
|
+
id: m.id ?? '',
|
|
412
|
+
centerX: m.center.x,
|
|
413
|
+
radius: m.radius,
|
|
414
|
+
title: m.title,
|
|
415
|
+
fontSize: 10,
|
|
416
|
+
bold: true,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const repacked = packMarkerRow(allMarkerEntries, chartLeftX, ctx.chartRightX, deps.estimateTextWidth);
|
|
420
|
+
ctx.markerRowPlacements.clear();
|
|
421
|
+
for (const [id, p] of repacked.placements) {
|
|
422
|
+
const centerY = markerRowY + MARKER_ROW_CENTER_OFFSET_PX + p.rowIndex * MARKER_ROW_PITCH_PX;
|
|
423
|
+
ctx.markerRowPlacements.set(id, {
|
|
424
|
+
rowIndex: p.rowIndex,
|
|
425
|
+
centerY,
|
|
426
|
+
labelBox: { ...p.labelBox, y: centerY - 4 },
|
|
427
|
+
labelSide: p.labelSide,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
// Push final placements back onto already-built milestones and
|
|
431
|
+
// every marker's entityMidpoints entry (used by edge routing
|
|
432
|
+
// and slack-arrow geometry).
|
|
433
|
+
for (const m of milestones) {
|
|
434
|
+
const placement = ctx.markerRowPlacements.get(m.id ?? '');
|
|
435
|
+
if (!placement)
|
|
436
|
+
continue;
|
|
437
|
+
m.center = { x: m.center.x, y: placement.centerY };
|
|
438
|
+
m.labelBox = placement.labelBox;
|
|
439
|
+
m.labelSide = placement.labelSide;
|
|
440
|
+
}
|
|
441
|
+
for (const e of datePinnedEntries) {
|
|
442
|
+
const p = ctx.markerRowPlacements.get(e.id);
|
|
443
|
+
if (!p)
|
|
444
|
+
continue;
|
|
445
|
+
ctx.entityMidpoints.set(e.id, { x: e.centerX, y: p.centerY });
|
|
446
|
+
}
|
|
447
|
+
for (const m of milestones) {
|
|
448
|
+
if (m.fixed)
|
|
449
|
+
continue;
|
|
450
|
+
ctx.entityMidpoints.set(m.id ?? '', { x: m.center.x, y: m.center.y });
|
|
451
|
+
}
|
|
452
|
+
// If the unified pack needs more rows than we sized for, grow
|
|
453
|
+
// the marker band and translate every chart coordinate below
|
|
454
|
+
// it. Anchors, edges, nowline, footnotes, and the attribution
|
|
455
|
+
// mark are built AFTER this block so they pick up the new ctx
|
|
456
|
+
// values without further work.
|
|
457
|
+
const actualRowCount = hasMarkerEntities ? Math.max(1, repacked.rowCount) : 0;
|
|
458
|
+
if (actualRowCount > markerRowsCount) {
|
|
459
|
+
const deltaY = (actualRowCount - markerRowsCount) * MARKER_ROW_PITCH_PX;
|
|
460
|
+
ctx.timeline.markerRow.height = actualRowCount * MARKER_ROW_PITCH_PX;
|
|
461
|
+
ctx.timeline.box.height += deltaY;
|
|
462
|
+
ctx.chartTopY += deltaY;
|
|
463
|
+
ctx.chartBottomY += deltaY;
|
|
464
|
+
ctx.swimlaneBottomY += deltaY;
|
|
465
|
+
for (const lane of swimlanes)
|
|
466
|
+
shiftSwimlaneY(lane, deltaY);
|
|
467
|
+
for (const inc of includes)
|
|
468
|
+
shiftIncludeY(inc, deltaY);
|
|
469
|
+
// Item entityMidpoints were captured during swimlane
|
|
470
|
+
// place; markers live in markerRowPlacements with their
|
|
471
|
+
// own centerY that's already final.
|
|
472
|
+
for (const [id, m] of ctx.entityMidpoints) {
|
|
473
|
+
if (ctx.markerRowPlacements.has(id))
|
|
474
|
+
continue;
|
|
475
|
+
ctx.entityMidpoints.set(id, { x: m.x, y: m.y + deltaY });
|
|
476
|
+
}
|
|
477
|
+
// itemSlackAttachY was sampled at the same pre-shift Y as
|
|
478
|
+
// the entity midpoints — keep the two in sync.
|
|
479
|
+
for (const [id, y] of ctx.itemSlackAttachY) {
|
|
480
|
+
ctx.itemSlackAttachY.set(id, y + deltaY);
|
|
481
|
+
}
|
|
482
|
+
for (const m of milestones) {
|
|
483
|
+
m.cutTopY = ctx.chartTopY;
|
|
484
|
+
m.cutBottomY = ctx.swimlaneBottomY;
|
|
485
|
+
// Slack arrows were baked with the pre-shift attach Y
|
|
486
|
+
// when buildMilestones ran above. Shift them now so
|
|
487
|
+
// they land on the same row band as the (now-shifted)
|
|
488
|
+
// predecessor bar.
|
|
489
|
+
if (m.slackArrows) {
|
|
490
|
+
for (const arrow of m.slackArrows)
|
|
491
|
+
arrow.y += deltaY;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// Insert the mirrored bottom tick panel directly above the
|
|
496
|
+
// footnote area, BELOW the chart's last swimlane / include
|
|
497
|
+
// region. Done AFTER any marker-row-growth shift so the panel
|
|
498
|
+
// y honors the final chartBottomY. `timeline.box.height` stays
|
|
499
|
+
// anchored to the chart's bottom edge — grid lines therefore
|
|
500
|
+
// stop at the panel's TOP, not at its bottom — while the panel
|
|
501
|
+
// gets its own fill / border / labels in the renderer. Milestone
|
|
502
|
+
// and anchor cut-lines stay anchored to `swimlaneBottomY` so
|
|
503
|
+
// they never invade the bottom date strip; only the now-line
|
|
504
|
+
// (and full-height major grid lines) thread through this panel.
|
|
505
|
+
if (showBottomTickPanel) {
|
|
506
|
+
const gapAbovePanel = 8;
|
|
507
|
+
ctx.timeline.bottomTickPanelY = ctx.chartBottomY + gapAbovePanel;
|
|
508
|
+
ctx.timeline.bottomTickPanelHeight = TIMELINE_TICK_PANEL_HEIGHT_PX;
|
|
509
|
+
ctx.chartBottomY += gapAbovePanel + TIMELINE_TICK_PANEL_HEIGHT_PX;
|
|
510
|
+
}
|
|
511
|
+
const milestoneXs = new Set(milestones.map((m) => m.center.x));
|
|
512
|
+
const anchors = buildAnchors(resolved.content.anchors, ctx, milestoneXs);
|
|
513
|
+
const itemsMap = deps.collectItems(laneEntries);
|
|
514
|
+
const edges = deps.buildDependencies(itemsMap, swimlanes, includes, ctx);
|
|
515
|
+
// Now-line (if today is within the window). Stops at the bottom
|
|
516
|
+
// timeline panel when present, otherwise at the last swimlane —
|
|
517
|
+
// see `buildNowline`. Footnote panels below do not extend it.
|
|
518
|
+
const nowline = deps.buildNowline(options.today, ctx, locale);
|
|
519
|
+
// Finalize footnotes at the bottom.
|
|
520
|
+
const foot = buildFootnotes(resolved.content.footnotes, ctx, ctx.chartBottomY);
|
|
521
|
+
ctx.footnoteIndex = foot.index;
|
|
522
|
+
ctx.footnoteHosts = foot.hosts;
|
|
523
|
+
// Header (depends on chart height in beside-mode and on the
|
|
524
|
+
// final canvas width in above-mode).
|
|
525
|
+
headerBox.height = headerBox.height || ctx.chartBottomY;
|
|
526
|
+
if (!isBeside)
|
|
527
|
+
headerBox.width = ctx.chartRightX;
|
|
528
|
+
// Card sub-box for beside-mode (the visible white panel inside
|
|
529
|
+
// headerBox). Card BOTTOM hugs the bottom of the header rows so
|
|
530
|
+
// the title block visually anchors to the chart's top edge
|
|
531
|
+
// regardless of which header rows are present. timelineY was
|
|
532
|
+
// already nudged down above to guarantee the card has at least
|
|
533
|
+
// HEADER_CARD_TOP_INSET clearance from the canvas top.
|
|
534
|
+
const cardBox = isBeside
|
|
535
|
+
? {
|
|
536
|
+
x: 6,
|
|
537
|
+
y: headerRowsBottomY - sizedHeader.cardHeight,
|
|
538
|
+
width: sizedHeader.cardWidth,
|
|
539
|
+
height: sizedHeader.cardHeight,
|
|
540
|
+
}
|
|
541
|
+
: { x: 0, y: 0, width: ctx.chartRightX, height: HEADER_ABOVE_HEIGHT_PX };
|
|
542
|
+
// Attribution wordmark placement. Canvas grows by GUTTER_PX +
|
|
543
|
+
// glyph height + GUTTER_PX so the wordmark sits in a clean bottom
|
|
544
|
+
// margin below all content (last swimlane / footnote panel /
|
|
545
|
+
// include region). Today only the bottom-right slot fires, but
|
|
546
|
+
// the priority order is bottom-right → upper-right → bottom-left
|
|
547
|
+
// — when content density makes bottom-right disruptive in some
|
|
548
|
+
// future case, fall back to upper-right (above the timeline) and
|
|
549
|
+
// then bottom-left (under the header card in beside-mode).
|
|
550
|
+
//
|
|
551
|
+
// When there are no footnotes, `foot.area.box` still carries a 16 px
|
|
552
|
+
// top-of-panel offset; that's a placeholder for the footnote
|
|
553
|
+
// header gap and shouldn't push the attribution down. Use the
|
|
554
|
+
// bare `chartBottomY` in that case so the bottom margin is
|
|
555
|
+
// exactly `GUTTER_PX + glyphHeight + GUTTER_PX`.
|
|
556
|
+
const contentBottomY = foot.area.entries.length > 0
|
|
557
|
+
? foot.area.box.y + foot.area.box.height
|
|
558
|
+
: ctx.chartBottomY;
|
|
559
|
+
const attributionBox = {
|
|
560
|
+
x: ctx.chartRightX - GUTTER_PX - ATTRIBUTION_GLYPH_WIDTH,
|
|
561
|
+
y: contentBottomY + GUTTER_PX,
|
|
562
|
+
width: ATTRIBUTION_GLYPH_WIDTH,
|
|
563
|
+
height: ATTRIBUTION_GLYPH_HEIGHT,
|
|
564
|
+
};
|
|
565
|
+
const height = attributionBox.y + attributionBox.height + GUTTER_PX;
|
|
566
|
+
const header = {
|
|
567
|
+
box: headerBox,
|
|
568
|
+
position: headerStyle.headerPosition,
|
|
569
|
+
title: titleStr,
|
|
570
|
+
author: authorStr,
|
|
571
|
+
titleLines: sizedHeader.titleLines,
|
|
572
|
+
authorLines: sizedHeader.authorLines,
|
|
573
|
+
cardBox,
|
|
574
|
+
logo: undefined,
|
|
575
|
+
style: headerStyle,
|
|
576
|
+
attributionBox,
|
|
577
|
+
};
|
|
578
|
+
const model = {
|
|
579
|
+
width: ctx.chartRightX,
|
|
580
|
+
height,
|
|
581
|
+
theme: themeName,
|
|
582
|
+
palette: theme,
|
|
583
|
+
backgroundColor: theme.surface.page,
|
|
584
|
+
header,
|
|
585
|
+
timeline,
|
|
586
|
+
nowline,
|
|
587
|
+
swimlanes,
|
|
588
|
+
anchors,
|
|
589
|
+
milestones,
|
|
590
|
+
edges,
|
|
591
|
+
footnotes: foot.area,
|
|
592
|
+
includes,
|
|
593
|
+
chartBox: {
|
|
594
|
+
x: chartLeftX,
|
|
595
|
+
y: ctx.chartTopY,
|
|
596
|
+
width: ctx.chartRightX - chartLeftX,
|
|
597
|
+
height: ctx.chartBottomY - ctx.chartTopY,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
return model;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Collect slack-arrow corridors from the resolved milestones using the
|
|
605
|
+
* current entity-edge maps. The result mirrors what
|
|
606
|
+
* `MilestoneNode.place` would emit on `slackArrows`, so the row-packer
|
|
607
|
+
* and the renderer agree on every arrow's geometry.
|
|
608
|
+
*
|
|
609
|
+
* Each non-binding predecessor (`x < maxEnd`, `y > 0`) becomes one
|
|
610
|
+
* corridor; date-pinned milestones whose latest predecessor overruns
|
|
611
|
+
* the date contribute a single back-pointing corridor between the
|
|
612
|
+
* pinned column and the predecessor's right edge.
|
|
613
|
+
*/
|
|
614
|
+
function collectSlackCorridors(milestones, ctx) {
|
|
615
|
+
const out = [];
|
|
616
|
+
for (const [id, m] of milestones) {
|
|
617
|
+
const dateRaw = propValue(m.properties, 'date');
|
|
618
|
+
const afterRaw = propValues(m.properties, 'after');
|
|
619
|
+
const date = parseDate(dateRaw);
|
|
620
|
+
// Reuse MilestoneNode's helpers so the corridor set agrees
|
|
621
|
+
// exactly with the rendered slack-arrow set: same source x
|
|
622
|
+
// (visual edge for items / cut-line for markers), same
|
|
623
|
+
// attach y, and the same flow-dedupe rule that collapses
|
|
624
|
+
// chained predecessors to one arrow per flow.
|
|
625
|
+
const preds = collectMilestonePredecessors(afterRaw, ctx);
|
|
626
|
+
const dedupedPreds = lastPredecessorPerFlow(preds);
|
|
627
|
+
if (date) {
|
|
628
|
+
const milestoneX = ctx.scale.forwardWithinDomain(date);
|
|
629
|
+
if (milestoneX === null)
|
|
630
|
+
continue;
|
|
631
|
+
let maxPred = null;
|
|
632
|
+
for (const p of dedupedPreds) {
|
|
633
|
+
if (!maxPred || p.x > maxPred.x)
|
|
634
|
+
maxPred = p;
|
|
635
|
+
}
|
|
636
|
+
if (maxPred && maxPred.x > milestoneX && maxPred.y > 0) {
|
|
637
|
+
out.push({
|
|
638
|
+
xStart: Math.min(maxPred.x, milestoneX),
|
|
639
|
+
xEnd: Math.max(maxPred.x, milestoneX),
|
|
640
|
+
y: maxPred.y,
|
|
641
|
+
slackPredId: maxPred.ref,
|
|
642
|
+
milestoneId: id,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (dedupedPreds.length === 0)
|
|
648
|
+
continue;
|
|
649
|
+
dedupedPreds.sort((a, b) => b.x - a.x);
|
|
650
|
+
const maxEnd = dedupedPreds[0].x;
|
|
651
|
+
for (let i = 1; i < dedupedPreds.length; i++) {
|
|
652
|
+
const p = dedupedPreds[i];
|
|
653
|
+
if (p.x < maxEnd && p.y > 0) {
|
|
654
|
+
out.push({
|
|
655
|
+
xStart: p.x,
|
|
656
|
+
xEnd: maxEnd,
|
|
657
|
+
y: p.y,
|
|
658
|
+
slackPredId: p.ref,
|
|
659
|
+
milestoneId: id,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Translate every Y coordinate inside a swimlane subtree by `dy`. Used
|
|
668
|
+
* when the marker band has to grow after items are already placed —
|
|
669
|
+
* the shift cascades from the swimlane box through every track child,
|
|
670
|
+
* including parallels and groups (which are recursive `children`).
|
|
671
|
+
*/
|
|
672
|
+
function shiftSwimlaneY(lane, dy) {
|
|
673
|
+
lane.box.y += dy;
|
|
674
|
+
for (const child of lane.children)
|
|
675
|
+
shiftTrackChildY(child, dy);
|
|
676
|
+
for (const nested of lane.nested)
|
|
677
|
+
shiftSwimlaneY(nested, dy);
|
|
678
|
+
}
|
|
679
|
+
function shiftTrackChildY(child, dy) {
|
|
680
|
+
child.box.y += dy;
|
|
681
|
+
if (child.kind === 'item') {
|
|
682
|
+
if (child.overflowBox)
|
|
683
|
+
child.overflowBox.y += dy;
|
|
684
|
+
for (const chip of child.labelChips)
|
|
685
|
+
chip.box.y += dy;
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
for (const c of child.children)
|
|
689
|
+
shiftTrackChildY(c, dy);
|
|
690
|
+
}
|
|
691
|
+
function shiftIncludeY(region, dy) {
|
|
692
|
+
region.box.y += dy;
|
|
693
|
+
for (const lane of region.nestedSwimlanes)
|
|
694
|
+
shiftSwimlaneY(lane, dy);
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Pack anchors + milestones into marker rows using a bottom-first
|
|
698
|
+
* tick-order strategy. Markers default to the row CLOSEST to the
|
|
699
|
+
* chart; conflicts push them UP toward the date ticks (the inverse of
|
|
700
|
+
* swimlane items which default to the top and push down).
|
|
701
|
+
*
|
|
702
|
+
* Walk order is left-to-right by `centerX` (tick order, not file
|
|
703
|
+
* declaration). For each marker:
|
|
704
|
+
* 1. Try the bottommost existing row (highest `rowIndex`), right
|
|
705
|
+
* side first then left, working UP through the rows.
|
|
706
|
+
* 2. If neither side fits at any existing row, GROW: prepend a new
|
|
707
|
+
* row at `rowIndex = 0`. Every previously-placed marker has its
|
|
708
|
+
* `rowIndex` incremented by 1 so the bottom corridor stays
|
|
709
|
+
* stable as the band expands upward toward the ticks.
|
|
710
|
+
*
|
|
711
|
+
* Returns row-relative placements (no centerY yet — caller fills that
|
|
712
|
+
* in once `markerRowY` is known). Row indices follow the existing
|
|
713
|
+
* convention: 0 = top of band (closest to ticks), `rowCount - 1` =
|
|
714
|
+
* bottom of band (closest to chart). The "bottom-first" preference
|
|
715
|
+
* lives entirely in the search order and grow direction; geometry
|
|
716
|
+
* stays anchored to `markerRowY` at the top.
|
|
717
|
+
*/
|
|
718
|
+
export function packMarkerRow(entries, chartLeftX, chartRightX, estimateTextWidth) {
|
|
719
|
+
const sorted = [...entries].sort((a, b) => a.centerX - b.centerX);
|
|
720
|
+
const placements = new Map();
|
|
721
|
+
let rowSpans = [];
|
|
722
|
+
for (const e of sorted) {
|
|
723
|
+
const labelWidth = estimateTextWidth(e.title, e.fontSize) * (e.bold ? MARKER_BOLD_WIDTH_FACTOR : 1);
|
|
724
|
+
const naturalRightX = e.centerX + e.radius + MARKER_LABEL_GAP_PX;
|
|
725
|
+
const naturalLeftX = e.centerX - e.radius - MARKER_LABEL_GAP_PX - labelWidth;
|
|
726
|
+
const fitsRightCanvas = naturalRightX + labelWidth <= chartRightX;
|
|
727
|
+
const fitsLeftCanvas = naturalLeftX >= chartLeftX;
|
|
728
|
+
const diamondLeft = e.centerX - e.radius;
|
|
729
|
+
const diamondRight = e.centerX + e.radius;
|
|
730
|
+
const sideXLeft = (s) => (s === 'right' ? naturalRightX : naturalLeftX);
|
|
731
|
+
const sideFitsCanvas = (s) => s === 'right' ? fitsRightCanvas : fitsLeftCanvas;
|
|
732
|
+
const collidesAt = (xLeft, row) => {
|
|
733
|
+
const extLeft = Math.min(diamondLeft, xLeft);
|
|
734
|
+
const extRight = Math.max(diamondRight, xLeft + labelWidth);
|
|
735
|
+
return rowSpans[row].some((s) => s.left < extRight && s.right > extLeft);
|
|
736
|
+
};
|
|
737
|
+
let placedRow = -1;
|
|
738
|
+
let placedSide = 'right';
|
|
739
|
+
let placedXLeft = naturalRightX;
|
|
740
|
+
// Bottom-first: try the highest existing row first, working up
|
|
741
|
+
// toward row 0 (closest to the ticks).
|
|
742
|
+
outer: for (let row = rowSpans.length - 1; row >= 0; row--) {
|
|
743
|
+
for (const side of ['right', 'left']) {
|
|
744
|
+
if (!sideFitsCanvas(side))
|
|
745
|
+
continue;
|
|
746
|
+
const xLeft = sideXLeft(side);
|
|
747
|
+
if (collidesAt(xLeft, row))
|
|
748
|
+
continue;
|
|
749
|
+
placedRow = row;
|
|
750
|
+
placedSide = side;
|
|
751
|
+
placedXLeft = xLeft;
|
|
752
|
+
break outer;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (placedRow === -1) {
|
|
756
|
+
// No existing row fits — prepend a fresh top row and shift
|
|
757
|
+
// every previous placement DOWN by one slot (rowIndex += 1)
|
|
758
|
+
// so the bottom corridor stays stable.
|
|
759
|
+
for (const [id, p] of placements) {
|
|
760
|
+
placements.set(id, { ...p, rowIndex: p.rowIndex + 1 });
|
|
761
|
+
}
|
|
762
|
+
rowSpans = [[], ...rowSpans];
|
|
763
|
+
placedRow = 0;
|
|
764
|
+
placedSide = fitsRightCanvas ? 'right' : 'left';
|
|
765
|
+
placedXLeft = sideXLeft(placedSide);
|
|
766
|
+
}
|
|
767
|
+
const extLeft = Math.min(diamondLeft, placedXLeft);
|
|
768
|
+
const extRight = Math.max(diamondRight, placedXLeft + labelWidth);
|
|
769
|
+
rowSpans[placedRow].push({ left: extLeft, right: extRight });
|
|
770
|
+
placements.set(e.id, {
|
|
771
|
+
rowIndex: placedRow,
|
|
772
|
+
labelBox: { x: placedXLeft, y: 0, width: labelWidth, height: MARKER_LABEL_HEIGHT_PX },
|
|
773
|
+
labelSide: placedSide,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return { placements, rowCount: rowSpans.length };
|
|
777
|
+
}
|
|
778
|
+
// Read the optional `locale:` property from the file's `nowline` directive.
|
|
779
|
+
// Layout receives an already-parsed AST, so this is a cheap lookup with no
|
|
780
|
+
// validation work — that's the validator's job in `@nowline/core`.
|
|
781
|
+
function directiveLocale(file) {
|
|
782
|
+
const prop = file.directive?.properties.find((p) => stripColon(p.key) === 'locale');
|
|
783
|
+
return prop?.value;
|
|
784
|
+
}
|
|
785
|
+
function stripColon(key) {
|
|
786
|
+
return key.endsWith(':') ? key.slice(0, -1) : key;
|
|
787
|
+
}
|
|
788
|
+
//# sourceMappingURL=roadmap-node.js.map
|