@nowline/layout 0.8.6 → 0.10.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/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 -152
- 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 -30
- 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 -145
- 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 +4 -4
- 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 -214
- 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 -38
- 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 -205
- 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
package/src/nodes/anchor-node.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// helper. Each anchor sits in the marker row of the timeline and emits
|
|
3
3
|
// a downward cut line through the chart. Marker-row placement (row +
|
|
4
4
|
// label box + left/right label side) is decided up-front in
|
|
5
|
-
// `
|
|
5
|
+
// `marker-band.ts` (`packMarkerRow`) so anchors and milestones share
|
|
6
6
|
// the same row stack — see `LayoutContext.markerRowPlacements`.
|
|
7
7
|
|
|
8
8
|
import type { AnchorDeclaration } from '@nowline/core';
|
|
9
|
-
import {
|
|
9
|
+
import { formatIsoDate, parseIsoDate, propValue } from '../dsl-utils.js';
|
|
10
10
|
import type { LayoutContext } from '../layout-context.js';
|
|
11
11
|
import { resolveStyle } from '../style-resolution.js';
|
|
12
12
|
import type { Point, PositionedAnchor } from '../types.js';
|
|
@@ -29,7 +29,7 @@ export class AnchorNode {
|
|
|
29
29
|
*/
|
|
30
30
|
place(ctx: LayoutContext, _milestoneXs: Set<number>): PositionedAnchor | null {
|
|
31
31
|
const dateRaw = propValue(this.anchor.properties, 'date');
|
|
32
|
-
const date =
|
|
32
|
+
const date = parseIsoDate(dateRaw);
|
|
33
33
|
if (!date) return null;
|
|
34
34
|
const x = ctx.scale.forwardWithinDomain(date);
|
|
35
35
|
if (x === null) return null;
|
|
@@ -47,7 +47,7 @@ export class AnchorNode {
|
|
|
47
47
|
ctx.entityLeftEdges.set(this.id, x);
|
|
48
48
|
ctx.entityRightEdges.set(this.id, x);
|
|
49
49
|
ctx.entityMidpoints.set(this.id, center);
|
|
50
|
-
|
|
50
|
+
const positioned: PositionedAnchor = {
|
|
51
51
|
id: this.id,
|
|
52
52
|
title: this.anchor.title ?? this.id,
|
|
53
53
|
center,
|
|
@@ -60,6 +60,13 @@ export class AnchorNode {
|
|
|
60
60
|
labelBox,
|
|
61
61
|
labelSide,
|
|
62
62
|
};
|
|
63
|
+
// A date the axis hides keeps its own day as a tooltip
|
|
64
|
+
// (specs/working-calendar.md §7.4). Under `show` the day is drawn,
|
|
65
|
+
// and the anchor sits on it.
|
|
66
|
+
if (!ctx.scale.showsNonWorking && !ctx.calendar.isWorkingDay(date)) {
|
|
67
|
+
positioned.hiddenDate = formatIsoDate(date);
|
|
68
|
+
}
|
|
69
|
+
return positioned;
|
|
63
70
|
}
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// content-extent — shared helpers that measure how far right placed
|
|
2
|
+
// content reaches. `maxLeafItemRightX` measures the rightmost edge
|
|
3
|
+
// reached by leaf item bars inside a set of swimlanes, recursing
|
|
4
|
+
// through nested swimlanes and group/parallel `children`;
|
|
5
|
+
// `trackChildPaintedRightX` measures everything one track child paints,
|
|
6
|
+
// spilled captions included.
|
|
7
|
+
//
|
|
8
|
+
// A `PositionedItem.box` is always the bar itself, never a spilled
|
|
9
|
+
// caption (see `PositionedItem.textSpills`), so the leaf walk is immune
|
|
10
|
+
// to caption spill by construction. That makes it a stricter measure
|
|
11
|
+
// than `RowPacker.usedRightX()` / `SwimlaneNode`'s `usedRightX`,
|
|
12
|
+
// which both fold in `spillX` (the caption's reserved column).
|
|
13
|
+
//
|
|
14
|
+
// Used by:
|
|
15
|
+
// - Finalize's timeline extension (`extendTimeline`, f2), which
|
|
16
|
+
// grows the date window to cover overflowing content boxes
|
|
17
|
+
// without letting overhanging captions drag it out too.
|
|
18
|
+
// - `buildIncludeRegions`, to size the dashed include bracket to
|
|
19
|
+
// its nested content's bars rather than a spilled caption.
|
|
20
|
+
//
|
|
21
|
+
// `maxSpilledCaptionRightX` is the other half of f2: a spilled caption
|
|
22
|
+
// past the axis end grows the axis by the one column it was capped to.
|
|
23
|
+
|
|
24
|
+
import { containerHeaderRightX } from '../container-header-geometry.js';
|
|
25
|
+
import type { PositionedSwimlane, PositionedTrackChild } from '../types.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rightmost x anything in a placed track child paints: an item's bar, its
|
|
29
|
+
* spilled caption, decorations and chips; a group's or parallel's box, its
|
|
30
|
+
* header (a title may run past the bracket's end) and, recursively, its
|
|
31
|
+
* children. A container's box spans only its dates, so a caption one of its
|
|
32
|
+
* children spills past that edge shows up here and nowhere else. The row
|
|
33
|
+
* track feeds it to the packer so the canvas grows to it.
|
|
34
|
+
*/
|
|
35
|
+
export function trackChildPaintedRightX(child: PositionedTrackChild): number {
|
|
36
|
+
if (child.kind === 'item') {
|
|
37
|
+
// `decorationsRightX` leads the spill column even when nothing
|
|
38
|
+
// spills, so it counts only when something does.
|
|
39
|
+
const spills =
|
|
40
|
+
child.textSpills || child.dotSpills || child.iconSpills || child.footnoteSpills;
|
|
41
|
+
return Math.max(
|
|
42
|
+
child.box.x + child.box.width,
|
|
43
|
+
spills ? child.decorationsRightX : Number.NEGATIVE_INFINITY,
|
|
44
|
+
child.chipsOutside ? child.chipsRightX : Number.NEGATIVE_INFINITY,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
let right = Math.max(child.box.x + child.box.width, containerHeaderRightX(child));
|
|
48
|
+
for (const sub of child.children) right = Math.max(right, trackChildPaintedRightX(sub));
|
|
49
|
+
return right;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Rightmost x reached by any leaf item bar across `swimlanes`, recursing
|
|
53
|
+
* into nested swimlanes and group/parallel children. Returns 0 when the
|
|
54
|
+
* set contains no items — callers combine this with another baseline
|
|
55
|
+
* (e.g. `originX` or a chrome-derived minimum) via `Math.max`. */
|
|
56
|
+
export function maxLeafItemRightX(swimlanes: PositionedSwimlane[]): number {
|
|
57
|
+
let max = 0;
|
|
58
|
+
for (const lane of swimlanes) {
|
|
59
|
+
max = Math.max(max, maxTrackChildRightX(lane.children));
|
|
60
|
+
if (lane.nested.length > 0) {
|
|
61
|
+
max = Math.max(max, maxLeafItemRightX(lane.nested));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return max;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maxTrackChildRightX(children: PositionedTrackChild[]): number {
|
|
68
|
+
let max = 0;
|
|
69
|
+
for (const child of children) {
|
|
70
|
+
if (child.kind === 'item') {
|
|
71
|
+
max = Math.max(max, child.box.x + child.box.width);
|
|
72
|
+
} else {
|
|
73
|
+
max = Math.max(max, maxTrackChildRightX(child.children));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return max;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rightmost x a spilled item caption reaches across `swimlanes` (its spill
|
|
81
|
+
* column: icon, caption, footnotes, dot), recursing like
|
|
82
|
+
* `maxLeafItemRightX`; 0 when no caption spills. Finalize f2 grows the
|
|
83
|
+
* axis by one column for a caption that ends past it (specs/rendering.md
|
|
84
|
+
* § "Caption cap at the chart's end").
|
|
85
|
+
*/
|
|
86
|
+
export function maxSpilledCaptionRightX(swimlanes: PositionedSwimlane[]): number {
|
|
87
|
+
let max = 0;
|
|
88
|
+
for (const lane of swimlanes) {
|
|
89
|
+
max = Math.max(max, maxTrackChildCaptionRightX(lane.children));
|
|
90
|
+
if (lane.nested.length > 0) {
|
|
91
|
+
max = Math.max(max, maxSpilledCaptionRightX(lane.nested));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return max;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function maxTrackChildCaptionRightX(children: PositionedTrackChild[]): number {
|
|
98
|
+
let max = 0;
|
|
99
|
+
for (const child of children) {
|
|
100
|
+
if (child.kind === 'item') {
|
|
101
|
+
if (child.textSpills) max = Math.max(max, child.decorationsRightX);
|
|
102
|
+
} else {
|
|
103
|
+
max = Math.max(max, maxTrackChildCaptionRightX(child.children));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return max;
|
|
107
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Dependency edges (finalize step f11): every `after:` reference of a
|
|
2
|
+
// placed item becomes an arrow on the dates (specs/true-width-layout.md
|
|
3
|
+
// §3.4), its ends read off the final model. The host chart and each
|
|
4
|
+
// isolated region route their own, among their own lanes.
|
|
5
|
+
|
|
6
|
+
import type { ItemDeclaration, SwimlaneDeclaration } from '@nowline/core';
|
|
7
|
+
import { isItemDeclaration } from '@nowline/core';
|
|
8
|
+
import { trackNodes } from '@nowline/core/internal';
|
|
9
|
+
import { containerHeaderRightX } from '../container-header-geometry.js';
|
|
10
|
+
import { propValues } from '../dsl-utils.js';
|
|
11
|
+
import {
|
|
12
|
+
type ArrowEnd,
|
|
13
|
+
arrowHead,
|
|
14
|
+
type DependencyRouteRequest,
|
|
15
|
+
type RouteObstacles,
|
|
16
|
+
routeDependencies,
|
|
17
|
+
} from '../edge-routing.js';
|
|
18
|
+
import { containerArrowEnd, itemArrowEnd } from '../item-port-geometry.js';
|
|
19
|
+
import type { LayoutContext } from '../layout-context.js';
|
|
20
|
+
import { resolveStyle } from '../style-resolution.js';
|
|
21
|
+
import type {
|
|
22
|
+
PositionedDependencyEdge,
|
|
23
|
+
PositionedGroup,
|
|
24
|
+
PositionedIncludeRegion,
|
|
25
|
+
PositionedParallel,
|
|
26
|
+
PositionedSwimlane,
|
|
27
|
+
PositionedTrackChild,
|
|
28
|
+
} from '../types.js';
|
|
29
|
+
import { trackChildPaintedRightX } from './content-extent.js';
|
|
30
|
+
import { syntheticItemKey } from './sequence-item.js';
|
|
31
|
+
|
|
32
|
+
// Every item under the lanes, keyed by id. Title-only items register under a
|
|
33
|
+
// synthetic, non-referenceable handle so they still appear as dependency-edge
|
|
34
|
+
// targets.
|
|
35
|
+
export function collectItems(swimlanes: SwimlaneDeclaration[]): Map<string, ItemDeclaration> {
|
|
36
|
+
const out = new Map<string, ItemDeclaration>();
|
|
37
|
+
for (const node of trackNodes(swimlanes)) {
|
|
38
|
+
if (isItemDeclaration(node)) out.set(node.name ?? syntheticItemKey(node), node);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A target that starts within this of a marker's date touches its line. */
|
|
44
|
+
const ON_LINE_PX = 0.01;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What the router must route around, and every group and parallel by id
|
|
48
|
+
* (the containers an `after:` may name), from the lanes it routes among.
|
|
49
|
+
* Include regions in `includes` count as obstacles only: their own arrows
|
|
50
|
+
* are routed separately.
|
|
51
|
+
*/
|
|
52
|
+
function collectRouting(
|
|
53
|
+
swimlanes: PositionedSwimlane[],
|
|
54
|
+
includes: PositionedIncludeRegion[],
|
|
55
|
+
): { obstacles: RouteObstacles; containers: Map<string, PositionedGroup | PositionedParallel> } {
|
|
56
|
+
const obstacles: RouteObstacles = { blocks: [], edges: [] };
|
|
57
|
+
const containers = new Map<string, PositionedGroup | PositionedParallel>();
|
|
58
|
+
const line = (x: number, width: number, y: number): void => {
|
|
59
|
+
obstacles.blocks.push({ x, y, width, height: 0 });
|
|
60
|
+
};
|
|
61
|
+
const visitChild = (child: PositionedTrackChild, register: boolean): void => {
|
|
62
|
+
const { box } = child;
|
|
63
|
+
if (child.kind === 'item') {
|
|
64
|
+
// The bar with whatever it spills right of itself (caption,
|
|
65
|
+
// decorations, chips), so a horizontal leg stays out of the text.
|
|
66
|
+
obstacles.blocks.push({ ...box, width: trackChildPaintedRightX(child) - box.x });
|
|
67
|
+
obstacles.edges.push(
|
|
68
|
+
{ x: box.x, top: box.y, bottom: box.y + box.height },
|
|
69
|
+
{ x: box.x + box.width, top: box.y, bottom: box.y + box.height },
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (register && child.id) containers.set(child.id, child);
|
|
74
|
+
if (!(child.kind === 'group' && child.waveOnly)) {
|
|
75
|
+
if (child.headerHeight > 0) {
|
|
76
|
+
obstacles.blocks.push({
|
|
77
|
+
x: box.x,
|
|
78
|
+
y: box.y,
|
|
79
|
+
width: Math.max(box.width, containerHeaderRightX(child) - box.x),
|
|
80
|
+
height: child.headerHeight,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// Nor along the body's bottom edge.
|
|
84
|
+
line(box.x, box.width, box.y + box.height);
|
|
85
|
+
obstacles.edges.push(
|
|
86
|
+
{ x: box.x, top: box.y, bottom: box.y + box.height },
|
|
87
|
+
{ x: box.x + box.width, top: box.y, bottom: box.y + box.height },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
for (const sub of child.children) visitChild(sub, register);
|
|
91
|
+
};
|
|
92
|
+
const visitLane = (lane: PositionedSwimlane, register: boolean): void => {
|
|
93
|
+
// A leg never runs along a lane's edge.
|
|
94
|
+
line(lane.box.x, lane.box.width, lane.box.y);
|
|
95
|
+
line(lane.box.x, lane.box.width, lane.box.y + lane.box.height);
|
|
96
|
+
for (const child of lane.children) visitChild(child, register);
|
|
97
|
+
for (const nested of lane.nested) visitLane(nested, register);
|
|
98
|
+
};
|
|
99
|
+
for (const lane of swimlanes) visitLane(lane, true);
|
|
100
|
+
// A region's containers are not referenceable from the host.
|
|
101
|
+
for (const region of includes) {
|
|
102
|
+
line(region.box.x, region.box.width, region.box.y);
|
|
103
|
+
line(region.box.x, region.box.width, region.box.y + region.box.height);
|
|
104
|
+
for (const lane of region.nestedSwimlanes) visitLane(lane, false);
|
|
105
|
+
}
|
|
106
|
+
return { obstacles, containers };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildDependencies(
|
|
110
|
+
items: Map<string, ItemDeclaration>,
|
|
111
|
+
swimlanes: PositionedSwimlane[],
|
|
112
|
+
includes: PositionedIncludeRegion[],
|
|
113
|
+
ctx: LayoutContext,
|
|
114
|
+
): PositionedDependencyEdge[] {
|
|
115
|
+
const { obstacles, containers } = collectRouting(swimlanes, includes);
|
|
116
|
+
const bandwidth = ctx.bandScale.bandwidth();
|
|
117
|
+
const style = resolveStyle('item', [], ctx.styleCtx);
|
|
118
|
+
// Each slot keeps the arrow's place in `after:` order, whichever way it
|
|
119
|
+
// is built, so the model lists arrows as the file declares them.
|
|
120
|
+
const slots: Array<PositionedDependencyEdge | DependencyRouteRequest | null> = [];
|
|
121
|
+
|
|
122
|
+
for (const [id, item] of items) {
|
|
123
|
+
const placed = ctx.placedItems.get(id);
|
|
124
|
+
if (!placed) continue;
|
|
125
|
+
const target = itemArrowEnd(placed, bandwidth);
|
|
126
|
+
for (const pred of propValues(item.properties, 'after')) {
|
|
127
|
+
const predItem = ctx.placedItems.get(pred);
|
|
128
|
+
const predContainer = predItem ? undefined : containers.get(pred);
|
|
129
|
+
let source: ArrowEnd | undefined;
|
|
130
|
+
if (predItem) source = itemArrowEnd(predItem, bandwidth);
|
|
131
|
+
else if (predContainer) source = containerArrowEnd(predContainer);
|
|
132
|
+
if (source) {
|
|
133
|
+
slots.push({ fromId: pred, toId: id, source, target });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// An anchor or milestone: its cut line is the stem. An item that
|
|
137
|
+
// starts on it touches the line and needs no arrow; one that
|
|
138
|
+
// starts later gets a dotted stub from the line to its start
|
|
139
|
+
// (specs/true-width-layout.md §3.5).
|
|
140
|
+
const marker = ctx.entityMidpoints.get(pred);
|
|
141
|
+
if (!marker || target.startX - marker.x <= ON_LINE_PX) continue;
|
|
142
|
+
const y = target.rowY ?? target.top;
|
|
143
|
+
slots.push({
|
|
144
|
+
fromId: pred,
|
|
145
|
+
toId: id,
|
|
146
|
+
waypoints: [
|
|
147
|
+
{ x: marker.x, y },
|
|
148
|
+
{ x: target.paintedX, y },
|
|
149
|
+
],
|
|
150
|
+
slack: true,
|
|
151
|
+
...arrowHead(target.bottom - target.top),
|
|
152
|
+
style,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const requests = slots.filter((s): s is DependencyRouteRequest => s !== null && 'source' in s);
|
|
158
|
+
const routed = routeDependencies(requests, obstacles, ctx.scale.pixelsPerDay);
|
|
159
|
+
const byRequest = new Map(requests.map((r, i) => [r, routed[i]]));
|
|
160
|
+
const out: PositionedDependencyEdge[] = [];
|
|
161
|
+
for (const slot of slots) {
|
|
162
|
+
if (!slot) continue;
|
|
163
|
+
if (!('source' in slot)) {
|
|
164
|
+
out.push(slot);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const r = byRequest.get(slot);
|
|
168
|
+
if (r) out.push({ ...r, style });
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
@@ -1,60 +1,200 @@
|
|
|
1
|
-
// FootnoteNode +
|
|
2
|
-
// chart. Each footnote gets a stable index (1-based,
|
|
1
|
+
// FootnoteNode + indexFootnotes / buildFootnotePanel — footnote area at the bottom of the
|
|
2
|
+
// chart. Each footnote gets a stable index (1-based, in document order) and
|
|
3
3
|
// records its `on:` host references so item / swimlane sequencing can
|
|
4
|
-
// emit the matching superscript indicators.
|
|
5
|
-
//
|
|
4
|
+
// emit the matching superscript indicators. An isolated region's
|
|
5
|
+
// footnotes continue the numbering after the host's, in include order,
|
|
6
|
+
// and share the one panel under a heading row with the region's title.
|
|
7
|
+
// An entry whose text does not fit the panel's width wraps onto more
|
|
8
|
+
// rows at the title's x. The panel grows to fit: 28 px header + (entry
|
|
9
|
+
// rows and region headings × FOOTNOTE_ROW_HEIGHT) + 16 px padding.
|
|
6
10
|
|
|
7
11
|
import type { FootnoteDeclaration } from '@nowline/core';
|
|
8
12
|
import { propValues } from '../dsl-utils.js';
|
|
9
13
|
import type { LayoutContext } from '../layout-context.js';
|
|
10
14
|
import { resolveStyle } from '../style-resolution.js';
|
|
15
|
+
import { estimateTextWidth, wrapText } from '../text-measure.js';
|
|
11
16
|
import {
|
|
12
17
|
FOOTNOTE_HEADER_HEIGHT_PX,
|
|
13
18
|
FOOTNOTE_PANEL_PADDING_PX,
|
|
14
19
|
FOOTNOTE_ROW_HEIGHT,
|
|
15
20
|
} from '../themes/shared.js';
|
|
16
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
BoundingBox,
|
|
23
|
+
PositionedFootnoteArea,
|
|
24
|
+
PositionedFootnoteEntry,
|
|
25
|
+
PositionedFootnoteLine,
|
|
26
|
+
} from '../types.js';
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
/** Each footnote's 1-based number (in document order) and its `on:` host ids. */
|
|
29
|
+
export interface FootnoteIndex {
|
|
20
30
|
index: Map<string, number>;
|
|
21
31
|
hosts: Map<string, string[]>;
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Pure: depends only on the footnote declarations. Numbers start after
|
|
36
|
+
* `precedingCount` (an isolated region continues after the host and the
|
|
37
|
+
* regions before it). The map's order is document order: the resolver
|
|
38
|
+
* inserts a file's own footnotes as declared, then each merged include's.
|
|
39
|
+
*/
|
|
40
|
+
export function indexFootnotes(
|
|
25
41
|
footnotes: Map<string, FootnoteDeclaration>,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
): BuiltFootnotes {
|
|
29
|
-
const entries: PositionedFootnoteEntry[] = [];
|
|
42
|
+
precedingCount = 0,
|
|
43
|
+
): FootnoteIndex {
|
|
30
44
|
const index = new Map<string, number>();
|
|
31
45
|
const hosts = new Map<string, string[]>();
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const n = i + 1;
|
|
35
|
-
index.set(id, n);
|
|
46
|
+
[...footnotes.entries()].forEach(([id, f], i) => {
|
|
47
|
+
index.set(id, precedingCount + i + 1);
|
|
36
48
|
hosts.set(id, propValues(f.properties, 'on'));
|
|
37
|
-
entries.push({
|
|
38
|
-
number: n,
|
|
39
|
-
title: f.title ?? id,
|
|
40
|
-
description: f.description?.text,
|
|
41
|
-
style: resolveStyle('footnote', f.properties, ctx.styleCtx),
|
|
42
|
-
});
|
|
43
49
|
});
|
|
50
|
+
return { index, hosts };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An isolated region's footnotes, numbered and styled by its own context. */
|
|
54
|
+
export interface RegionFootnotes {
|
|
55
|
+
/** The region's title, as on its label tab. */
|
|
56
|
+
title: string;
|
|
57
|
+
footnotes: Map<string, FootnoteDeclaration>;
|
|
58
|
+
ctx: LayoutContext;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Font sizes (px) of an entry's title and description. */
|
|
62
|
+
const FOOTNOTE_TEXT_FONT_SIZE_PX = 11;
|
|
63
|
+
/** The description column never starts closer than this to the title's x. */
|
|
64
|
+
const FOOTNOTE_DESCRIPTION_MIN_OFFSET_PX = 120;
|
|
65
|
+
/** Per-character advance the description column is placed by. */
|
|
66
|
+
const FOOTNOTE_TITLE_CHAR_ADVANCE_PX = 6;
|
|
67
|
+
const DESCRIPTION_LEAD = '— ';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The panel below the chart, spanning the final canvas (`chartRightX`):
|
|
71
|
+
* the host's footnotes, then each region's under its title, each in
|
|
72
|
+
* document order. The canvas does not grow for the panel: an entry whose
|
|
73
|
+
* title or description runs past the panel's inner right edge wraps onto
|
|
74
|
+
* more rows, which start at the title's x and use the whole width.
|
|
75
|
+
*/
|
|
76
|
+
export function buildFootnotePanel(
|
|
77
|
+
footnotes: Map<string, FootnoteDeclaration>,
|
|
78
|
+
ctx: LayoutContext,
|
|
79
|
+
chartBottomY: number,
|
|
80
|
+
chartRightX: number,
|
|
81
|
+
regions: readonly RegionFootnotes[] = [],
|
|
82
|
+
): PositionedFootnoteArea {
|
|
83
|
+
const boxX = FOOTNOTE_PANEL_PADDING_PX;
|
|
84
|
+
const boxY = chartBottomY + FOOTNOTE_PANEL_PADDING_PX;
|
|
85
|
+
const boxWidth = chartRightX - 2 * FOOTNOTE_PANEL_PADDING_PX;
|
|
86
|
+
const titleX = boxX + 2 * FOOTNOTE_PANEL_PADDING_PX;
|
|
87
|
+
const textRightX = boxX + boxWidth - FOOTNOTE_PANEL_PADDING_PX;
|
|
88
|
+
const firstRowY = boxY + FOOTNOTE_HEADER_HEIGHT_PX + FOOTNOTE_PANEL_PADDING_PX;
|
|
89
|
+
const entries: PositionedFootnoteEntry[] = [];
|
|
90
|
+
let row = 0;
|
|
91
|
+
const add = (
|
|
92
|
+
decls: Map<string, FootnoteDeclaration>,
|
|
93
|
+
source: LayoutContext,
|
|
94
|
+
region?: string,
|
|
95
|
+
): void => {
|
|
96
|
+
for (const [id, f] of decls) {
|
|
97
|
+
const title = f.title ?? id;
|
|
98
|
+
const description = f.description?.text;
|
|
99
|
+
const lines = entryLines(title, description, titleX, textRightX);
|
|
100
|
+
const y = firstRowY + row * FOOTNOTE_ROW_HEIGHT;
|
|
101
|
+
const at = (l: { x: number; row: number; text: string }): PositionedFootnoteLine => ({
|
|
102
|
+
x: l.x,
|
|
103
|
+
y: y + l.row * FOOTNOTE_ROW_HEIGHT,
|
|
104
|
+
text: l.text,
|
|
105
|
+
});
|
|
106
|
+
const entry: PositionedFootnoteEntry = {
|
|
107
|
+
number: source.footnoteIndex.get(id) ?? entries.length + 1,
|
|
108
|
+
y,
|
|
109
|
+
title,
|
|
110
|
+
titleLines: lines.title.map(at),
|
|
111
|
+
description,
|
|
112
|
+
style: resolveStyle('footnote', f.properties, source.styleCtx),
|
|
113
|
+
};
|
|
114
|
+
if (lines.description.length > 0) entry.descriptionLines = lines.description.map(at);
|
|
115
|
+
if (region !== undefined) entry.region = region;
|
|
116
|
+
entries.push(entry);
|
|
117
|
+
row += lines.rows;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
add(footnotes, ctx);
|
|
121
|
+
for (const r of regions) {
|
|
122
|
+
if (r.footnotes.size === 0) continue;
|
|
123
|
+
// The region's heading takes a row of its own above its entries.
|
|
124
|
+
const headingY = firstRowY + row * FOOTNOTE_ROW_HEIGHT;
|
|
125
|
+
row += 1;
|
|
126
|
+
const first = entries.length;
|
|
127
|
+
add(r.footnotes, r.ctx, r.title);
|
|
128
|
+
entries[first].regionHeadingY = headingY;
|
|
129
|
+
}
|
|
44
130
|
const box: BoundingBox = {
|
|
45
|
-
x:
|
|
46
|
-
y:
|
|
47
|
-
width:
|
|
131
|
+
x: boxX,
|
|
132
|
+
y: boxY,
|
|
133
|
+
width: boxWidth,
|
|
48
134
|
height:
|
|
49
135
|
entries.length === 0
|
|
50
136
|
? 0
|
|
51
|
-
: FOOTNOTE_HEADER_HEIGHT_PX +
|
|
52
|
-
entries.length * FOOTNOTE_ROW_HEIGHT +
|
|
53
|
-
FOOTNOTE_PANEL_PADDING_PX,
|
|
137
|
+
: FOOTNOTE_HEADER_HEIGHT_PX + row * FOOTNOTE_ROW_HEIGHT + FOOTNOTE_PANEL_PADDING_PX,
|
|
54
138
|
};
|
|
139
|
+
return { box, entries };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface EntryLine {
|
|
143
|
+
x: number;
|
|
144
|
+
/** Rows below the entry's first row. */
|
|
145
|
+
row: number;
|
|
146
|
+
text: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wrap one entry into rows. The title starts at `titleX`; the description
|
|
151
|
+
* follows on the title's row at the description column (`— ` first) and
|
|
152
|
+
* wraps at `titleX`. A wrapped title, or a description whose first word
|
|
153
|
+
* does not fit beside the title, starts the description on a row of its
|
|
154
|
+
* own. A single word wider than the panel stays whole on its row, as
|
|
155
|
+
* `wrapText` never splits a word.
|
|
156
|
+
*/
|
|
157
|
+
function entryLines(
|
|
158
|
+
title: string,
|
|
159
|
+
description: string | undefined,
|
|
160
|
+
titleX: number,
|
|
161
|
+
textRightX: number,
|
|
162
|
+
): { title: EntryLine[]; description: EntryLine[]; rows: number } {
|
|
163
|
+
const fullWidth = textRightX - titleX;
|
|
164
|
+
const titleRows = fitsOrWraps(title, fullWidth, fullWidth);
|
|
165
|
+
const titleLines = titleRows.map((text, row) => ({ x: titleX, row, text }));
|
|
166
|
+
if (!description) return { title: titleLines, description: [], rows: titleRows.length };
|
|
167
|
+
|
|
168
|
+
const leadWidth = estimateTextWidth(DESCRIPTION_LEAD, FOOTNOTE_TEXT_FONT_SIZE_PX);
|
|
169
|
+
const firstWord = description.trim().split(/\s+/)[0] ?? '';
|
|
170
|
+
const columnX =
|
|
171
|
+
titleX +
|
|
172
|
+
Math.max(FOOTNOTE_DESCRIPTION_MIN_OFFSET_PX, title.length * FOOTNOTE_TITLE_CHAR_ADVANCE_PX);
|
|
173
|
+
const besideTitle =
|
|
174
|
+
titleRows.length === 1 &&
|
|
175
|
+
leadWidth + estimateTextWidth(firstWord, FOOTNOTE_TEXT_FONT_SIZE_PX) <=
|
|
176
|
+
textRightX - columnX;
|
|
177
|
+
const firstX = besideTitle ? columnX : titleX;
|
|
178
|
+
const firstRow = besideTitle ? titleRows.length - 1 : titleRows.length;
|
|
179
|
+
const rows = fitsOrWraps(description, fullWidth, textRightX - firstX - leadWidth);
|
|
180
|
+
const descriptionLines = rows.map((text, i) => ({
|
|
181
|
+
x: i === 0 ? firstX : titleX,
|
|
182
|
+
row: firstRow + i,
|
|
183
|
+
text: i === 0 ? `${DESCRIPTION_LEAD}${text}` : text,
|
|
184
|
+
}));
|
|
55
185
|
return {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
186
|
+
title: titleLines,
|
|
187
|
+
description: descriptionLines,
|
|
188
|
+
rows: firstRow + rows.length,
|
|
59
189
|
};
|
|
60
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* `text` as is when it fits `firstWidth`, else wrapped (`wrapText`, which
|
|
194
|
+
* collapses whitespace), so an entry that fits keeps its exact text.
|
|
195
|
+
*/
|
|
196
|
+
function fitsOrWraps(text: string, width: number, firstWidth: number): string[] {
|
|
197
|
+
if (estimateTextWidth(text, FOOTNOTE_TEXT_FONT_SIZE_PX) <= firstWidth) return [text];
|
|
198
|
+
const rows = wrapText(text, width, FOOTNOTE_TEXT_FONT_SIZE_PX, firstWidth);
|
|
199
|
+
return rows.length > 0 ? rows : [text];
|
|
200
|
+
}
|