@nowline/layout 0.8.5 → 0.9.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/dist/axis-columns.d.ts +70 -0
- package/dist/axis-columns.d.ts.map +1 -0
- package/dist/axis-columns.js +104 -0
- package/dist/axis-columns.js.map +1 -0
- package/dist/band-scale.d.ts.map +1 -1
- package/dist/calendar-resolver.d.ts +27 -0
- package/dist/calendar-resolver.d.ts.map +1 -0
- package/dist/calendar-resolver.js +132 -0
- package/dist/calendar-resolver.js.map +1 -0
- package/dist/calendar.d.ts +0 -19
- package/dist/calendar.d.ts.map +1 -1
- package/dist/calendar.js +4 -44
- package/dist/calendar.js.map +1 -1
- package/dist/canvas-width.d.ts +23 -0
- package/dist/canvas-width.d.ts.map +1 -0
- package/dist/canvas-width.js +26 -0
- package/dist/canvas-width.js.map +1 -0
- package/dist/capacity.d.ts +28 -0
- package/dist/capacity.d.ts.map +1 -1
- package/dist/capacity.js +64 -29
- package/dist/capacity.js.map +1 -1
- package/dist/chart-translation.d.ts +22 -0
- package/dist/chart-translation.d.ts.map +1 -0
- package/dist/chart-translation.js +83 -0
- package/dist/chart-translation.js.map +1 -0
- package/dist/container-header-geometry.d.ts +64 -0
- package/dist/container-header-geometry.d.ts.map +1 -0
- package/dist/container-header-geometry.js +89 -0
- package/dist/container-header-geometry.js.map +1 -0
- package/dist/dsl-utils.d.ts +3 -4
- package/dist/dsl-utils.d.ts.map +1 -1
- package/dist/dsl-utils.js +8 -25
- package/dist/dsl-utils.js.map +1 -1
- package/dist/edge-routing.d.ts +69 -78
- package/dist/edge-routing.d.ts.map +1 -1
- package/dist/edge-routing.js +271 -401
- package/dist/edge-routing.js.map +1 -1
- package/dist/frame-tab-geometry.d.ts +24 -14
- package/dist/frame-tab-geometry.d.ts.map +1 -1
- package/dist/frame-tab-geometry.js +69 -23
- package/dist/frame-tab-geometry.js.map +1 -1
- package/dist/header-card-geometry.d.ts +12 -0
- package/dist/header-card-geometry.d.ts.map +1 -1
- package/dist/header-card-geometry.js +14 -2
- package/dist/header-card-geometry.js.map +1 -1
- package/dist/header-logo.d.ts +28 -0
- package/dist/header-logo.d.ts.map +1 -0
- package/dist/header-logo.js +51 -0
- package/dist/header-logo.js.map +1 -0
- package/dist/i18n.d.ts +11 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +11 -1
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +20 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -7
- package/dist/index.js.map +1 -1
- package/dist/inline-date-pin-geometry.d.ts +19 -11
- package/dist/inline-date-pin-geometry.d.ts.map +1 -1
- package/dist/inline-date-pin-geometry.js +45 -46
- package/dist/inline-date-pin-geometry.js.map +1 -1
- package/dist/item-bar-geometry.d.ts +192 -4
- package/dist/item-bar-geometry.d.ts.map +1 -1
- package/dist/item-bar-geometry.js +258 -5
- package/dist/item-bar-geometry.js.map +1 -1
- package/dist/item-port-geometry.d.ts +13 -0
- package/dist/item-port-geometry.d.ts.map +1 -0
- package/dist/item-port-geometry.js +63 -0
- package/dist/item-port-geometry.js.map +1 -0
- package/dist/layout-context.d.ts +115 -51
- package/dist/layout-context.d.ts.map +1 -1
- package/dist/layout-context.js +96 -5
- package/dist/layout-context.js.map +1 -1
- package/dist/layout-insights.d.ts.map +1 -1
- package/dist/layout-insights.js +73 -1
- package/dist/layout-insights.js.map +1 -1
- package/dist/layout.d.ts +35 -0
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +18 -1315
- package/dist/layout.js.map +1 -1
- package/dist/nodes/anchor-node.d.ts.map +1 -1
- package/dist/nodes/anchor-node.js +11 -4
- package/dist/nodes/anchor-node.js.map +1 -1
- package/dist/nodes/content-extent.d.ts +24 -0
- package/dist/nodes/content-extent.d.ts.map +1 -0
- package/dist/nodes/content-extent.js +100 -0
- package/dist/nodes/content-extent.js.map +1 -0
- package/dist/nodes/dependency-edges.d.ts +6 -0
- package/dist/nodes/dependency-edges.d.ts.map +1 -0
- package/dist/nodes/dependency-edges.js +148 -0
- package/dist/nodes/dependency-edges.js.map +1 -0
- package/dist/nodes/footnote-node.d.ts +24 -3
- package/dist/nodes/footnote-node.d.ts.map +1 -1
- package/dist/nodes/footnote-node.js +129 -25
- package/dist/nodes/footnote-node.js.map +1 -1
- package/dist/nodes/group-node.d.ts +10 -21
- package/dist/nodes/group-node.d.ts.map +1 -1
- package/dist/nodes/group-node.js +79 -154
- package/dist/nodes/group-node.js.map +1 -1
- package/dist/nodes/header-node.d.ts +56 -0
- package/dist/nodes/header-node.d.ts.map +1 -0
- package/dist/nodes/header-node.js +163 -0
- package/dist/nodes/header-node.js.map +1 -0
- package/dist/nodes/include-node.d.ts +22 -12
- package/dist/nodes/include-node.d.ts.map +1 -1
- package/dist/nodes/include-node.js +95 -62
- package/dist/nodes/include-node.js.map +1 -1
- package/dist/nodes/item-measure.d.ts +135 -0
- package/dist/nodes/item-measure.d.ts.map +1 -0
- package/dist/nodes/item-measure.js +514 -0
- package/dist/nodes/item-measure.js.map +1 -0
- package/dist/nodes/item-node.d.ts +162 -9
- package/dist/nodes/item-node.d.ts.map +1 -1
- package/dist/nodes/item-node.js +256 -69
- package/dist/nodes/item-node.js.map +1 -1
- package/dist/nodes/marker-band.d.ts +118 -0
- package/dist/nodes/marker-band.d.ts.map +1 -0
- package/dist/nodes/marker-band.js +303 -0
- package/dist/nodes/marker-band.js.map +1 -0
- package/dist/nodes/marker-geometry.js +1 -1
- package/dist/nodes/marker-geometry.js.map +1 -1
- package/dist/nodes/milestone-node.d.ts +112 -15
- package/dist/nodes/milestone-node.d.ts.map +1 -1
- package/dist/nodes/milestone-node.js +246 -95
- package/dist/nodes/milestone-node.js.map +1 -1
- package/dist/nodes/nowline-node.d.ts +8 -0
- package/dist/nodes/nowline-node.d.ts.map +1 -0
- package/dist/nodes/nowline-node.js +74 -0
- package/dist/nodes/nowline-node.js.map +1 -0
- package/dist/nodes/parallel-node.d.ts +7 -12
- package/dist/nodes/parallel-node.d.ts.map +1 -1
- package/dist/nodes/parallel-node.js +66 -32
- package/dist/nodes/parallel-node.js.map +1 -1
- package/dist/nodes/region-markers.d.ts +41 -0
- package/dist/nodes/region-markers.d.ts.map +1 -0
- package/dist/nodes/region-markers.js +130 -0
- package/dist/nodes/region-markers.js.map +1 -0
- package/dist/nodes/roadmap-node.d.ts +9 -72
- package/dist/nodes/roadmap-node.d.ts.map +1 -1
- package/dist/nodes/roadmap-node.js +38 -788
- package/dist/nodes/roadmap-node.js.map +1 -1
- package/dist/nodes/row-track.d.ts +33 -0
- package/dist/nodes/row-track.d.ts.map +1 -0
- package/dist/nodes/row-track.js +170 -0
- package/dist/nodes/row-track.js.map +1 -0
- package/dist/nodes/sequence-item.d.ts +25 -0
- package/dist/nodes/sequence-item.d.ts.map +1 -0
- package/dist/nodes/sequence-item.js +396 -0
- package/dist/nodes/sequence-item.js.map +1 -0
- package/dist/nodes/sequence-one.d.ts +5 -0
- package/dist/nodes/sequence-one.d.ts.map +1 -0
- package/dist/nodes/sequence-one.js +26 -0
- package/dist/nodes/sequence-one.js.map +1 -0
- package/dist/nodes/swimlane-node.d.ts +5 -17
- package/dist/nodes/swimlane-node.d.ts.map +1 -1
- package/dist/nodes/swimlane-node.js +62 -147
- package/dist/nodes/swimlane-node.js.map +1 -1
- package/dist/nodes/wave-node.d.ts +52 -0
- package/dist/nodes/wave-node.d.ts.map +1 -0
- package/dist/nodes/wave-node.js +483 -0
- package/dist/nodes/wave-node.js.map +1 -0
- package/dist/non-working-display.d.ts +14 -0
- package/dist/non-working-display.d.ts.map +1 -0
- package/dist/non-working-display.js +28 -0
- package/dist/non-working-display.js.map +1 -0
- package/dist/phases/date-window.d.ts +48 -0
- package/dist/phases/date-window.d.ts.map +1 -0
- package/dist/phases/date-window.js +130 -0
- package/dist/phases/date-window.js.map +1 -0
- package/dist/phases/finalize.d.ts +198 -0
- package/dist/phases/finalize.d.ts.map +1 -0
- package/dist/phases/finalize.js +1058 -0
- package/dist/phases/finalize.js.map +1 -0
- package/dist/phases/measure.d.ts +77 -0
- package/dist/phases/measure.d.ts.map +1 -0
- package/dist/phases/measure.js +222 -0
- package/dist/phases/measure.js.map +1 -0
- package/dist/phases/place.d.ts +28 -0
- package/dist/phases/place.d.ts.map +1 -0
- package/dist/phases/place.js +225 -0
- package/dist/phases/place.js.map +1 -0
- package/dist/renderable.d.ts.map +1 -1
- package/dist/renderable.js +23 -3
- package/dist/renderable.js.map +1 -1
- package/dist/resolve-today.d.ts +11 -1
- package/dist/resolve-today.d.ts.map +1 -1
- package/dist/resolve-today.js +26 -12
- package/dist/resolve-today.js.map +1 -1
- package/dist/row-packer.d.ts +42 -16
- package/dist/row-packer.d.ts.map +1 -1
- package/dist/row-packer.js +51 -78
- package/dist/row-packer.js.map +1 -1
- package/dist/schedule.d.ts +58 -1
- package/dist/schedule.d.ts.map +1 -1
- package/dist/schedule.js +280 -106
- package/dist/schedule.js.map +1 -1
- package/dist/start-rules.d.ts +27 -0
- package/dist/start-rules.d.ts.map +1 -0
- package/dist/start-rules.js +91 -0
- package/dist/start-rules.js.map +1 -0
- package/dist/start-terms.d.ts +23 -0
- package/dist/start-terms.d.ts.map +1 -0
- package/dist/start-terms.js +27 -0
- package/dist/start-terms.js.map +1 -0
- package/dist/status-dot.d.ts +46 -0
- package/dist/status-dot.d.ts.map +1 -0
- package/dist/status-dot.js +78 -0
- package/dist/status-dot.js.map +1 -0
- package/dist/style-resolution.d.ts.map +1 -1
- package/dist/style-resolution.js +23 -43
- package/dist/style-resolution.js.map +1 -1
- package/dist/text-measure.d.ts +28 -0
- package/dist/text-measure.d.ts.map +1 -0
- package/dist/text-measure.js +70 -0
- package/dist/text-measure.js.map +1 -0
- package/dist/themes/contrast.d.ts +4 -0
- package/dist/themes/contrast.d.ts.map +1 -0
- package/dist/themes/contrast.js +32 -0
- package/dist/themes/contrast.js.map +1 -0
- package/dist/themes/dark.d.ts.map +1 -1
- package/dist/themes/dark.js +28 -1
- package/dist/themes/dark.js.map +1 -1
- package/dist/themes/grayscale.d.ts.map +1 -1
- package/dist/themes/grayscale.js +28 -1
- package/dist/themes/grayscale.js.map +1 -1
- package/dist/themes/index.d.ts +4 -11
- package/dist/themes/index.d.ts.map +1 -1
- package/dist/themes/index.js +6 -4
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/light.d.ts.map +1 -1
- package/dist/themes/light.js +28 -1
- package/dist/themes/light.js.map +1 -1
- package/dist/themes/shape.d.ts +31 -0
- package/dist/themes/shape.d.ts.map +1 -1
- package/dist/themes/shared.d.ts +44 -50
- package/dist/themes/shared.d.ts.map +1 -1
- package/dist/themes/shared.js +79 -83
- package/dist/themes/shared.js.map +1 -1
- package/dist/time-scale.d.ts +42 -6
- package/dist/time-scale.d.ts.map +1 -1
- package/dist/time-scale.js +109 -17
- package/dist/time-scale.js.map +1 -1
- package/dist/types.d.ts +528 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/view-preset.d.ts +61 -6
- package/dist/view-preset.d.ts.map +1 -1
- package/dist/view-preset.js +439 -24
- package/dist/view-preset.js.map +1 -1
- package/dist/wave-barrier.d.ts +107 -0
- package/dist/wave-barrier.d.ts.map +1 -0
- package/dist/wave-barrier.js +174 -0
- package/dist/wave-barrier.js.map +1 -0
- package/dist/wave-hatch.d.ts +4 -0
- package/dist/wave-hatch.d.ts.map +1 -0
- package/dist/wave-hatch.js +21 -0
- package/dist/wave-hatch.js.map +1 -0
- package/dist/wave-layout.d.ts +99 -0
- package/dist/wave-layout.d.ts.map +1 -0
- package/dist/wave-layout.js +130 -0
- package/dist/wave-layout.js.map +1 -0
- package/dist/working-calendar.d.ts +128 -4
- package/dist/working-calendar.d.ts.map +1 -1
- package/dist/working-calendar.js +725 -51
- package/dist/working-calendar.js.map +1 -1
- package/dist/working-span.d.ts +11 -0
- package/dist/working-span.d.ts.map +1 -0
- package/dist/working-span.js +18 -0
- package/dist/working-span.js.map +1 -0
- package/package.json +7 -7
- package/src/axis-columns.ts +140 -0
- package/src/calendar-resolver.ts +161 -0
- package/src/calendar.ts +4 -45
- package/src/canvas-width.ts +36 -0
- package/src/capacity.ts +71 -30
- package/src/chart-translation.ts +88 -0
- package/src/container-header-geometry.ts +116 -0
- package/src/dsl-utils.ts +8 -26
- package/src/edge-routing.ts +347 -491
- package/src/frame-tab-geometry.ts +78 -25
- package/src/header-card-geometry.ts +17 -2
- package/src/header-logo.ts +67 -0
- package/src/i18n.ts +22 -1
- package/src/index.ts +97 -12
- package/src/inline-date-pin-geometry.ts +55 -58
- package/src/item-bar-geometry.ts +341 -5
- package/src/item-port-geometry.ts +69 -0
- package/src/layout-context.ts +210 -72
- package/src/layout-insights.ts +109 -2
- package/src/layout.ts +57 -1530
- package/src/nodes/anchor-node.ts +11 -4
- package/src/nodes/content-extent.ts +107 -0
- package/src/nodes/dependency-edges.ts +171 -0
- package/src/nodes/footnote-node.ts +171 -31
- package/src/nodes/group-node.ts +92 -215
- package/src/nodes/header-node.ts +255 -0
- package/src/nodes/include-node.ts +124 -110
- package/src/nodes/item-measure.ts +723 -0
- package/src/nodes/item-node.ts +358 -80
- package/src/nodes/marker-band.ts +406 -0
- package/src/nodes/marker-geometry.ts +1 -1
- package/src/nodes/milestone-node.ts +321 -136
- package/src/nodes/nowline-node.ts +87 -0
- package/src/nodes/parallel-node.ts +69 -39
- package/src/nodes/region-markers.ts +194 -0
- package/src/nodes/roadmap-node.ts +46 -950
- package/src/nodes/row-track.ts +217 -0
- package/src/nodes/sequence-item.ts +433 -0
- package/src/nodes/sequence-one.ts +40 -0
- package/src/nodes/swimlane-node.ts +66 -206
- package/src/nodes/wave-node.ts +609 -0
- package/src/non-working-display.ts +32 -0
- package/src/phases/date-window.ts +191 -0
- package/src/phases/finalize.ts +1303 -0
- package/src/phases/measure.ts +329 -0
- package/src/phases/place.ts +318 -0
- package/src/renderable.ts +23 -3
- package/src/resolve-today.ts +28 -25
- package/src/row-packer.ts +85 -84
- package/src/schedule.ts +362 -114
- package/src/start-rules.ts +113 -0
- package/src/start-terms.ts +38 -0
- package/src/status-dot.ts +81 -0
- package/src/style-resolution.ts +27 -57
- package/src/text-measure.ts +71 -0
- package/src/themes/contrast.ts +34 -0
- package/src/themes/dark.ts +28 -1
- package/src/themes/grayscale.ts +28 -1
- package/src/themes/index.ts +10 -7
- package/src/themes/light.ts +28 -1
- package/src/themes/shape.ts +38 -0
- package/src/themes/shared.ts +85 -93
- package/src/time-scale.ts +117 -19
- package/src/types.ts +529 -20
- package/src/view-preset.ts +520 -27
- package/src/wave-barrier.ts +242 -0
- package/src/wave-hatch.ts +25 -0
- package/src/wave-layout.ts +203 -0
- package/src/working-calendar.ts +875 -57
- package/src/working-span.ts +19 -0
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
// Phase 4, finalize (specs/layout-phases.md §3.5): every change after
|
|
2
|
+
// placement runs here, as one ordered pass of named steps over an explicit
|
|
3
|
+
// `FinalizeContext`. The steps grow the canvas, extend the axis, pack the
|
|
4
|
+
// marker band, grow the header rows and build the pieces of the model that
|
|
5
|
+
// read final geometry; none of them places content again.
|
|
6
|
+
//
|
|
7
|
+
// The pass is a constant table. Each step declares the slots it reads and
|
|
8
|
+
// writes, and `checkFinalizeOrder` checks the table against the slot
|
|
9
|
+
// rules: the canvas, the scale and the marker band are final after f3, f2
|
|
10
|
+
// and f5, the chart top, the header rows' bottom and the lane bottom after
|
|
11
|
+
// f7, and the chart bottom is a cursor that f7, f8 and f13 advance. The
|
|
12
|
+
// guards on `CanvasExtent`, `finalScale()`, `markerBand()`,
|
|
13
|
+
// `headerRowsTop()` and `headerRowsBottom()` throw when a slot is read
|
|
14
|
+
// before its writer ran, so a reordering that slips past the check still
|
|
15
|
+
// fails.
|
|
16
|
+
|
|
17
|
+
import type { NowlineFile, ResolveResult } from '@nowline/core';
|
|
18
|
+
import { AxisColumns } from '../axis-columns.js';
|
|
19
|
+
import { daysBetween } from '../calendar.js';
|
|
20
|
+
import {
|
|
21
|
+
translateIncludeY,
|
|
22
|
+
translateMilestoneChartY,
|
|
23
|
+
translateSwimlaneY,
|
|
24
|
+
} from '../chart-translation.js';
|
|
25
|
+
import { localeStrings } from '../i18n.js';
|
|
26
|
+
import type { LayoutOptions, LayoutResult } from '../layout.js';
|
|
27
|
+
import type { LayoutContext } from '../layout-context.js';
|
|
28
|
+
import { buildAnchors } from '../nodes/anchor-node.js';
|
|
29
|
+
import { maxLeafItemRightX, maxSpilledCaptionRightX } from '../nodes/content-extent.js';
|
|
30
|
+
import { buildDependencies, collectItems } from '../nodes/dependency-edges.js';
|
|
31
|
+
import { buildFootnotePanel } from '../nodes/footnote-node.js';
|
|
32
|
+
import type { PositionedHeaderParts } from '../nodes/header-node.js';
|
|
33
|
+
import { fitIncludeRegionWidth, includeRegionWidth } from '../nodes/include-node.js';
|
|
34
|
+
import {
|
|
35
|
+
calendarMarkerEntries,
|
|
36
|
+
type MarkerBand,
|
|
37
|
+
type MarkerEntity,
|
|
38
|
+
markerArrows,
|
|
39
|
+
markerLabelsRightX,
|
|
40
|
+
markerRowPlacements,
|
|
41
|
+
nonWorkingRunMarkerId,
|
|
42
|
+
packMarkerRow,
|
|
43
|
+
workingExceptionMarkerId,
|
|
44
|
+
} from '../nodes/marker-band.js';
|
|
45
|
+
import {
|
|
46
|
+
MARKER_BOLD_WIDTH_FACTOR,
|
|
47
|
+
MARKER_LABEL_GAP_PX,
|
|
48
|
+
MARKER_ROW_PITCH_PX,
|
|
49
|
+
} from '../nodes/marker-geometry.js';
|
|
50
|
+
import { positionMilestones } from '../nodes/milestone-node.js';
|
|
51
|
+
import { buildNowline } from '../nodes/nowline-node.js';
|
|
52
|
+
import { regionMarkerCanvasRightX } from '../nodes/region-markers.js';
|
|
53
|
+
import { type BuiltWaves, buildWaveLegend, buildWaves } from '../nodes/wave-node.js';
|
|
54
|
+
import { estimateTextWidth } from '../text-measure.js';
|
|
55
|
+
import {
|
|
56
|
+
ATTRIBUTION_GLYPH_HEIGHT,
|
|
57
|
+
ATTRIBUTION_GLYPH_WIDTH,
|
|
58
|
+
GUTTER_PX,
|
|
59
|
+
NOW_PILL_HEIGHT_PX,
|
|
60
|
+
WAVE_LEGEND_GAP_PX,
|
|
61
|
+
} from '../themes/shared.js';
|
|
62
|
+
import { TimeScale } from '../time-scale.js';
|
|
63
|
+
import type {
|
|
64
|
+
BoundingBox,
|
|
65
|
+
MarkerRowPlacement,
|
|
66
|
+
PositionedAnchor,
|
|
67
|
+
PositionedDependencyEdge,
|
|
68
|
+
PositionedFootnoteArea,
|
|
69
|
+
PositionedMilestone,
|
|
70
|
+
PositionedNowline,
|
|
71
|
+
PositionedRoadmap,
|
|
72
|
+
PositionedWaveLegend,
|
|
73
|
+
} from '../types.js';
|
|
74
|
+
import { buildHeaderTicks, tickBoundaryAtOrAfter } from '../view-preset.js';
|
|
75
|
+
import type { DateWindow, RoadmapScope } from './date-window.js';
|
|
76
|
+
import { headerRowsTimelineY, type MeasuredRoadmap, setCalendarRuns } from './measure.js';
|
|
77
|
+
import type { PlacedChart } from './place.js';
|
|
78
|
+
|
|
79
|
+
/** Gap (px) between the chart bottom and a mirrored bottom tick panel. */
|
|
80
|
+
const BOTTOM_TICK_PANEL_GAP_PX = 8;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The canvas right edge. `grow` is the single mutator: every artifact that
|
|
84
|
+
* wants the canvas wider passes the absolute x it must contain, its
|
|
85
|
+
* breathing room (typically `GUTTER_PX`) included. Today: item caption
|
|
86
|
+
* spills (f1), the timeline extension (f2) and marker-row labels (f3).
|
|
87
|
+
* f3 freezes it; `final` reads it only after that.
|
|
88
|
+
*
|
|
89
|
+
* The now-pill is intentionally NOT a contributor — when the line lands
|
|
90
|
+
* close to either edge, `buildNowline` switches the pill to "flag" mode
|
|
91
|
+
* (squared edge against the line, rounded edge into the chart), so the
|
|
92
|
+
* pill always fits inside the canvas.
|
|
93
|
+
*/
|
|
94
|
+
export class CanvasExtent {
|
|
95
|
+
private frozen = false;
|
|
96
|
+
|
|
97
|
+
constructor(private rightX: number) {}
|
|
98
|
+
|
|
99
|
+
/** Grow the canvas to contain `x`; returns whether it grew. Throws once frozen. */
|
|
100
|
+
grow(x: number): boolean {
|
|
101
|
+
if (this.frozen) {
|
|
102
|
+
throw new Error('layout: the canvas grew after it was frozen');
|
|
103
|
+
}
|
|
104
|
+
if (x <= this.rightX) return false;
|
|
105
|
+
this.rightX = x;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The canvas is final from here on. */
|
|
110
|
+
freeze(): void {
|
|
111
|
+
this.frozen = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The right edge so far. Only the step that freezes the canvas (f3) reads it before `final`. */
|
|
115
|
+
get provisional(): number {
|
|
116
|
+
return this.rightX;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The final right edge. Throws before `freeze`. */
|
|
120
|
+
get final(): number {
|
|
121
|
+
if (!this.frozen) {
|
|
122
|
+
throw new Error('layout: the canvas was read before it was final');
|
|
123
|
+
}
|
|
124
|
+
return this.rightX;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The pieces of the model the steps produce. Each is set once, by its step. */
|
|
129
|
+
export interface ModelDraft {
|
|
130
|
+
/** Every marker with its final x, and how many of them are calendar labels (f3). */
|
|
131
|
+
markerEntries?: { all: MarkerEntity[]; calendarCount: number };
|
|
132
|
+
milestones?: PositionedMilestone[];
|
|
133
|
+
waves?: BuiltWaves;
|
|
134
|
+
anchors?: PositionedAnchor[];
|
|
135
|
+
edges?: PositionedDependencyEdge[];
|
|
136
|
+
nowline?: PositionedNowline | null;
|
|
137
|
+
waveLegend?: PositionedWaveLegend;
|
|
138
|
+
footnotes?: PositionedFootnoteArea;
|
|
139
|
+
header?: PositionedHeaderParts;
|
|
140
|
+
attributionBox?: BoundingBox;
|
|
141
|
+
/** The canvas height, below the attribution mark. */
|
|
142
|
+
height?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The top of the header rows on the final axis. The measure phase reserves
|
|
147
|
+
* the now-pill row only when `today` falls inside the natural window; when
|
|
148
|
+
* the axis extension (f2) brings `today` in, the pill row is reserved here
|
|
149
|
+
* (L2, specs/layout-phases.md §1.3), and the tick panel, wave strip and
|
|
150
|
+
* marker band below it move down by `shift`.
|
|
151
|
+
*/
|
|
152
|
+
export interface HeaderRowsTop {
|
|
153
|
+
timelineY: number;
|
|
154
|
+
pillRowHeight: number;
|
|
155
|
+
tickPanelY: number;
|
|
156
|
+
/** The marker band's top, where f5 packs it. */
|
|
157
|
+
markerRowY: number;
|
|
158
|
+
/** How far the tick panel, wave strip and marker band move down from the measure. */
|
|
159
|
+
shift: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reports whether a finalize step changed something and, for a step that
|
|
164
|
+
* says (`FinalizeContext.explain`), why; see `setFinalizeTrace`.
|
|
165
|
+
*/
|
|
166
|
+
export type FinalizeTrace = (step: string, changed: boolean, why?: readonly string[]) => void;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What passed the natural window when f2 grew the axis (specs/layout-phases.md
|
|
170
|
+
* §9, F3): a leaf item bar (a bug: the schedule's extent and the placement
|
|
171
|
+
* disagree), a spilled caption (its one capped column), a date-pinned marker
|
|
172
|
+
* (a dated anchor or milestone on the last day), or an after-only milestone
|
|
173
|
+
* on the window end (decision 14), in the main chart or in an isolated
|
|
174
|
+
* region. A diamond sits on its day, so its right half passes a window that
|
|
175
|
+
* ends on that day.
|
|
176
|
+
*/
|
|
177
|
+
export type TimelineExtensionCause = 'bar' | 'caption' | 'datedMarker' | 'afterMilestone';
|
|
178
|
+
|
|
179
|
+
let finalizeTrace: FinalizeTrace | undefined;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Install (or, with `undefined`, remove) the `FinalizeTrace`. Internal: the
|
|
183
|
+
* coverage register (`finalize-coverage.test.ts`) records with it which
|
|
184
|
+
* steps change something on each sample.
|
|
185
|
+
*/
|
|
186
|
+
export function setFinalizeTrace(trace: FinalizeTrace | undefined): void {
|
|
187
|
+
finalizeTrace = trace;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class FinalizeContext {
|
|
191
|
+
/** The canvas right edge: `grow` until f3 freezes it, `final` only after. */
|
|
192
|
+
readonly canvas: CanvasExtent;
|
|
193
|
+
/** Chart top, bottom of the lanes, bottom including the panels below them. */
|
|
194
|
+
readonly chartY: { top: number; swimlaneBottom: number; bottom: number };
|
|
195
|
+
/** Pieces of the model the steps produce. */
|
|
196
|
+
readonly draft: ModelDraft = {};
|
|
197
|
+
private scaleIsFinal = false;
|
|
198
|
+
private band: MarkerBand | undefined;
|
|
199
|
+
private rowsTop: HeaderRowsTop | undefined;
|
|
200
|
+
private rowsBottom: number | undefined;
|
|
201
|
+
private why: readonly string[] | undefined;
|
|
202
|
+
|
|
203
|
+
constructor(
|
|
204
|
+
readonly file: NowlineFile,
|
|
205
|
+
readonly resolved: ResolveResult,
|
|
206
|
+
readonly options: LayoutOptions,
|
|
207
|
+
readonly scope: RoadmapScope,
|
|
208
|
+
readonly window: DateWindow,
|
|
209
|
+
readonly measured: MeasuredRoadmap,
|
|
210
|
+
readonly chart: PlacedChart,
|
|
211
|
+
) {
|
|
212
|
+
this.canvas = new CanvasExtent(measured.naturalChartRightX);
|
|
213
|
+
// The chart extent lives on the placement context, which the node
|
|
214
|
+
// builders (anchors, waves, the now-line) read; these accessors are
|
|
215
|
+
// the finalize steps' view of it.
|
|
216
|
+
const ctx = chart.ctx;
|
|
217
|
+
this.chartY = {
|
|
218
|
+
get top(): number {
|
|
219
|
+
return ctx.chartTopY;
|
|
220
|
+
},
|
|
221
|
+
set top(y: number) {
|
|
222
|
+
ctx.chartTopY = y;
|
|
223
|
+
},
|
|
224
|
+
get swimlaneBottom(): number {
|
|
225
|
+
return ctx.swimlaneBottomY;
|
|
226
|
+
},
|
|
227
|
+
set swimlaneBottom(y: number) {
|
|
228
|
+
ctx.swimlaneBottomY = y;
|
|
229
|
+
},
|
|
230
|
+
get bottom(): number {
|
|
231
|
+
return ctx.chartBottomY;
|
|
232
|
+
},
|
|
233
|
+
set bottom(y: number) {
|
|
234
|
+
ctx.chartBottomY = y;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The placement context of the kept pass. */
|
|
240
|
+
get ctx(): LayoutContext {
|
|
241
|
+
return this.chart.ctx;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The axis scale. f2 may replace it; `finalScale()` throws before f2 ran. */
|
|
245
|
+
get scale(): TimeScale {
|
|
246
|
+
return this.chart.ctx.scale;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
finalScale(): TimeScale {
|
|
250
|
+
if (!this.scaleIsFinal) {
|
|
251
|
+
throw new Error('layout: the final scale was read before the timeline extension');
|
|
252
|
+
}
|
|
253
|
+
return this.chart.ctx.scale;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Replace the axis scale with an extended one (f2 only). */
|
|
257
|
+
extendScale(scale: TimeScale): void {
|
|
258
|
+
if (this.scaleIsFinal) {
|
|
259
|
+
throw new Error('layout: the scale was extended after it was final');
|
|
260
|
+
}
|
|
261
|
+
this.chart.ctx.scale = scale;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The scale is final from here on (f2, whether or not it extended it). */
|
|
265
|
+
settleScale(): void {
|
|
266
|
+
this.scaleIsFinal = true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The final marker pack, set by f5; reading it earlier throws. */
|
|
270
|
+
markerBand(): MarkerBand {
|
|
271
|
+
if (!this.band) {
|
|
272
|
+
throw new Error('layout: the marker band was read before the final pack');
|
|
273
|
+
}
|
|
274
|
+
return this.band;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Set the final marker pack (f5 only); the context's placements become its. */
|
|
278
|
+
setMarkerBand(band: MarkerBand): void {
|
|
279
|
+
if (this.band) {
|
|
280
|
+
throw new Error('layout: the marker band was packed twice');
|
|
281
|
+
}
|
|
282
|
+
this.band = band;
|
|
283
|
+
this.chart.ctx.markerRowPlacements = band.placements;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The header rows' top on the final axis. It reads the final scale, so
|
|
288
|
+
* it throws before f2; f5 packs the marker band at it and f7 moves the
|
|
289
|
+
* rows to it.
|
|
290
|
+
*/
|
|
291
|
+
headerRowsTop(): HeaderRowsTop {
|
|
292
|
+
if (!this.rowsTop) {
|
|
293
|
+
this.rowsTop = resolveHeaderRowsTop(
|
|
294
|
+
this.measured,
|
|
295
|
+
this.options.today,
|
|
296
|
+
this.finalScale(),
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return this.rowsTop;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The bottom of the grown header rows, set by f7; reading it earlier throws. */
|
|
303
|
+
headerRowsBottom(): number {
|
|
304
|
+
if (this.rowsBottom === undefined) {
|
|
305
|
+
throw new Error('layout: the header rows bottom was read before the rows grew');
|
|
306
|
+
}
|
|
307
|
+
return this.rowsBottom;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Set the bottom of the grown header rows (f7 only). */
|
|
311
|
+
setHeaderRowsBottom(y: number): void {
|
|
312
|
+
if (this.rowsBottom !== undefined) {
|
|
313
|
+
throw new Error('layout: the header rows grew twice');
|
|
314
|
+
}
|
|
315
|
+
this.rowsBottom = y;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Say why the running step changed something; its report carries it. */
|
|
319
|
+
explain(why: readonly string[]): void {
|
|
320
|
+
this.why = why;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Report a step's effect to the trace, when one is installed. */
|
|
324
|
+
report(step: string, changed: boolean): void {
|
|
325
|
+
finalizeTrace?.(step, changed, this.why);
|
|
326
|
+
this.why = undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The header rows' top once the axis is final. When the measure phase left
|
|
332
|
+
* the pill row at 0 and `today` lies on the final axis, f2 brought it in:
|
|
333
|
+
* the rows grow by `NOW_PILL_HEIGHT_PX` above the tick panel, and the
|
|
334
|
+
* timeline y follows the beside-card rule (`headerRowsTimelineY`), so a
|
|
335
|
+
* card that already pushed the rows down absorbs some or all of the pill
|
|
336
|
+
* row instead of the rows growing by its full height.
|
|
337
|
+
*/
|
|
338
|
+
export function resolveHeaderRowsTop(
|
|
339
|
+
measured: MeasuredRoadmap,
|
|
340
|
+
today: Date | undefined,
|
|
341
|
+
scale: TimeScale,
|
|
342
|
+
): HeaderRowsTop {
|
|
343
|
+
const { bands, header } = measured;
|
|
344
|
+
const pillRowBroughtIn =
|
|
345
|
+
bands.pillRowHeight === 0 &&
|
|
346
|
+
today !== undefined &&
|
|
347
|
+
scale.forwardWithinDomain(today) !== null;
|
|
348
|
+
if (!pillRowBroughtIn) {
|
|
349
|
+
return {
|
|
350
|
+
timelineY: bands.timelineY,
|
|
351
|
+
pillRowHeight: bands.pillRowHeight,
|
|
352
|
+
tickPanelY: bands.tickPanelY,
|
|
353
|
+
markerRowY: bands.markerRowY,
|
|
354
|
+
shift: 0,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const timelineY = headerRowsTimelineY(header, NOW_PILL_HEIGHT_PX + bands.rowsHeight);
|
|
358
|
+
const tickPanelY = timelineY + NOW_PILL_HEIGHT_PX;
|
|
359
|
+
const shift = tickPanelY - bands.tickPanelY;
|
|
360
|
+
return {
|
|
361
|
+
timelineY,
|
|
362
|
+
pillRowHeight: NOW_PILL_HEIGHT_PX,
|
|
363
|
+
tickPanelY,
|
|
364
|
+
markerRowY: bands.markerRowY + shift,
|
|
365
|
+
shift,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* What a finalize step reads or writes. `placement` is everything place
|
|
371
|
+
* left (lanes, regions, items, milestone x, the date-pinned markers),
|
|
372
|
+
* `window` the date window and `headerBands` the measured header rows;
|
|
373
|
+
* the rest are the slots finalize produces.
|
|
374
|
+
*/
|
|
375
|
+
export type FinalizeSlot =
|
|
376
|
+
| 'placement'
|
|
377
|
+
| 'window'
|
|
378
|
+
| 'headerBands'
|
|
379
|
+
| 'canvas'
|
|
380
|
+
| 'scale'
|
|
381
|
+
| 'timeline'
|
|
382
|
+
| 'calendarRuns'
|
|
383
|
+
| 'markerEntries'
|
|
384
|
+
| 'laneWidths'
|
|
385
|
+
| 'markerBand'
|
|
386
|
+
| 'milestones'
|
|
387
|
+
| 'calendarLabels'
|
|
388
|
+
| 'markerMidpoints'
|
|
389
|
+
| 'chartTop'
|
|
390
|
+
| 'rowsBottom'
|
|
391
|
+
| 'laneBottom'
|
|
392
|
+
| 'chartBottom'
|
|
393
|
+
| 'laneY'
|
|
394
|
+
| 'waves'
|
|
395
|
+
| 'anchors'
|
|
396
|
+
| 'edges'
|
|
397
|
+
| 'nowline'
|
|
398
|
+
| 'legend'
|
|
399
|
+
| 'footnotes'
|
|
400
|
+
| 'header'
|
|
401
|
+
| 'attribution';
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The rule a slot follows:
|
|
405
|
+
* - `input`: nothing in finalize writes it.
|
|
406
|
+
* - `final`: steps up to `finalAfter` may write it, none after; only
|
|
407
|
+
* `finalAfter` itself may read it before then.
|
|
408
|
+
* - `cursor`: only the `advancers` write it (each reads it as it advances
|
|
409
|
+
* it); every other reader comes after the last advancer.
|
|
410
|
+
* - `product`: one step writes it; readers come after that step.
|
|
411
|
+
*/
|
|
412
|
+
export type SlotRule =
|
|
413
|
+
| { kind: 'input' }
|
|
414
|
+
| { kind: 'final'; finalAfter: string }
|
|
415
|
+
| { kind: 'cursor'; advancers: readonly string[] }
|
|
416
|
+
| { kind: 'product' };
|
|
417
|
+
|
|
418
|
+
export const SLOT_RULES: Readonly<Record<FinalizeSlot, SlotRule>> = {
|
|
419
|
+
placement: { kind: 'input' },
|
|
420
|
+
window: { kind: 'input' },
|
|
421
|
+
headerBands: { kind: 'input' },
|
|
422
|
+
canvas: { kind: 'final', finalAfter: 'growCanvasForMarkerLabels' },
|
|
423
|
+
scale: { kind: 'final', finalAfter: 'extendTimeline' },
|
|
424
|
+
markerBand: { kind: 'final', finalAfter: 'packMarkers' },
|
|
425
|
+
chartTop: { kind: 'final', finalAfter: 'growHeaderRows' },
|
|
426
|
+
rowsBottom: { kind: 'final', finalAfter: 'growHeaderRows' },
|
|
427
|
+
laneBottom: { kind: 'final', finalAfter: 'growHeaderRows' },
|
|
428
|
+
chartBottom: {
|
|
429
|
+
kind: 'cursor',
|
|
430
|
+
advancers: ['growHeaderRows', 'bottomTickPanel', 'waveLegend'],
|
|
431
|
+
},
|
|
432
|
+
timeline: {
|
|
433
|
+
kind: 'cursor',
|
|
434
|
+
advancers: ['extendTimeline', 'growHeaderRows', 'bottomTickPanel', 'waves'],
|
|
435
|
+
},
|
|
436
|
+
milestones: { kind: 'cursor', advancers: ['placeMarkers', 'growHeaderRows'] },
|
|
437
|
+
markerMidpoints: { kind: 'cursor', advancers: ['placeMarkers', 'anchors'] },
|
|
438
|
+
calendarRuns: { kind: 'product' },
|
|
439
|
+
markerEntries: { kind: 'product' },
|
|
440
|
+
laneWidths: { kind: 'product' },
|
|
441
|
+
calendarLabels: { kind: 'product' },
|
|
442
|
+
laneY: { kind: 'product' },
|
|
443
|
+
waves: { kind: 'product' },
|
|
444
|
+
anchors: { kind: 'product' },
|
|
445
|
+
edges: { kind: 'product' },
|
|
446
|
+
nowline: { kind: 'product' },
|
|
447
|
+
legend: { kind: 'product' },
|
|
448
|
+
footnotes: { kind: 'product' },
|
|
449
|
+
header: { kind: 'product' },
|
|
450
|
+
attribution: { kind: 'product' },
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
export interface FinalizeStep {
|
|
454
|
+
readonly name: string;
|
|
455
|
+
readonly reads: readonly FinalizeSlot[];
|
|
456
|
+
readonly writes: readonly FinalizeSlot[];
|
|
457
|
+
/** Run the step; returns whether it changed something (for the coverage trace). */
|
|
458
|
+
run(fx: FinalizeContext): boolean;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Every way `steps` breaks `rules`, as one message each; empty when the
|
|
463
|
+
* order holds.
|
|
464
|
+
*/
|
|
465
|
+
export function checkFinalizeOrder(
|
|
466
|
+
steps: readonly FinalizeStep[],
|
|
467
|
+
rules: Readonly<Record<FinalizeSlot, SlotRule>> = SLOT_RULES,
|
|
468
|
+
): string[] {
|
|
469
|
+
const problems: string[] = [];
|
|
470
|
+
const indexOf = new Map<string, number>();
|
|
471
|
+
steps.forEach((step, i) => {
|
|
472
|
+
if (indexOf.has(step.name)) problems.push(`step ${step.name} is listed twice`);
|
|
473
|
+
indexOf.set(step.name, i);
|
|
474
|
+
});
|
|
475
|
+
const slots = new Set<FinalizeSlot>();
|
|
476
|
+
for (const step of steps) {
|
|
477
|
+
for (const slot of [...step.reads, ...step.writes]) slots.add(slot);
|
|
478
|
+
}
|
|
479
|
+
for (const slot of slots) {
|
|
480
|
+
const rule = rules[slot];
|
|
481
|
+
const writers = steps.flatMap((s, i) => (s.writes.includes(slot) ? [i] : []));
|
|
482
|
+
const readers = steps.flatMap((s, i) => (s.reads.includes(slot) ? [i] : []));
|
|
483
|
+
const name = (i: number): string => steps[i].name;
|
|
484
|
+
switch (rule.kind) {
|
|
485
|
+
case 'input':
|
|
486
|
+
for (const w of writers) problems.push(`${name(w)} writes the input ${slot}`);
|
|
487
|
+
break;
|
|
488
|
+
case 'final': {
|
|
489
|
+
const f = indexOf.get(rule.finalAfter);
|
|
490
|
+
if (f === undefined || !steps[f].writes.includes(slot)) {
|
|
491
|
+
problems.push(
|
|
492
|
+
`${rule.finalAfter} must make ${slot} final but does not write it`,
|
|
493
|
+
);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
for (const w of writers) {
|
|
497
|
+
if (w > f)
|
|
498
|
+
problems.push(`${name(w)} writes ${slot} after ${name(f)} made it final`);
|
|
499
|
+
}
|
|
500
|
+
for (const r of readers) {
|
|
501
|
+
if (r < f)
|
|
502
|
+
problems.push(`${name(r)} reads ${slot} before ${name(f)} makes it final`);
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case 'cursor': {
|
|
507
|
+
const advancers = rule.advancers.map((a) => indexOf.get(a));
|
|
508
|
+
if (advancers.some((a) => a === undefined)) {
|
|
509
|
+
problems.push(`a step that advances ${slot} is missing`);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
const last = Math.max(...(advancers as number[]));
|
|
513
|
+
for (const w of writers) {
|
|
514
|
+
if (!rule.advancers.includes(name(w))) {
|
|
515
|
+
problems.push(`${name(w)} writes ${slot}, which only its advancers write`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
for (const r of readers) {
|
|
519
|
+
if (!rule.advancers.includes(name(r)) && r < last) {
|
|
520
|
+
problems.push(`${name(r)} reads ${slot} before ${name(last)} advances it`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'product': {
|
|
526
|
+
if (writers.length !== 1) {
|
|
527
|
+
problems.push(`${slot} has ${writers.length} writers, not one`);
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
for (const r of readers) {
|
|
531
|
+
if (r < writers[0]) {
|
|
532
|
+
problems.push(
|
|
533
|
+
`${name(r)} reads ${slot} before ${name(writers[0])} writes it`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return problems;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** f1 (was P3): grow the canvas to the rightmost spilled caption. */
|
|
545
|
+
function growCanvasForSpill(fx: FinalizeContext): boolean {
|
|
546
|
+
// Otherwise the long captions on narrow charts — e.g.
|
|
547
|
+
// `examples/minimal.svg` and `tests/text-spills-right.svg` — would
|
|
548
|
+
// land outside the SVG's viewBox and clip in browsers. The marker
|
|
549
|
+
// pack (f5) sees the expanded width and picks a less aggressive
|
|
550
|
+
// label side.
|
|
551
|
+
return fx.canvas.grow(fx.chart.maxRightX + GUTTER_PX);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* f2 (was P7): extend the axis when content passes the natural window.
|
|
556
|
+
* The window is engine C's extent, which every bar's dates fall inside;
|
|
557
|
+
* what passes it is a spilled caption and the right half of a diamond on
|
|
558
|
+
* the window's last day (specs/layout-phases.md §9, F3). Measure the
|
|
559
|
+
* rightmost CONTENT BOX edge — leaf item bars (never a spilled caption,
|
|
560
|
+
* see `maxLeafItemRightX`) and marker diamonds, an isolated region's own
|
|
561
|
+
* included — and grow the window to
|
|
562
|
+
* the next tick boundary when content overflows it. A spilled caption past
|
|
563
|
+
* that end grows it by whole columns up to `AxisColumns.captionLimit`, labelled by the
|
|
564
|
+
* same rules as any other column: the item measure capped the caption to
|
|
565
|
+
* end inside it (`AxisColumns.captionCapX`). This is driven
|
|
566
|
+
* purely by boxes, never by `today`, so the `length:`-as-minimum and
|
|
567
|
+
* now-line boundary behaviour is unaffected. Origin and pixels per day
|
|
568
|
+
* stay, so every placed x stays valid.
|
|
569
|
+
*/
|
|
570
|
+
function extendTimeline(fx: FinalizeContext): boolean {
|
|
571
|
+
const { swimlanes, includes, milestones } = fx.chart;
|
|
572
|
+
const { calendar, preset: scale, startDate, spanDays, nonWorking } = fx.window;
|
|
573
|
+
const { originX, ppd } = fx.measured.axis;
|
|
574
|
+
const timeline = fx.measured.timeline;
|
|
575
|
+
let barRightX = maxLeafItemRightX(swimlanes);
|
|
576
|
+
for (const inc of includes) {
|
|
577
|
+
barRightX = Math.max(barRightX, maxLeafItemRightX(inc.nestedSwimlanes));
|
|
578
|
+
}
|
|
579
|
+
let datedRightX = 0;
|
|
580
|
+
for (const e of fx.measured.markers.datePinned) {
|
|
581
|
+
datedRightX = Math.max(datedRightX, e.centerX + e.radius);
|
|
582
|
+
}
|
|
583
|
+
let afterRightX = 0;
|
|
584
|
+
for (const m of milestones) {
|
|
585
|
+
if (m.fixed) datedRightX = Math.max(datedRightX, m.x + m.radius);
|
|
586
|
+
else afterRightX = Math.max(afterRightX, m.x + m.radius);
|
|
587
|
+
}
|
|
588
|
+
// An isolated region's own markers sit on the same axis: placement put
|
|
589
|
+
// them at their final centre, and the window already reaches their days.
|
|
590
|
+
for (const inc of includes) {
|
|
591
|
+
for (const a of inc.anchors ?? []) {
|
|
592
|
+
datedRightX = Math.max(datedRightX, a.center.x + a.radius);
|
|
593
|
+
}
|
|
594
|
+
for (const m of inc.milestones ?? []) {
|
|
595
|
+
if (m.fixed) datedRightX = Math.max(datedRightX, m.center.x + m.radius);
|
|
596
|
+
else afterRightX = Math.max(afterRightX, m.center.x + m.radius);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const maxContentRightX = Math.max(barRightX, datedRightX, afterRightX);
|
|
600
|
+
const tickDays = calendar.daysPerUnit(scale.unit);
|
|
601
|
+
// Working-day counts on both sides of the comparison: under `show` x
|
|
602
|
+
// is linear in calendar days, so the overflow is read back through
|
|
603
|
+
// the scale and the window keeps its working-day span.
|
|
604
|
+
const overflowDays = fx.scale.showsNonWorking
|
|
605
|
+
? fx.scale.indexAtX(maxContentRightX)
|
|
606
|
+
: (maxContentRightX - originX) / ppd;
|
|
607
|
+
let paddedDays =
|
|
608
|
+
overflowDays > 0
|
|
609
|
+
? tickBoundaryAtOrAfter(startDate, overflowDays, scale.unit, tickDays, calendar)
|
|
610
|
+
: 0;
|
|
611
|
+
// A spilled caption past the axis end grows it by the whole columns that
|
|
612
|
+
// hold it, up to `captionLimit`, the boundary the item measure capped the
|
|
613
|
+
// caption to; `label-every`, the default thinning and the narrow-column
|
|
614
|
+
// rules label them like any other column (specs/scheduler-unification.md
|
|
615
|
+
// §8 decision 12). Under an explicit `length:` that ends mid-column the
|
|
616
|
+
// columns are the full ones after the partial last column. A caption the
|
|
617
|
+
// cap could not bring inside (its meta line is never truncated) still
|
|
618
|
+
// grows the axis only to `captionLimit`.
|
|
619
|
+
let captionRightX = maxSpilledCaptionRightX(swimlanes);
|
|
620
|
+
for (const inc of includes) {
|
|
621
|
+
captionRightX = Math.max(captionRightX, maxSpilledCaptionRightX(inc.nestedSwimlanes));
|
|
622
|
+
}
|
|
623
|
+
const columns = new AxisColumns(fx.window, fx.scale);
|
|
624
|
+
if (captionRightX > 0) {
|
|
625
|
+
const axisDays = Math.max(spanDays, paddedDays);
|
|
626
|
+
if (columns.isPast(captionRightX, axisDays)) {
|
|
627
|
+
paddedDays = Math.max(
|
|
628
|
+
paddedDays,
|
|
629
|
+
Math.min(columns.boundaryAtOrAfterX(captionRightX), columns.captionLimit(axisDays)),
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
let extended = false;
|
|
634
|
+
if (paddedDays > spanDays) {
|
|
635
|
+
// For the trace: every kind of content past the natural window.
|
|
636
|
+
const rightX: Record<TimelineExtensionCause, number> = {
|
|
637
|
+
bar: barRightX,
|
|
638
|
+
caption: captionRightX,
|
|
639
|
+
datedMarker: datedRightX,
|
|
640
|
+
afterMilestone: afterRightX,
|
|
641
|
+
};
|
|
642
|
+
fx.explain(
|
|
643
|
+
(Object.keys(rightX) as TimelineExtensionCause[]).filter(
|
|
644
|
+
(cause) => rightX[cause] > 0 && columns.isPast(rightX[cause], spanDays),
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
const extendedEndDate = calendar.dateAtWorkingIndex(startDate, paddedDays);
|
|
648
|
+
const extendedWidth = fx.scale.showsNonWorking
|
|
649
|
+
? daysBetween(startDate, extendedEndDate) * ppd
|
|
650
|
+
: paddedDays * ppd;
|
|
651
|
+
const extendedScale = new TimeScale({
|
|
652
|
+
domain: [startDate, extendedEndDate],
|
|
653
|
+
range: [originX, originX + extendedWidth],
|
|
654
|
+
calendar,
|
|
655
|
+
nonWorking,
|
|
656
|
+
});
|
|
657
|
+
fx.extendScale(extendedScale);
|
|
658
|
+
timeline.endDate = extendedEndDate;
|
|
659
|
+
timeline.box.width = extendedWidth;
|
|
660
|
+
timeline.ticks = buildHeaderTicks(extendedScale, scale, calendar, fx.scope.locale);
|
|
661
|
+
setCalendarRuns(timeline, extendedScale, scale.unit);
|
|
662
|
+
fx.canvas.grow(originX + extendedWidth + GUTTER_PX);
|
|
663
|
+
extended = true;
|
|
664
|
+
}
|
|
665
|
+
fx.settleScale();
|
|
666
|
+
return extended;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* f3 (was P8): every marker (date-pinned anchor, date-pinned milestone,
|
|
671
|
+
* after-only milestone, titled run, working exception) with its final
|
|
672
|
+
* centerX. Calendar labels come from the final runs and exceptions: f2
|
|
673
|
+
* rebuilt them, so never the measure phase's copies. A label that fits
|
|
674
|
+
* neither side grows the canvas so the final pack places it right. The
|
|
675
|
+
* canvas is final after this step.
|
|
676
|
+
*/
|
|
677
|
+
function growCanvasForMarkerLabels(fx: FinalizeContext): boolean {
|
|
678
|
+
const timeline = fx.measured.timeline;
|
|
679
|
+
// A slipped milestone's entry moves to its slipped diamond, where its
|
|
680
|
+
// label goes, and reserves the span back to its date.
|
|
681
|
+
const slips = new Map<string, number>();
|
|
682
|
+
for (const m of fx.chart.milestones) {
|
|
683
|
+
if (m.slipX !== undefined) slips.set(m.id, m.slipX);
|
|
684
|
+
}
|
|
685
|
+
const all: MarkerEntity[] = fx.measured.markers.datePinned.map((e) => {
|
|
686
|
+
const slipX = e.isMilestone ? slips.get(e.id) : undefined;
|
|
687
|
+
return {
|
|
688
|
+
id: e.id,
|
|
689
|
+
centerX: slipX ?? e.centerX,
|
|
690
|
+
radius: e.radius,
|
|
691
|
+
title: e.title,
|
|
692
|
+
fontSize: e.fontSize,
|
|
693
|
+
bold: e.bold,
|
|
694
|
+
...(slipX !== undefined ? { slipFromX: e.centerX } : {}),
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
for (const m of fx.chart.milestones) {
|
|
698
|
+
if (m.fixed) continue;
|
|
699
|
+
all.push({
|
|
700
|
+
id: m.id,
|
|
701
|
+
centerX: m.x,
|
|
702
|
+
radius: m.radius,
|
|
703
|
+
title: m.title,
|
|
704
|
+
fontSize: 10,
|
|
705
|
+
bold: true,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
const runEntries = calendarMarkerEntries(timeline.nonWorking, timeline.workingExceptions);
|
|
709
|
+
all.push(...runEntries);
|
|
710
|
+
fx.draft.markerEntries = { all, calendarCount: runEntries.length };
|
|
711
|
+
let rightX = markerLabelsRightX(
|
|
712
|
+
all,
|
|
713
|
+
fx.measured.header.chartLeftX,
|
|
714
|
+
fx.canvas.provisional,
|
|
715
|
+
estimateTextWidth,
|
|
716
|
+
);
|
|
717
|
+
// An isolated region's own marker labels grow it the same way, inside
|
|
718
|
+
// the region's insets (placement packed them against the natural canvas).
|
|
719
|
+
// A region box reaches past the diagram's content by its pad
|
|
720
|
+
// (`includeRegionWidth`).
|
|
721
|
+
const contentRightX = diagramContentRightX(fx, fx.draft.markerEntries);
|
|
722
|
+
for (const inc of fx.chart.includes) {
|
|
723
|
+
rightX = Math.max(rightX, regionMarkerCanvasRightX(inc, fx.canvas.provisional));
|
|
724
|
+
rightX = Math.max(
|
|
725
|
+
rightX,
|
|
726
|
+
inc.box.x + includeRegionWidth(inc, Number.POSITIVE_INFINITY, contentRightX),
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
const grew = fx.canvas.grow(rightX);
|
|
730
|
+
fx.canvas.freeze();
|
|
731
|
+
return grew;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* The furthest content edge anywhere in the diagram, main lanes and every
|
|
736
|
+
* isolated region: a leaf bar's right edge, a spilled caption's, an anchor
|
|
737
|
+
* or milestone with its label (a host marker's label on its right, as f3
|
|
738
|
+
* estimates it before the final pack), and the now-line. Every region box
|
|
739
|
+
* reaches past it by the same pad.
|
|
740
|
+
*/
|
|
741
|
+
function diagramContentRightX(
|
|
742
|
+
fx: FinalizeContext,
|
|
743
|
+
markerEntries: { all: MarkerEntity[]; calendarCount: number } | undefined,
|
|
744
|
+
): number {
|
|
745
|
+
const { swimlanes, includes } = fx.chart;
|
|
746
|
+
let rightX = Math.max(maxLeafItemRightX(swimlanes), maxSpilledCaptionRightX(swimlanes));
|
|
747
|
+
for (const inc of includes) {
|
|
748
|
+
rightX = Math.max(
|
|
749
|
+
rightX,
|
|
750
|
+
maxLeafItemRightX(inc.nestedSwimlanes),
|
|
751
|
+
maxSpilledCaptionRightX(inc.nestedSwimlanes),
|
|
752
|
+
);
|
|
753
|
+
for (const m of [...(inc.anchors ?? []), ...(inc.milestones ?? [])]) {
|
|
754
|
+
rightX = Math.max(rightX, m.labelBox.x + m.labelBox.width);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (markerEntries) {
|
|
758
|
+
const markers = markerEntries.all.slice(
|
|
759
|
+
0,
|
|
760
|
+
markerEntries.all.length - markerEntries.calendarCount,
|
|
761
|
+
);
|
|
762
|
+
for (const e of markers) {
|
|
763
|
+
const labelWidth =
|
|
764
|
+
estimateTextWidth(e.title, e.fontSize) * (e.bold ? MARKER_BOLD_WIDTH_FACTOR : 1);
|
|
765
|
+
rightX = Math.max(rightX, e.centerX + e.radius + MARKER_LABEL_GAP_PX + labelWidth);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const nowX = fx.options.today ? fx.scale.forwardWithinDomain(fx.options.today) : null;
|
|
769
|
+
if (nowX !== null) rightX = Math.max(rightX, nowX);
|
|
770
|
+
return rightX;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* f4 (was P9, N2, N4): placement left every width that spans the canvas
|
|
775
|
+
* unset. Stretch every lane band to the final canvas so the lane
|
|
776
|
+
* background contains its own spilled captions (text-spills-right's
|
|
777
|
+
* "1w — 50% remaining" would otherwise sit outside it) and reaches the
|
|
778
|
+
* extended tick panel, and fit each region so its right-edge clamp reads
|
|
779
|
+
* the final width.
|
|
780
|
+
*/
|
|
781
|
+
function fitWidths(fx: FinalizeContext): boolean {
|
|
782
|
+
const chartRightX = fx.canvas.final;
|
|
783
|
+
const natural = fx.measured.naturalChartRightX;
|
|
784
|
+
const contentRightX = diagramContentRightX(
|
|
785
|
+
fx,
|
|
786
|
+
need(fx.draft.markerEntries, 'the marker entries'),
|
|
787
|
+
);
|
|
788
|
+
// Changed: a width differs from the one the natural canvas would give.
|
|
789
|
+
let changed = fx.chart.swimlanes.length > 0 && chartRightX !== natural;
|
|
790
|
+
for (const lane of fx.chart.swimlanes) {
|
|
791
|
+
lane.box.width = chartRightX;
|
|
792
|
+
}
|
|
793
|
+
for (const inc of fx.chart.includes) {
|
|
794
|
+
if (
|
|
795
|
+
includeRegionWidth(inc, chartRightX, contentRightX) !==
|
|
796
|
+
includeRegionWidth(inc, natural, contentRightX)
|
|
797
|
+
) {
|
|
798
|
+
changed = true;
|
|
799
|
+
}
|
|
800
|
+
fitIncludeRegionWidth(inc, chartRightX, contentRightX);
|
|
801
|
+
}
|
|
802
|
+
return changed;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* f5 (was P10): pack every marker once, against the final canvas, so
|
|
807
|
+
* `packMarkerRow` can sort by tick and assign rows and label sides
|
|
808
|
+
* bottom-first, keeping the milestones' marker-to-marker arrows clear
|
|
809
|
+
* (`markerArrows`, from the placements). This is the single source of the context's marker
|
|
810
|
+
* placements from here on; the measure phase's pre-pack only sized the
|
|
811
|
+
* band before the lanes ran. The band sits at its final top: below a
|
|
812
|
+
* now-pill row that f7 reserves when f2 brought `today` into the axis.
|
|
813
|
+
*/
|
|
814
|
+
function packMarkers(fx: FinalizeContext): boolean {
|
|
815
|
+
const entries = need(fx.draft.markerEntries, 'the marker entries');
|
|
816
|
+
const repacked = packMarkerRow(
|
|
817
|
+
entries.all,
|
|
818
|
+
fx.measured.header.chartLeftX,
|
|
819
|
+
fx.canvas.final,
|
|
820
|
+
estimateTextWidth,
|
|
821
|
+
markerArrows(fx.chart.milestones),
|
|
822
|
+
);
|
|
823
|
+
const placements = markerRowPlacements(repacked.placements, fx.headerRowsTop().markerRowY);
|
|
824
|
+
const rowCount =
|
|
825
|
+
fx.measured.markers.hasEntities || entries.calendarCount > 0
|
|
826
|
+
? Math.max(1, repacked.rowCount)
|
|
827
|
+
: 0;
|
|
828
|
+
fx.setMarkerBand({ rowCount, placements });
|
|
829
|
+
// Changed: a pre-packed marker moved.
|
|
830
|
+
let moved = false;
|
|
831
|
+
for (const [id, estimate] of fx.measured.markers.placements) {
|
|
832
|
+
const p = placements.get(id);
|
|
833
|
+
if (!p || !samePlacement(estimate, p)) moved = true;
|
|
834
|
+
}
|
|
835
|
+
return moved;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* f6 (was P11, N7): build the positioned milestones from their placements
|
|
840
|
+
* and the final pack (a slack or overrun arrow from an anchor or milestone
|
|
841
|
+
* starts on that marker's final row), then push the final placements onto
|
|
842
|
+
* the titled runs, the working exceptions and every marker's entity
|
|
843
|
+
* midpoint (used by edge routing).
|
|
844
|
+
*/
|
|
845
|
+
function placeMarkers(fx: FinalizeContext): boolean {
|
|
846
|
+
const band = fx.markerBand();
|
|
847
|
+
const ctx = fx.ctx;
|
|
848
|
+
const timeline = fx.measured.timeline;
|
|
849
|
+
const milestones = positionMilestones(fx.chart.milestones, band.placements);
|
|
850
|
+
// Changed: a milestone's marker row differs from the estimate its
|
|
851
|
+
// placement wrote into the entity midpoints.
|
|
852
|
+
const moved = milestones.some((m) => ctx.entityMidpoints.get(m.id ?? '')?.y !== m.center.y);
|
|
853
|
+
for (const run of timeline.nonWorking ?? []) {
|
|
854
|
+
if (run.label === undefined) continue;
|
|
855
|
+
const placement = band.placements.get(nonWorkingRunMarkerId(run));
|
|
856
|
+
if (!placement) continue;
|
|
857
|
+
run.labelBox = placement.labelBox;
|
|
858
|
+
run.labelSide = placement.labelSide;
|
|
859
|
+
}
|
|
860
|
+
for (const exception of timeline.workingExceptions ?? []) {
|
|
861
|
+
const placement = band.placements.get(workingExceptionMarkerId(exception));
|
|
862
|
+
if (!placement) continue;
|
|
863
|
+
exception.labelBox = placement.labelBox;
|
|
864
|
+
exception.labelSide = placement.labelSide;
|
|
865
|
+
}
|
|
866
|
+
for (const e of fx.measured.markers.datePinned) {
|
|
867
|
+
const p = band.placements.get(e.id);
|
|
868
|
+
if (!p) continue;
|
|
869
|
+
ctx.entityMidpoints.set(e.id, { x: e.centerX, y: p.centerY });
|
|
870
|
+
}
|
|
871
|
+
for (const m of milestones) {
|
|
872
|
+
if (m.fixed) continue;
|
|
873
|
+
ctx.entityMidpoints.set(m.id ?? '', { x: m.center.x, y: m.center.y });
|
|
874
|
+
}
|
|
875
|
+
fx.draft.milestones = milestones;
|
|
876
|
+
return moved;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* f7 (was P12): grow the header rows to what the final axis and the final
|
|
881
|
+
* marker pack need, then translate the chart, which placement laid out
|
|
882
|
+
* with its top at 0, to its top below them, in one translation on every
|
|
883
|
+
* layout. The rows grow at two places: the now-pill row above the tick
|
|
884
|
+
* panel, when f2 brought `today` into the axis (L2; the tick panel, wave
|
|
885
|
+
* strip and marker band move down with it, and f5 already packed the band
|
|
886
|
+
* there), and the marker band's bottom, when the final pack needs more
|
|
887
|
+
* rows than the estimate. Every later step reads the grown values.
|
|
888
|
+
*/
|
|
889
|
+
function growHeaderRows(fx: FinalizeContext): boolean {
|
|
890
|
+
const band = fx.markerBand();
|
|
891
|
+
const rowsTop = fx.headerRowsTop();
|
|
892
|
+
const bands = fx.measured.bands;
|
|
893
|
+
const estimatedRows = bands.estimatedMarkerRows;
|
|
894
|
+
const ctx = fx.ctx;
|
|
895
|
+
const timeline = ctx.timeline;
|
|
896
|
+
let changed = false;
|
|
897
|
+
if (rowsTop.pillRowHeight !== bands.pillRowHeight) {
|
|
898
|
+
timeline.box.y = rowsTop.timelineY;
|
|
899
|
+
timeline.pillRowHeight = rowsTop.pillRowHeight;
|
|
900
|
+
timeline.tickPanelY = rowsTop.tickPanelY;
|
|
901
|
+
if (timeline.waveStrip) timeline.waveStrip.y += rowsTop.shift;
|
|
902
|
+
timeline.markerRow.y += rowsTop.shift;
|
|
903
|
+
timeline.markerRow.collisionY += rowsTop.shift;
|
|
904
|
+
changed = true;
|
|
905
|
+
}
|
|
906
|
+
let deltaY = rowsTop.shift;
|
|
907
|
+
if (band.rowCount > estimatedRows) {
|
|
908
|
+
deltaY += (band.rowCount - estimatedRows) * MARKER_ROW_PITCH_PX;
|
|
909
|
+
timeline.markerRow.height = band.rowCount * MARKER_ROW_PITCH_PX;
|
|
910
|
+
changed = true;
|
|
911
|
+
}
|
|
912
|
+
// The beside card hugs this bottom (f15).
|
|
913
|
+
fx.setHeaderRowsBottom(rowsTop.markerRowY + timeline.markerRow.height);
|
|
914
|
+
const topY = bands.chartTopY + deltaY;
|
|
915
|
+
fx.chartY.top = topY;
|
|
916
|
+
fx.chartY.bottom += topY;
|
|
917
|
+
fx.chartY.swimlaneBottom += topY;
|
|
918
|
+
timeline.box.height = fx.chartY.bottom - timeline.box.y;
|
|
919
|
+
// The translation moves every y the chart's positioned model carries
|
|
920
|
+
// (see `chart-translation.ts`). No LayoutContext map needs it: item
|
|
921
|
+
// attach ports are derived from the (now-translated) boxes in
|
|
922
|
+
// `placedItems` when edges are built (f11), the edge maps hold x only,
|
|
923
|
+
// itemFlowKey holds strings, marker midpoints and markerRowPlacements
|
|
924
|
+
// sit in the header, and slackCorridors are pass-2 inputs consumed
|
|
925
|
+
// during placement. A milestone's arrow from a marker sits in the
|
|
926
|
+
// header too, on the row f6 gave it, so only its item arrows move.
|
|
927
|
+
for (const lane of fx.chart.swimlanes) translateSwimlaneY(lane, topY);
|
|
928
|
+
for (const inc of fx.chart.includes) translateIncludeY(inc, topY);
|
|
929
|
+
const placements = fx.chart.milestones;
|
|
930
|
+
const milestones = need(fx.draft.milestones, 'the milestones');
|
|
931
|
+
for (let i = 0; i < milestones.length; i++) {
|
|
932
|
+
translateMilestoneChartY(milestones[i], placements[i], topY);
|
|
933
|
+
}
|
|
934
|
+
return changed;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* f8 (was P13): the mirrored bottom tick panel, directly above the
|
|
939
|
+
* footnote area and below the last lane or region, at the chart bottom
|
|
940
|
+
* after any marker-band growth. `timeline.box.height` stays anchored to
|
|
941
|
+
* the chart's bottom edge — grid lines therefore stop at the panel's TOP,
|
|
942
|
+
* not at its bottom — while the panel gets its own fill, border and
|
|
943
|
+
* labels in the renderer. Milestone and anchor cut lines stay anchored to
|
|
944
|
+
* the lane bottom so they never invade the bottom date strip; only the
|
|
945
|
+
* now-line (and full-height major grid lines) thread through this panel.
|
|
946
|
+
*/
|
|
947
|
+
function bottomTickPanel(fx: FinalizeContext): boolean {
|
|
948
|
+
const height = fx.measured.bands.bottomTickPanelHeight;
|
|
949
|
+
if (height <= 0) return false;
|
|
950
|
+
fx.ctx.timeline.bottomTickPanelY = fx.chartY.bottom + BOTTOM_TICK_PANEL_GAP_PX;
|
|
951
|
+
fx.ctx.timeline.bottomTickPanelHeight = height;
|
|
952
|
+
fx.chartY.bottom += BOTTOM_TICK_PANEL_GAP_PX + height;
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* f9 (was P14): the positioned waves (specs/waves.md §8.7), after the
|
|
958
|
+
* axis extension (final scale) and the marker-band shift, so the columns,
|
|
959
|
+
* the boundaries and the crossings read final geometry.
|
|
960
|
+
*/
|
|
961
|
+
function waves(fx: FinalizeContext): boolean {
|
|
962
|
+
fx.finalScale();
|
|
963
|
+
const ctx = fx.ctx;
|
|
964
|
+
if (!ctx.waves) return false;
|
|
965
|
+
const built = buildWaves(
|
|
966
|
+
ctx.waves,
|
|
967
|
+
ctx,
|
|
968
|
+
localeStrings(fx.scope.locale),
|
|
969
|
+
fx.chart.swimlanes,
|
|
970
|
+
fx.chart.includes,
|
|
971
|
+
);
|
|
972
|
+
built.regionCrossings.forEach((crossings, i) => {
|
|
973
|
+
if (crossings.length > 0) fx.chart.includes[i].waveCrossings = crossings;
|
|
974
|
+
});
|
|
975
|
+
fx.draft.waves = built;
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** f10 (was P15): the anchors from the final pack, scale and chart y. */
|
|
980
|
+
function anchors(fx: FinalizeContext): boolean {
|
|
981
|
+
fx.finalScale();
|
|
982
|
+
fx.markerBand();
|
|
983
|
+
const milestoneXs = new Set<number>(
|
|
984
|
+
need(fx.draft.milestones, 'the milestones').map((m) => m.center.x),
|
|
985
|
+
);
|
|
986
|
+
const built = buildAnchors(fx.resolved.content.anchors, fx.ctx, milestoneXs);
|
|
987
|
+
fx.draft.anchors = built;
|
|
988
|
+
return built.length > 0;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* f11 (was P16): dependency edges, their ports read off the final boxes.
|
|
993
|
+
* Each isolated region routes its own, among its own lanes: its `after:`
|
|
994
|
+
* references resolve only inside it.
|
|
995
|
+
*/
|
|
996
|
+
function dependencyEdges(fx: FinalizeContext): boolean {
|
|
997
|
+
const itemsMap = collectItems(fx.chart.laneEntries);
|
|
998
|
+
const edges = buildDependencies(itemsMap, fx.chart.swimlanes, fx.chart.includes, fx.ctx);
|
|
999
|
+
fx.draft.edges = edges;
|
|
1000
|
+
let regionEdgeCount = 0;
|
|
1001
|
+
fx.resolved.content.isolatedRegions.forEach((region, i) => {
|
|
1002
|
+
const inc = fx.chart.includes[i];
|
|
1003
|
+
const regionCtx = fx.chart.regionContexts[i];
|
|
1004
|
+
if (!inc || !regionCtx) return;
|
|
1005
|
+
const regionEdges = buildDependencies(
|
|
1006
|
+
collectItems([...region.content.swimlanes.values()]),
|
|
1007
|
+
inc.nestedSwimlanes,
|
|
1008
|
+
[],
|
|
1009
|
+
regionCtx,
|
|
1010
|
+
);
|
|
1011
|
+
if (regionEdges.length > 0) inc.edges = regionEdges;
|
|
1012
|
+
regionEdgeCount += regionEdges.length;
|
|
1013
|
+
});
|
|
1014
|
+
return edges.length + regionEdgeCount > 0;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* f12 (was P17): the now-line on the final scale and canvas, when today
|
|
1019
|
+
* falls inside the axis. It stops at the bottom tick panel when there is
|
|
1020
|
+
* one, otherwise at the last lane; footnote panels below do not extend it.
|
|
1021
|
+
*/
|
|
1022
|
+
function nowline(fx: FinalizeContext): boolean {
|
|
1023
|
+
fx.finalScale();
|
|
1024
|
+
const line = buildNowline(fx.options.today, fx.ctx, fx.scope.locale, fx.canvas.final);
|
|
1025
|
+
fx.draft.nowline = line;
|
|
1026
|
+
return line !== null;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* f13 (was P18): the wave legend (specs/waves.md §8.8, §9.5) sits below
|
|
1031
|
+
* the chart and any bottom tick panel, above the footnotes: grow the chart
|
|
1032
|
+
* bottom by its gap and lines before the footnote panel is placed.
|
|
1033
|
+
*/
|
|
1034
|
+
function waveLegend(fx: FinalizeContext): boolean {
|
|
1035
|
+
const built = fx.draft.waves;
|
|
1036
|
+
if (!built) return false;
|
|
1037
|
+
const legend = buildWaveLegend(
|
|
1038
|
+
built,
|
|
1039
|
+
fx.ctx,
|
|
1040
|
+
localeStrings(fx.scope.locale),
|
|
1041
|
+
fx.chartY.bottom + WAVE_LEGEND_GAP_PX,
|
|
1042
|
+
);
|
|
1043
|
+
if (!legend) return false;
|
|
1044
|
+
fx.chartY.bottom += WAVE_LEGEND_GAP_PX + legend.box.height;
|
|
1045
|
+
fx.draft.waveLegend = legend;
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** f14 (was P19): the footnote panel at the bottom: the host's footnotes, then each region's. */
|
|
1050
|
+
function footnotePanel(fx: FinalizeContext): boolean {
|
|
1051
|
+
const regions = fx.resolved.content.isolatedRegions.flatMap((region, i) => {
|
|
1052
|
+
const regionCtx = fx.chart.regionContexts[i];
|
|
1053
|
+
const inc = fx.chart.includes[i];
|
|
1054
|
+
return regionCtx && inc
|
|
1055
|
+
? [{ title: inc.label, footnotes: region.content.footnotes, ctx: regionCtx }]
|
|
1056
|
+
: [];
|
|
1057
|
+
});
|
|
1058
|
+
const panel = buildFootnotePanel(
|
|
1059
|
+
fx.resolved.content.footnotes,
|
|
1060
|
+
fx.ctx,
|
|
1061
|
+
fx.chartY.bottom,
|
|
1062
|
+
fx.canvas.final,
|
|
1063
|
+
regions,
|
|
1064
|
+
);
|
|
1065
|
+
fx.draft.footnotes = panel;
|
|
1066
|
+
return panel.entries.length > 0;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* f15 (was P20): the header, from the chart height in beside mode and the
|
|
1071
|
+
* final canvas width in above mode. The beside card hugs the header rows'
|
|
1072
|
+
* bottom after f7 grew them (F1: before, it hugged the measure phase's
|
|
1073
|
+
* estimate and missed a grown marker band or a reserved now-pill row).
|
|
1074
|
+
*/
|
|
1075
|
+
function header(fx: FinalizeContext): boolean {
|
|
1076
|
+
const { header: measuredHeader, headerNode } = fx.measured;
|
|
1077
|
+
fx.draft.header = headerNode.place(measuredHeader, {
|
|
1078
|
+
rowsBottomY: fx.headerRowsBottom(),
|
|
1079
|
+
chartBottomY: fx.chartY.bottom,
|
|
1080
|
+
canvasRightX: fx.canvas.final,
|
|
1081
|
+
});
|
|
1082
|
+
return measuredHeader.position === 'beside';
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* f16 (was P20): the attribution wordmark and the canvas height. The
|
|
1087
|
+
* canvas grows by GUTTER_PX + glyph height + GUTTER_PX so the wordmark
|
|
1088
|
+
* sits in a clean bottom margin below all content (last lane, footnote
|
|
1089
|
+
* panel or region). Today only the bottom-right slot fires, but the
|
|
1090
|
+
* priority order is bottom-right → upper-right → bottom-left — when
|
|
1091
|
+
* content density makes bottom-right disruptive in some future case, fall
|
|
1092
|
+
* back to upper-right (above the timeline) and then bottom-left (under
|
|
1093
|
+
* the header card in beside mode).
|
|
1094
|
+
*/
|
|
1095
|
+
function attribution(fx: FinalizeContext): boolean {
|
|
1096
|
+
const footnotes = need(fx.draft.footnotes, 'the footnote panel');
|
|
1097
|
+
const chartRightX = fx.canvas.final;
|
|
1098
|
+
// When there are no footnotes, the panel box still carries a 16 px
|
|
1099
|
+
// top-of-panel offset; that's a placeholder for the footnote header
|
|
1100
|
+
// gap and shouldn't push the attribution down. Use the bare chart
|
|
1101
|
+
// bottom in that case so the bottom margin is exactly
|
|
1102
|
+
// `GUTTER_PX + glyphHeight + GUTTER_PX`.
|
|
1103
|
+
const contentBottomY =
|
|
1104
|
+
footnotes.entries.length > 0 ? footnotes.box.y + footnotes.box.height : fx.chartY.bottom;
|
|
1105
|
+
const attributionBox: BoundingBox = {
|
|
1106
|
+
x: chartRightX - GUTTER_PX - ATTRIBUTION_GLYPH_WIDTH,
|
|
1107
|
+
y: contentBottomY + GUTTER_PX,
|
|
1108
|
+
width: ATTRIBUTION_GLYPH_WIDTH,
|
|
1109
|
+
height: ATTRIBUTION_GLYPH_HEIGHT,
|
|
1110
|
+
};
|
|
1111
|
+
fx.draft.attributionBox = attributionBox;
|
|
1112
|
+
fx.draft.height = attributionBox.y + attributionBox.height + GUTTER_PX;
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** The finalize pass, in order (specs/layout-phases.md §3.5). */
|
|
1117
|
+
export const FINALIZE_STEPS: readonly FinalizeStep[] = [
|
|
1118
|
+
{
|
|
1119
|
+
name: 'growCanvasForSpill',
|
|
1120
|
+
reads: ['placement'],
|
|
1121
|
+
writes: ['canvas'],
|
|
1122
|
+
run: growCanvasForSpill,
|
|
1123
|
+
},
|
|
1124
|
+
{
|
|
1125
|
+
name: 'extendTimeline',
|
|
1126
|
+
reads: ['placement', 'window', 'scale'],
|
|
1127
|
+
writes: ['scale', 'timeline', 'calendarRuns', 'canvas'],
|
|
1128
|
+
run: extendTimeline,
|
|
1129
|
+
},
|
|
1130
|
+
{
|
|
1131
|
+
name: 'growCanvasForMarkerLabels',
|
|
1132
|
+
reads: ['placement', 'calendarRuns', 'canvas', 'scale'],
|
|
1133
|
+
writes: ['markerEntries', 'canvas'],
|
|
1134
|
+
run: growCanvasForMarkerLabels,
|
|
1135
|
+
},
|
|
1136
|
+
{
|
|
1137
|
+
name: 'fitWidths',
|
|
1138
|
+
reads: ['placement', 'canvas', 'markerEntries', 'scale'],
|
|
1139
|
+
writes: ['laneWidths'],
|
|
1140
|
+
run: fitWidths,
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
name: 'packMarkers',
|
|
1144
|
+
reads: ['placement', 'canvas', 'markerEntries', 'headerBands', 'scale'],
|
|
1145
|
+
writes: ['markerBand'],
|
|
1146
|
+
run: packMarkers,
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
name: 'placeMarkers',
|
|
1150
|
+
reads: ['placement', 'markerBand', 'calendarRuns'],
|
|
1151
|
+
writes: ['milestones', 'calendarLabels', 'markerMidpoints'],
|
|
1152
|
+
run: placeMarkers,
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
name: 'growHeaderRows',
|
|
1156
|
+
reads: [
|
|
1157
|
+
'placement',
|
|
1158
|
+
'markerBand',
|
|
1159
|
+
'headerBands',
|
|
1160
|
+
'scale',
|
|
1161
|
+
'milestones',
|
|
1162
|
+
'chartTop',
|
|
1163
|
+
'laneBottom',
|
|
1164
|
+
],
|
|
1165
|
+
writes: [
|
|
1166
|
+
'chartTop',
|
|
1167
|
+
'rowsBottom',
|
|
1168
|
+
'laneBottom',
|
|
1169
|
+
'chartBottom',
|
|
1170
|
+
'laneY',
|
|
1171
|
+
'milestones',
|
|
1172
|
+
'timeline',
|
|
1173
|
+
],
|
|
1174
|
+
run: growHeaderRows,
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: 'bottomTickPanel',
|
|
1178
|
+
reads: ['headerBands', 'chartBottom'],
|
|
1179
|
+
writes: ['chartBottom', 'timeline'],
|
|
1180
|
+
run: bottomTickPanel,
|
|
1181
|
+
},
|
|
1182
|
+
{
|
|
1183
|
+
name: 'waves',
|
|
1184
|
+
reads: ['scale', 'chartTop', 'laneBottom', 'timeline', 'laneY'],
|
|
1185
|
+
writes: ['waves', 'timeline'],
|
|
1186
|
+
run: waves,
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
name: 'anchors',
|
|
1190
|
+
reads: ['scale', 'markerBand', 'chartTop', 'laneBottom', 'milestones'],
|
|
1191
|
+
writes: ['anchors', 'markerMidpoints'],
|
|
1192
|
+
run: anchors,
|
|
1193
|
+
},
|
|
1194
|
+
{
|
|
1195
|
+
name: 'dependencyEdges',
|
|
1196
|
+
reads: ['laneY', 'markerMidpoints'],
|
|
1197
|
+
writes: ['edges'],
|
|
1198
|
+
run: dependencyEdges,
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
name: 'nowline',
|
|
1202
|
+
reads: ['scale', 'canvas', 'laneBottom', 'timeline'],
|
|
1203
|
+
writes: ['nowline'],
|
|
1204
|
+
run: nowline,
|
|
1205
|
+
},
|
|
1206
|
+
{
|
|
1207
|
+
name: 'waveLegend',
|
|
1208
|
+
reads: ['waves', 'canvas', 'chartBottom'],
|
|
1209
|
+
writes: ['legend', 'chartBottom'],
|
|
1210
|
+
run: waveLegend,
|
|
1211
|
+
},
|
|
1212
|
+
{
|
|
1213
|
+
name: 'footnotePanel',
|
|
1214
|
+
reads: ['chartBottom', 'canvas'],
|
|
1215
|
+
writes: ['footnotes'],
|
|
1216
|
+
run: footnotePanel,
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
name: 'header',
|
|
1220
|
+
reads: ['chartBottom', 'canvas', 'rowsBottom'],
|
|
1221
|
+
writes: ['header'],
|
|
1222
|
+
run: header,
|
|
1223
|
+
},
|
|
1224
|
+
{
|
|
1225
|
+
name: 'attribution',
|
|
1226
|
+
reads: ['footnotes', 'chartBottom', 'canvas'],
|
|
1227
|
+
writes: ['attribution'],
|
|
1228
|
+
run: attribution,
|
|
1229
|
+
},
|
|
1230
|
+
];
|
|
1231
|
+
|
|
1232
|
+
/** Run the finalize pass over `fx` and assemble the positioned model. */
|
|
1233
|
+
export function finalizeRoadmap(
|
|
1234
|
+
fx: FinalizeContext,
|
|
1235
|
+
steps: readonly FinalizeStep[] = FINALIZE_STEPS,
|
|
1236
|
+
): LayoutResult {
|
|
1237
|
+
for (const step of steps) {
|
|
1238
|
+
fx.report(step.name, step.run(fx));
|
|
1239
|
+
}
|
|
1240
|
+
return assembleModel(fx);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function assembleModel(fx: FinalizeContext): PositionedRoadmap {
|
|
1244
|
+
const { draft } = fx;
|
|
1245
|
+
const { themeName, theme } = fx.scope;
|
|
1246
|
+
const { swimlanes, includes, waveSolve } = fx.chart;
|
|
1247
|
+
const chartRightX = fx.canvas.final;
|
|
1248
|
+
const chartLeftX = fx.measured.header.chartLeftX;
|
|
1249
|
+
const builtWaves = draft.waves;
|
|
1250
|
+
const waveLegend = draft.waveLegend;
|
|
1251
|
+
return {
|
|
1252
|
+
width: chartRightX,
|
|
1253
|
+
height: need(draft.height, 'the canvas height'),
|
|
1254
|
+
theme: themeName,
|
|
1255
|
+
palette: theme,
|
|
1256
|
+
backgroundColor: theme.surface.page,
|
|
1257
|
+
header: {
|
|
1258
|
+
...need(draft.header, 'the header'),
|
|
1259
|
+
attributionBox: need(draft.attributionBox, 'the attribution box'),
|
|
1260
|
+
},
|
|
1261
|
+
timeline: fx.measured.timeline,
|
|
1262
|
+
nowline: need(draft.nowline, 'the now-line'),
|
|
1263
|
+
swimlanes,
|
|
1264
|
+
anchors: need(draft.anchors, 'the anchors'),
|
|
1265
|
+
milestones: need(draft.milestones, 'the milestones'),
|
|
1266
|
+
edges: need(draft.edges, 'the edges'),
|
|
1267
|
+
footnotes: need(draft.footnotes, 'the footnote panel'),
|
|
1268
|
+
includes,
|
|
1269
|
+
chartBox: {
|
|
1270
|
+
x: chartLeftX,
|
|
1271
|
+
y: fx.chartY.top,
|
|
1272
|
+
width: chartRightX - chartLeftX,
|
|
1273
|
+
height: fx.chartY.bottom - fx.chartY.top,
|
|
1274
|
+
},
|
|
1275
|
+
...(waveSolve ? { waveSolve } : {}),
|
|
1276
|
+
...(builtWaves ? { waves: builtWaves.waves } : {}),
|
|
1277
|
+
...(builtWaves && builtWaves.boundaries.length > 0
|
|
1278
|
+
? { waveBoundaries: builtWaves.boundaries }
|
|
1279
|
+
: {}),
|
|
1280
|
+
...(builtWaves && builtWaves.crossings.length > 0
|
|
1281
|
+
? { waveCrossings: builtWaves.crossings }
|
|
1282
|
+
: {}),
|
|
1283
|
+
...(waveLegend ? { waveLegend } : {}),
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** A step's product, which the step order guarantees is set. */
|
|
1288
|
+
function need<T>(value: T | undefined, what: string): T {
|
|
1289
|
+
if (value === undefined) throw new Error(`layout: ${what} was read before its step ran`);
|
|
1290
|
+
return value;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function samePlacement(a: MarkerRowPlacement, b: MarkerRowPlacement): boolean {
|
|
1294
|
+
return (
|
|
1295
|
+
a.rowIndex === b.rowIndex &&
|
|
1296
|
+
a.centerY === b.centerY &&
|
|
1297
|
+
a.labelSide === b.labelSide &&
|
|
1298
|
+
a.labelBox.x === b.labelBox.x &&
|
|
1299
|
+
a.labelBox.y === b.labelBox.y &&
|
|
1300
|
+
a.labelBox.width === b.labelBox.width &&
|
|
1301
|
+
a.labelBox.height === b.labelBox.height
|
|
1302
|
+
);
|
|
1303
|
+
}
|