@diagrammo/dgmo 0.47.0 → 0.48.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/auto.js +1 -1
- package/dist/auto.mjs +1 -1
- package/dist/element.js +1 -1
- package/dist/element.mjs +1 -1
- package/package.json +19 -70
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
|
@@ -1,1742 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Event Line — D3 SVG Renderer (spec §28)
|
|
3
|
-
// ============================================================
|
|
4
|
-
//
|
|
5
|
-
// Horizontal spine of point events. Each event: a dot at its date position
|
|
6
|
-
// (or even index under no-scale), a vertical leader line, and an org-style card
|
|
7
|
-
// (utils/card.ts `renderNodeCard` chrome + a hand-drawn divider and a
|
|
8
|
-
// pyramid/ring prose body). The event's date rides INSIDE its card as a muted
|
|
9
|
-
// subtitle, so it travels with the event instead of sitting on the axis. The
|
|
10
|
-
// spine carries no date tick ruler. Cards auto-alternate above/below and pack
|
|
11
|
-
// into stacked lanes on collision. The tag legend uses the shared
|
|
12
|
-
// `renderIntegratedLegend`.
|
|
13
|
-
|
|
14
|
-
import * as d3Selection from 'd3-selection';
|
|
15
|
-
import { FONT_FAMILY } from '../fonts';
|
|
16
|
-
import {
|
|
17
|
-
TITLE_FONT_SIZE,
|
|
18
|
-
TITLE_FONT_WEIGHT,
|
|
19
|
-
TITLE_Y,
|
|
20
|
-
} from '../utils/title-constants';
|
|
21
|
-
import {
|
|
22
|
-
CARD_RADIUS,
|
|
23
|
-
HEADER_HEIGHT,
|
|
24
|
-
LABEL_FONT_SIZE,
|
|
25
|
-
NODE_STROKE_WIDTH,
|
|
26
|
-
SEPARATOR_GAP,
|
|
27
|
-
} from '../utils/visual-conventions';
|
|
28
|
-
import {
|
|
29
|
-
contrastText,
|
|
30
|
-
getSeriesColors,
|
|
31
|
-
mix,
|
|
32
|
-
shapeFill,
|
|
33
|
-
themeBaseBg,
|
|
34
|
-
} from '../palettes/color-utils';
|
|
35
|
-
import { resolveColor } from '../colors';
|
|
36
|
-
import { renderNodeCard } from '../utils/card';
|
|
37
|
-
import { renderInlineText } from '../utils/inline-markdown';
|
|
38
|
-
import { CHAR_WIDTH_RATIO } from '../utils/text-measure';
|
|
39
|
-
import {
|
|
40
|
-
wrapDescriptionLines,
|
|
41
|
-
type WrappedDescLine,
|
|
42
|
-
} from '../utils/wrapped-desc';
|
|
43
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
44
|
-
import { formatDateLabel } from '../timeline/renderer';
|
|
45
|
-
import type { LegendGroupData } from '../utils/legend-types';
|
|
46
|
-
import {
|
|
47
|
-
resolveActiveTagGroup,
|
|
48
|
-
resolveTagColor,
|
|
49
|
-
type TagGroup,
|
|
50
|
-
} from '../utils/tag-groups';
|
|
51
|
-
import type { PaletteColors } from '../palettes';
|
|
52
|
-
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
53
|
-
import type { EventLineEra, EventLineEvent, ParsedEventLine } from './types';
|
|
54
|
-
|
|
55
|
-
// ── Geometry constants ───────────────────────────────────────
|
|
56
|
-
const CARD_W = 210;
|
|
57
|
-
const H_MARGIN = 58;
|
|
58
|
-
const TITLE_AREA = 48;
|
|
59
|
-
const LEGEND_BAND = 34;
|
|
60
|
-
const LEADER_ABOVE = 46;
|
|
61
|
-
const LEADER_BELOW = 46;
|
|
62
|
-
const DESC_FONT = 11.5;
|
|
63
|
-
const DESC_LINE_H = 16;
|
|
64
|
-
const CARD_PAD = 9;
|
|
65
|
-
const CARD_BODY_TOP = HEADER_HEIGHT + SEPARATOR_GAP;
|
|
66
|
-
const DOT_R = 5.5;
|
|
67
|
-
const MIN_SPACING = 96;
|
|
68
|
-
const LANE_GAP = 16;
|
|
69
|
-
const NEUTRAL_TAG = '#999999';
|
|
70
|
-
// Horizontal placement: a card may slide sideways as long as its dot stays at
|
|
71
|
-
// least CARD_INSET from either edge (so a vertical leader still lands cleanly
|
|
72
|
-
// on it). Cards fan side-by-side until they can't, then stack into lanes.
|
|
73
|
-
const CARD_INSET = 18;
|
|
74
|
-
const FAN_GAP = 6;
|
|
75
|
-
// The event's date rides INSIDE the card as a muted subtitle directly adjacent to
|
|
76
|
-
// the title (on the side away from the spine), so a date travels with its event
|
|
77
|
-
// instead of sitting on the axis. This much header height is reserved for it.
|
|
78
|
-
const DATE_SUBTITLE_H = 15;
|
|
79
|
-
const DATE_SUBTITLE_FONT = 10;
|
|
80
|
-
// `no-box` only: a soft tag-tinted "shelf" sits behind the title + date so the
|
|
81
|
-
// header reads as a unit, and a full-strength colored bar on its spine-side edge
|
|
82
|
-
// is the leader's landing pad — keeping the dot→block link solid in dense charts.
|
|
83
|
-
const SHELF_TINT = 13; // % of the tag color mixed over the theme base
|
|
84
|
-
// Thickness of the colored landing lip. The shelf's corner radius is matched to
|
|
85
|
-
// it (SHELF_EDGE below) so the lip exactly fills the rounded corners and reads
|
|
86
|
-
// as part of the rounded rect — a thinner lip therefore also means slightly
|
|
87
|
-
// tighter shelf corners, which keeps the carry-through clean.
|
|
88
|
-
const SHELF_EDGE = 4;
|
|
89
|
-
// Era `]` bracket band: depth reserved on the side opposite the cards.
|
|
90
|
-
const ERA_BLOCK = 30;
|
|
91
|
-
const ERA_BRACKET_CAP = 8;
|
|
92
|
-
// Padding each era bracket extends past its first/last member dot, and the gap
|
|
93
|
-
// kept on each side of a seam when two adjacent brackets are clamped apart.
|
|
94
|
-
const ERA_BRACKET_PAD = 14;
|
|
95
|
-
const ERA_SEAM_GAP = 3;
|
|
96
|
-
// Collapsed-era axis-break glyph (`≈` rotated 90°): two short parallel wavy
|
|
97
|
-
// strokes that cross the spine, with the spine blanked between them so the
|
|
98
|
-
// timeline visibly breaks where the folded span is.
|
|
99
|
-
const BREAK_HALF_H = 6; // squiggle extends this far above AND below the spine
|
|
100
|
-
const BREAK_AMP = 2.6; // horizontal wave amplitude
|
|
101
|
-
const BREAK_GAP = 5; // spacing between the two waves (= the spine gap width)
|
|
102
|
-
// Collapsed era: the `⊓` bar floats this far off the spine (on the card's side);
|
|
103
|
-
// its legs drop from the bar to rest their feet ON the timeline.
|
|
104
|
-
const ERA_LEG = 13;
|
|
105
|
-
const ERA_LABEL_FONT = 11.5;
|
|
106
|
-
|
|
107
|
-
// ── Hover interactivity (preview only — inert in the resvg export path) ───────
|
|
108
|
-
// All effects are renderer-owned so they work identically in the app, the web
|
|
109
|
-
// editor, and Obsidian, and re-bind on every re-render (no orphaned listeners).
|
|
110
|
-
// CSS supplies the smooth self-effects + the dim/highlight state classes; a
|
|
111
|
-
// single delegated handler on the SVG root correlates the pieces of one event,
|
|
112
|
-
// an era's members, and legend-entry → category focus.
|
|
113
|
-
const HL = 'dgmo-evt-hl';
|
|
114
|
-
const DIM = 'dgmo-evt-dim';
|
|
115
|
-
const ERA_HL = 'dgmo-evt-era-hl';
|
|
116
|
-
// Clicking a legend entry collapses that tag value's cards (and their leaders)
|
|
117
|
-
// to bare dots — the dot stays on the spine in its tag color and still reveals
|
|
118
|
-
// its card on hover; the legend entry dims with a hollow swatch so the muted
|
|
119
|
-
// state is self-documenting and reversible. Preview-only
|
|
120
|
-
// (never in `exportMode`), so static SVG/PNG always carries the full set.
|
|
121
|
-
const COLLAPSED = 'dgmo-evt-collapsed';
|
|
122
|
-
const OFF = 'dgmo-evt-off';
|
|
123
|
-
const HIDDEN_ATTR = 'data-evt-hidden';
|
|
124
|
-
const HOVER_CSS =
|
|
125
|
-
`.dgmo-event-dot{transition:transform .12s ease;transform-box:fill-box;transform-origin:center}` +
|
|
126
|
-
`.dgmo-event-leader,.dgmo-event-card,.dgmo-event-era{transition:opacity .12s ease,filter .12s ease}` +
|
|
127
|
-
`.dgmo-event-dot,.dgmo-event-card,.dgmo-event-era{cursor:default}` +
|
|
128
|
-
`[data-legend-entry]{cursor:pointer}` +
|
|
129
|
-
`.${DIM}{opacity:.2}` +
|
|
130
|
-
`.dgmo-event-dot.${HL}{transform:scale(1.55)}` +
|
|
131
|
-
`.dgmo-event-leader.${HL}{stroke-opacity:1;stroke-width:2.5}` +
|
|
132
|
-
`.dgmo-event-card.${HL}{filter:drop-shadow(0 2px 5px rgba(0,0,0,.22))}` +
|
|
133
|
-
`.dgmo-event-era.${ERA_HL}{filter:drop-shadow(0 1px 2px rgba(0,0,0,.28))}` +
|
|
134
|
-
// Collapsed-to-dot: hide the card + its leader, keep the dot. A hover that
|
|
135
|
-
// glows the event (HL on all its pieces) transiently re-reveals the card —
|
|
136
|
-
// the extra class out-specifies both the collapse rule and DIM.
|
|
137
|
-
`.dgmo-event-card.${COLLAPSED},.dgmo-event-leader.${COLLAPSED}{opacity:0;pointer-events:none}` +
|
|
138
|
-
`.dgmo-event-card.${COLLAPSED}.${HL},.dgmo-event-leader.${COLLAPSED}.${HL}{opacity:1;pointer-events:auto}` +
|
|
139
|
-
`[data-legend-entry].${OFF} text{opacity:.5}`;
|
|
140
|
-
|
|
141
|
-
type Side = 'above' | 'below';
|
|
142
|
-
|
|
143
|
-
interface Placed {
|
|
144
|
-
/** `event` = a visible event card; `era` = a collapsed era's summary card. */
|
|
145
|
-
kind: 'event' | 'era';
|
|
146
|
-
event: EventLineEvent | null;
|
|
147
|
-
era: EventLineEra | null;
|
|
148
|
-
members: readonly EventLineEvent[];
|
|
149
|
-
label: string;
|
|
150
|
-
date: string | null;
|
|
151
|
-
dateValue: number | null;
|
|
152
|
-
/** True for a `TBD` future event — drawn with a hollow dot + dashed leader. */
|
|
153
|
-
future: boolean;
|
|
154
|
-
/** Bracketed-TBD gap `[lo, hi]` (dateValue units) for the "somewhere in here"
|
|
155
|
-
* whisker; null for a trailing TBD (dashed spine tail) or any real event. */
|
|
156
|
-
futureSpan: readonly [number, number] | null;
|
|
157
|
-
lineNumber: number;
|
|
158
|
-
/** Name of the enclosing era (for bracket runs), or null. */
|
|
159
|
-
eraName: string | null;
|
|
160
|
-
color: string;
|
|
161
|
-
cardFill: string;
|
|
162
|
-
titleColor: string;
|
|
163
|
-
lines: WrappedDescLine[];
|
|
164
|
-
/** For a collapsed era: per-member tag color, in member order (one per bullet). */
|
|
165
|
-
bulletColors: readonly string[];
|
|
166
|
-
/** For a collapsed era: per-member tag metadata, in member/bullet order — lets a
|
|
167
|
-
* legend hover highlight matching member bullets inside the folded card. */
|
|
168
|
-
bulletMeta: readonly Readonly<Record<string, string>>[];
|
|
169
|
-
cardH: number;
|
|
170
|
-
x: number;
|
|
171
|
-
side: Side;
|
|
172
|
-
lane: number;
|
|
173
|
-
left: number;
|
|
174
|
-
/** Collapsed era only: half the date-span width in px, so its bracket stretches
|
|
175
|
-
* across the folded run (`x` is the span MIDPOINT). 0 for events. */
|
|
176
|
-
spanHalf: number;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function renderEventLine(
|
|
180
|
-
container: HTMLDivElement,
|
|
181
|
-
parsed: ParsedEventLine,
|
|
182
|
-
palette: PaletteColors,
|
|
183
|
-
isDark: boolean,
|
|
184
|
-
onClickItem?: (lineNumber: number) => void,
|
|
185
|
-
exportDims?: D3ExportDimensions,
|
|
186
|
-
tagOverride?: string
|
|
187
|
-
): void {
|
|
188
|
-
if (parsed.events.length === 0) return;
|
|
189
|
-
|
|
190
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
191
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
192
|
-
const heightHint = exportDims?.height ?? container.clientHeight;
|
|
193
|
-
if (width <= 0 || heightHint <= 0) return;
|
|
194
|
-
|
|
195
|
-
const exportMode = !!exportDims;
|
|
196
|
-
// Preview-only legend-muted tag values (persisted on the container so they
|
|
197
|
-
// survive every re-render — era collapse/expand, resize, palette change). The
|
|
198
|
-
// export path always builds the full set; muting is interactive chrome only.
|
|
199
|
-
const hiddenSet = exportMode ? new Set<string>() : readHidden(container);
|
|
200
|
-
const seriesColors = getSeriesColors(palette);
|
|
201
|
-
const accent = seriesColors[0]!;
|
|
202
|
-
const activeGroup = resolveActiveTagGroup(
|
|
203
|
-
parsed.tagGroups,
|
|
204
|
-
undefined,
|
|
205
|
-
tagOverride
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const showTitle = !!parsed.title && !parsed.options.noTitle;
|
|
209
|
-
const hasLegend =
|
|
210
|
-
parsed.tagGroups.length > 0 &&
|
|
211
|
-
activeGroup !== null &&
|
|
212
|
-
!parsed.options.noLegend;
|
|
213
|
-
const titleH = showTitle ? TITLE_AREA : 0;
|
|
214
|
-
const legendH = hasLegend ? LEGEND_BAND : 0;
|
|
215
|
-
const topUsed = titleH + legendH;
|
|
216
|
-
|
|
217
|
-
// ── Eras (§28.6a) ──────────────────────────────────────────
|
|
218
|
-
// The era sits OPPOSITE the cards (one-sided → opposite the chosen side;
|
|
219
|
-
// alternating → below by default). A collapsed era folds into one event-like
|
|
220
|
-
// summary card on the cards' side, with a `]` bracket left on the spine.
|
|
221
|
-
const eraByName = new Map(parsed.eras.map((e) => [e.name, e]));
|
|
222
|
-
const collapsedSet = new Set(
|
|
223
|
-
parsed.eras.filter((e) => e.collapsed).map((e) => e.name)
|
|
224
|
-
);
|
|
225
|
-
const hasEras = parsed.events.some((e) => e.era && eraByName.has(e.era));
|
|
226
|
-
// Only EXPANDED eras draw the bottom `]` bracket band (and reserve room for
|
|
227
|
-
// it). A collapsed era is marked by a span bracket on the spine instead.
|
|
228
|
-
const hasExpandedEra = parsed.events.some(
|
|
229
|
-
(e) => e.era && eraByName.has(e.era) && !collapsedSet.has(e.era)
|
|
230
|
-
);
|
|
231
|
-
const sideOpt = parsed.options.side;
|
|
232
|
-
const alternate = sideOpt === 'alternate';
|
|
233
|
-
const eraSide: Side = alternate
|
|
234
|
-
? 'below'
|
|
235
|
-
: sideOpt === 'above'
|
|
236
|
-
? 'below'
|
|
237
|
-
: 'above';
|
|
238
|
-
|
|
239
|
-
// Slots in source order: a visible event, or one summary per collapsed era.
|
|
240
|
-
type Slot =
|
|
241
|
-
| { kind: 'event'; event: EventLineEvent }
|
|
242
|
-
| { kind: 'era'; era: EventLineEra; members: EventLineEvent[] };
|
|
243
|
-
const slots: Slot[] = [];
|
|
244
|
-
const emitted = new Set<string>();
|
|
245
|
-
for (const event of parsed.events) {
|
|
246
|
-
const name = event.era;
|
|
247
|
-
if (name && collapsedSet.has(name)) {
|
|
248
|
-
if (!emitted.has(name)) {
|
|
249
|
-
emitted.add(name);
|
|
250
|
-
slots.push({
|
|
251
|
-
kind: 'era',
|
|
252
|
-
era: eraByName.get(name)!,
|
|
253
|
-
members: parsed.events.filter((e) => e.era === name),
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
} else {
|
|
257
|
-
slots.push({ kind: 'event', event });
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// ── Per-slot color + wrapped body + card height ──
|
|
262
|
-
const charsPerLine = Math.max(
|
|
263
|
-
8,
|
|
264
|
-
Math.floor((CARD_W - CARD_PAD * 2) / (DESC_FONT * CHAR_WIDTH_RATIO))
|
|
265
|
-
);
|
|
266
|
-
const cardHeight = (lines: WrappedDescLine[], hasDate: boolean): number =>
|
|
267
|
-
HEADER_HEIGHT +
|
|
268
|
-
(hasDate ? DATE_SUBTITLE_H : 0) +
|
|
269
|
-
(lines.length > 0 ? SEPARATOR_GAP + lines.length * DESC_LINE_H : 0) +
|
|
270
|
-
(lines.length > 0 ? CARD_PAD : 6);
|
|
271
|
-
// An event's solid color = its active-tag color (or the neutral accent).
|
|
272
|
-
const eventColor = (ev: EventLineEvent): string => {
|
|
273
|
-
if (!activeGroup) return accent;
|
|
274
|
-
const tc = resolveTagColor(
|
|
275
|
-
ev.metadata,
|
|
276
|
-
parsed.tagGroups as TagGroup[],
|
|
277
|
-
activeGroup
|
|
278
|
-
);
|
|
279
|
-
return tc && tc !== NEUTRAL_TAG
|
|
280
|
-
? (resolveColor(tc, palette) ?? tc)
|
|
281
|
-
: accent;
|
|
282
|
-
};
|
|
283
|
-
let evIdx = 0;
|
|
284
|
-
const placed: Placed[] = slots.map((slot): Placed => {
|
|
285
|
-
// Events AND collapsed eras share the alternation in source order, so a
|
|
286
|
-
// collapsed era takes its natural above/below slot like any other entry.
|
|
287
|
-
const side: Side = alternate
|
|
288
|
-
? evIdx++ % 2 === 0
|
|
289
|
-
? 'above'
|
|
290
|
-
: 'below'
|
|
291
|
-
: (sideOpt as Side);
|
|
292
|
-
if (slot.kind === 'event') {
|
|
293
|
-
const event = slot.event;
|
|
294
|
-
const solid = eventColor(event);
|
|
295
|
-
const cardFill = shapeFill(palette, solid, isDark, { solid: false });
|
|
296
|
-
const titleColor = contrastText(
|
|
297
|
-
cardFill,
|
|
298
|
-
palette.textOnFillLight,
|
|
299
|
-
palette.textOnFillDark
|
|
300
|
-
);
|
|
301
|
-
const lines = wrapDescription(event.description, charsPerLine);
|
|
302
|
-
return {
|
|
303
|
-
kind: 'event',
|
|
304
|
-
event,
|
|
305
|
-
era: null,
|
|
306
|
-
members: [],
|
|
307
|
-
label: event.label,
|
|
308
|
-
date: event.date,
|
|
309
|
-
dateValue: event.dateValue,
|
|
310
|
-
future: event.future,
|
|
311
|
-
futureSpan: event.futureSpan,
|
|
312
|
-
lineNumber: event.lineNumber,
|
|
313
|
-
eraName: event.era,
|
|
314
|
-
color: solid,
|
|
315
|
-
cardFill,
|
|
316
|
-
titleColor,
|
|
317
|
-
lines,
|
|
318
|
-
bulletColors: [],
|
|
319
|
-
bulletMeta: [],
|
|
320
|
-
cardH: cardHeight(lines, !!event.date),
|
|
321
|
-
x: 0,
|
|
322
|
-
side,
|
|
323
|
-
lane: 0,
|
|
324
|
-
left: 0,
|
|
325
|
-
spanHalf: 0,
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
// Collapsed era → an event-like summary card: era name + bulleted members.
|
|
329
|
-
// Color: an explicit `[Name] color` wins; otherwise the era is BLACK unless
|
|
330
|
-
// every member shares one tag, in which case it adopts that tag color.
|
|
331
|
-
const era = slot.era;
|
|
332
|
-
const memberColors = slot.members.map(eventColor);
|
|
333
|
-
const uniformTag =
|
|
334
|
-
activeGroup != null &&
|
|
335
|
-
memberColors.length > 0 &&
|
|
336
|
-
memberColors.every((c) => c === memberColors[0]) &&
|
|
337
|
-
memberColors[0] !== accent;
|
|
338
|
-
const solid = era.color
|
|
339
|
-
? (resolveColor(era.color, palette) ?? palette.text)
|
|
340
|
-
: uniformTag
|
|
341
|
-
? memberColors[0]!
|
|
342
|
-
: palette.text;
|
|
343
|
-
// A collapsed era is a folded summary — render it much lighter than an event
|
|
344
|
-
// card (a faint ~10% tint) so it recedes rather than competing for attention.
|
|
345
|
-
const cardFill = mix(solid, themeBaseBg(palette, isDark), 10);
|
|
346
|
-
const titleColor = contrastText(
|
|
347
|
-
cardFill,
|
|
348
|
-
palette.textOnFillLight,
|
|
349
|
-
palette.textOnFillDark
|
|
350
|
-
);
|
|
351
|
-
// A collapsed era folds a date RANGE into one card. Anchor it at the span
|
|
352
|
-
// MIDPOINT so its card sits over the middle of the run, and its bracket (drawn
|
|
353
|
-
// later) stretches across the whole [first … last] member range — the folded
|
|
354
|
-
// span keeps its true timeline footprint instead of collapsing to a point.
|
|
355
|
-
let spanLo: number | null = null;
|
|
356
|
-
let spanHi: number | null = null;
|
|
357
|
-
for (const m of slot.members) {
|
|
358
|
-
if (m.dateValue === null) continue;
|
|
359
|
-
if (spanLo === null || m.dateValue < spanLo) spanLo = m.dateValue;
|
|
360
|
-
if (spanHi === null || m.dateValue > spanHi) spanHi = m.dateValue;
|
|
361
|
-
}
|
|
362
|
-
const repDateValue =
|
|
363
|
-
spanLo !== null && spanHi !== null ? (spanLo + spanHi) / 2 : null;
|
|
364
|
-
// Show every folded member — a collapsed era summarizes the whole run, so
|
|
365
|
-
// the card grows to fit rather than truncating with a "+N more" line. A
|
|
366
|
-
// member whose tag value is legend-muted (§28.5) drops out of the bullet
|
|
367
|
-
// list too — so de-selecting a category quiets it whether its events are
|
|
368
|
-
// expanded (collapsed-to-dot) or folded inside a collapsed era. The card
|
|
369
|
-
// height auto-shrinks to the surviving bullets.
|
|
370
|
-
const visibleMembers = slot.members.filter(
|
|
371
|
-
(m) => !eventTagHidden(m.metadata, hiddenSet)
|
|
372
|
-
);
|
|
373
|
-
const memberStrs = visibleMembers.map(
|
|
374
|
-
(m) =>
|
|
375
|
-
`• ${m.date ? `${m.future ? 'TBD' : formatDateLabel(m.date)} ` : ''}${m.label}`
|
|
376
|
-
);
|
|
377
|
-
const bulletColors = visibleMembers.map(eventColor);
|
|
378
|
-
const bulletMeta = visibleMembers.map((m) => m.metadata);
|
|
379
|
-
const lines = wrapDescription(memberStrs, charsPerLine);
|
|
380
|
-
return {
|
|
381
|
-
kind: 'era',
|
|
382
|
-
event: null,
|
|
383
|
-
era,
|
|
384
|
-
members: slot.members,
|
|
385
|
-
label: era.name,
|
|
386
|
-
date: null,
|
|
387
|
-
dateValue: repDateValue,
|
|
388
|
-
future: false,
|
|
389
|
-
futureSpan: null,
|
|
390
|
-
lineNumber: era.lineNumber,
|
|
391
|
-
eraName: era.name,
|
|
392
|
-
color: solid,
|
|
393
|
-
cardFill,
|
|
394
|
-
titleColor,
|
|
395
|
-
lines,
|
|
396
|
-
bulletColors,
|
|
397
|
-
bulletMeta,
|
|
398
|
-
cardH: cardHeight(lines, false),
|
|
399
|
-
x: 0,
|
|
400
|
-
side,
|
|
401
|
-
lane: 0,
|
|
402
|
-
left: 0,
|
|
403
|
-
spanHalf: 0,
|
|
404
|
-
};
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
// ── X positions ──
|
|
408
|
-
// Draw to scale whenever every slot has a date — a collapsed era is anchored at
|
|
409
|
-
// its earliest member (above), so it scales like any other point. Even spacing
|
|
410
|
-
// is reserved for explicit `no-scale` or a slot that genuinely lacks a date.
|
|
411
|
-
const scaled =
|
|
412
|
-
parsed.options.scale && placed.every((p) => p.dateValue !== null);
|
|
413
|
-
const innerW = width - H_MARGIN * 2;
|
|
414
|
-
const MIN_DOT_GAP = 16; // legibility floor between coincident-ish dots
|
|
415
|
-
const STRETCH_CAP = 8; // widen the date axis at most this many ×
|
|
416
|
-
|
|
417
|
-
// BROKEN AXIS: a collapsed era is COMPRESSED to a fixed-width capsule (its
|
|
418
|
-
// squiggle says "lots of timeline folded here"), so the expanded events take
|
|
419
|
-
// over the freed width and draw to scale among themselves. The timeline is a
|
|
420
|
-
// chronological sequence of SEGMENTS — a capsule for each collapsed era, and a
|
|
421
|
-
// to-scale "run" for each maximal stretch of expanded events between them. Dead
|
|
422
|
-
// time (gaps with no expanded events) costs only a fixed `SEG_GAP`, never width.
|
|
423
|
-
const COLLAPSE_W = 64; // fixed px width of a collapsed-era capsule
|
|
424
|
-
const SEG_GAP = 16; // gap between adjacent axis segments
|
|
425
|
-
type Seg =
|
|
426
|
-
| { kind: 'capsule'; p: Placed }
|
|
427
|
-
| { kind: 'run'; events: Placed[]; lo: number; hi: number };
|
|
428
|
-
const segments: Seg[] = [];
|
|
429
|
-
let run: Placed[] = [];
|
|
430
|
-
const flushRun = (): void => {
|
|
431
|
-
if (!run.length) return;
|
|
432
|
-
const ds = run.map((e) => e.dateValue!);
|
|
433
|
-
segments.push({
|
|
434
|
-
kind: 'run',
|
|
435
|
-
events: run,
|
|
436
|
-
lo: Math.min(...ds),
|
|
437
|
-
hi: Math.max(...ds),
|
|
438
|
-
});
|
|
439
|
-
run = [];
|
|
440
|
-
};
|
|
441
|
-
for (const p of placed) {
|
|
442
|
-
if (p.kind === 'era') {
|
|
443
|
-
flushRun();
|
|
444
|
-
segments.push({ kind: 'capsule', p });
|
|
445
|
-
} else {
|
|
446
|
-
run.push(p);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
flushRun();
|
|
450
|
-
// Total to-scale time = the sum of every expanded run's own date span.
|
|
451
|
-
const totalRunTime = segments.reduce(
|
|
452
|
-
(s, seg) => s + (seg.kind === 'run' ? seg.hi - seg.lo : 0),
|
|
453
|
-
0
|
|
454
|
-
);
|
|
455
|
-
|
|
456
|
-
// Position every entry by walking the segments left-to-right. Expanded runs
|
|
457
|
-
// collectively scale across `innerW × stretch`; capsules + inter-segment gaps add
|
|
458
|
-
// a fixed amount on top. `stretch` (≥1) widens the expanded scale to fill a wide
|
|
459
|
-
// panel without distorting proportions; a small MIN_DOT_GAP floor only de-overlaps
|
|
460
|
-
// coincident-ish dots. Re-runnable for any stretch so the search below probes freely.
|
|
461
|
-
const place = (stretch: number): void => {
|
|
462
|
-
if (scaled) {
|
|
463
|
-
const pxPerUnit =
|
|
464
|
-
totalRunTime > 0 ? (innerW * stretch) / totalRunTime : 0;
|
|
465
|
-
// Each collapsed-era card centers on its capsule. When two folded eras land
|
|
466
|
-
// on the SAME side their wide cards would overlap (capsules are only
|
|
467
|
-
// COLLAPSE_W apart), forcing the card off its bracket. Float the capsule
|
|
468
|
-
// right instead — enough that its centered card clears the previous
|
|
469
|
-
// same-side era card — so the card stays squarely over its `⊓` (a straight
|
|
470
|
-
// vertical leader) and the spine spacing absorbs the slack. Opposite-side
|
|
471
|
-
// eras stay tight (their cards don't collide).
|
|
472
|
-
const eraCardRight: Record<Side, number> = {
|
|
473
|
-
above: -Infinity,
|
|
474
|
-
below: -Infinity,
|
|
475
|
-
};
|
|
476
|
-
let cursor = H_MARGIN;
|
|
477
|
-
for (const seg of segments) {
|
|
478
|
-
if (seg.kind === 'capsule') {
|
|
479
|
-
let x = cursor + COLLAPSE_W / 2;
|
|
480
|
-
const minX = Math.max(
|
|
481
|
-
CARD_W / 2 + 6, // keep the centered card on-canvas (left ≥ 6)
|
|
482
|
-
eraCardRight[seg.p.side] + FAN_GAP + CARD_W / 2 // clear prior same-side card
|
|
483
|
-
);
|
|
484
|
-
if (x < minX) {
|
|
485
|
-
cursor += minX - x;
|
|
486
|
-
x = minX;
|
|
487
|
-
}
|
|
488
|
-
seg.p.x = x;
|
|
489
|
-
seg.p.spanHalf = COLLAPSE_W / 2;
|
|
490
|
-
eraCardRight[seg.p.side] = x + CARD_W / 2;
|
|
491
|
-
cursor += COLLAPSE_W + SEG_GAP;
|
|
492
|
-
} else {
|
|
493
|
-
for (const e of seg.events) {
|
|
494
|
-
e.x = cursor + (e.dateValue! - seg.lo) * pxPerUnit;
|
|
495
|
-
e.spanHalf = 0;
|
|
496
|
-
}
|
|
497
|
-
cursor += (seg.hi - seg.lo) * pxPerUnit + SEG_GAP;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
// Within a run, distinct dates that map closer than MIN_DOT_GAP get nudged
|
|
501
|
-
// apart for legibility; identical dates keep a shared x.
|
|
502
|
-
let prevX = -Infinity;
|
|
503
|
-
let prevVal: number | null = null;
|
|
504
|
-
for (const p of [...placed].sort((a, b) => a.x - b.x)) {
|
|
505
|
-
if (p.kind === 'era') {
|
|
506
|
-
prevX = p.x + p.spanHalf;
|
|
507
|
-
prevVal = null;
|
|
508
|
-
continue;
|
|
509
|
-
}
|
|
510
|
-
if (p.dateValue === prevVal) {
|
|
511
|
-
p.x = prevX;
|
|
512
|
-
continue;
|
|
513
|
-
}
|
|
514
|
-
if (p.x < prevX + MIN_DOT_GAP) p.x = prevX + MIN_DOT_GAP;
|
|
515
|
-
prevX = p.x;
|
|
516
|
-
prevVal = p.dateValue;
|
|
517
|
-
}
|
|
518
|
-
} else {
|
|
519
|
-
placed.forEach((p) => {
|
|
520
|
-
p.spanHalf = 0;
|
|
521
|
-
});
|
|
522
|
-
const n = placed.length;
|
|
523
|
-
const spacing = n > 1 ? Math.max(MIN_SPACING, innerW / (n - 1)) : 0;
|
|
524
|
-
placed.forEach((p, i) => {
|
|
525
|
-
p.x = H_MARGIN + i * spacing;
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
// ── Spread crowded dots into horizontal slack (scaled only) ──
|
|
529
|
-
// When several events bunch up next to a tall collapsed-era card, they get
|
|
530
|
-
// shoved into deep lanes far from the spine even though the chart has empty
|
|
531
|
-
// room to the right. Nudge each crowded EVENT dot rightward — per side, since
|
|
532
|
-
// collisions are per side — just far enough to clear the previous same-side
|
|
533
|
-
// card, but never within a card-width of the NEXT obstacle. Era capsules are
|
|
534
|
-
// fixed anchors (never moved); a well-spaced event acts as a wall that caps
|
|
535
|
-
// the spread. Iterated to a fixpoint so a cluster relaxes as its members move.
|
|
536
|
-
// Scale is already non-literal here (collapsed eras break it), so trading a
|
|
537
|
-
// dot's exact date position for a shallow, readable lane is a good deal.
|
|
538
|
-
if (scaled) {
|
|
539
|
-
const SPREAD = CARD_W + FAN_GAP; // min center-to-center so cards don't overlap
|
|
540
|
-
for (let iter = 0; iter < 8; iter++) {
|
|
541
|
-
let moved = false;
|
|
542
|
-
for (const side of ['above', 'below'] as Side[]) {
|
|
543
|
-
const obs = placed
|
|
544
|
-
.filter((p) => p.side === side)
|
|
545
|
-
.sort((a, b) => a.x - b.x);
|
|
546
|
-
for (let i = 1; i < obs.length; i++) {
|
|
547
|
-
const p = obs[i]!;
|
|
548
|
-
if (p.kind !== 'event') continue; // only event dots move; eras anchor
|
|
549
|
-
const prev = obs[i - 1]!;
|
|
550
|
-
// Coincident-date events legitimately share an x and stack with faded
|
|
551
|
-
// leaders — never fan those apart.
|
|
552
|
-
if (prev.kind === 'event' && p.dateValue === prev.dateValue)
|
|
553
|
-
continue;
|
|
554
|
-
const need = prev.x + SPREAD; // clear the previous same-side card
|
|
555
|
-
if (need <= p.x) continue;
|
|
556
|
-
// Cap only against the next FIXED era anchor (capsules never move); a
|
|
557
|
-
// movable event downstream is NOT a cap — it cascades right in turn,
|
|
558
|
-
// and a far, well-spaced event naturally stays put (its `need` check
|
|
559
|
-
// no-ops) and acts as the wall. Capping by movable events would pin
|
|
560
|
-
// the cluster left and leave it stacked behind the tall era card.
|
|
561
|
-
const nxt = obs[i + 1];
|
|
562
|
-
const cap = nxt?.kind === 'era' ? nxt.x - SPREAD : Infinity;
|
|
563
|
-
const target = Math.min(need, cap);
|
|
564
|
-
if (target > p.x + 0.5) {
|
|
565
|
-
p.x = target;
|
|
566
|
-
moved = true;
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
if (!moved) break;
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
// ── Fan side-by-side, stack into lanes when too tight ──
|
|
574
|
-
// Each card keeps its dot within [left+INSET, left+CARD_W-INSET] so a vertical
|
|
575
|
-
// leader lands on it. A card joins the innermost lane where it clears the
|
|
576
|
-
// previous card in that lane by FAN_GAP; otherwise it opens a new lane. Cards
|
|
577
|
-
// are NEVER pulled back over a neighbour (no right-edge clamp) — boxes must
|
|
578
|
-
// never overlap, so the canvas grows instead (contentW below).
|
|
579
|
-
for (const side of ['above', 'below'] as Side[]) {
|
|
580
|
-
const arr = placed
|
|
581
|
-
.filter((p) => p.side === side)
|
|
582
|
-
.sort((a, b) => a.x - b.x);
|
|
583
|
-
const laneRight: number[] = [];
|
|
584
|
-
for (let i = 0; i < arr.length; i++) {
|
|
585
|
-
const p = arr[i]!;
|
|
586
|
-
const next = arr[i + 1];
|
|
587
|
-
// A collapsed-era summary card centers on its own capsule (the `⊓`
|
|
588
|
-
// mark), so consecutive eras cascade exactly with their capsule spacing
|
|
589
|
-
// and each card sits over the spot it folds — spatial order = time
|
|
590
|
-
// order. Only expanded events fan-pull left when crowded by a neighbour.
|
|
591
|
-
const crowdedRight =
|
|
592
|
-
p.kind !== 'era' && !!next && next.x - p.x < CARD_W + FAN_GAP;
|
|
593
|
-
const preferred = crowdedRight
|
|
594
|
-
? p.x - CARD_W + CARD_INSET
|
|
595
|
-
: p.x - CARD_W / 2;
|
|
596
|
-
const maxLeft = p.x - CARD_INSET;
|
|
597
|
-
const minLeft = p.x - CARD_W + CARD_INSET;
|
|
598
|
-
let lane = laneRight.length;
|
|
599
|
-
let left = Math.max(preferred, minLeft);
|
|
600
|
-
for (let l = 0; l < laneRight.length; l++) {
|
|
601
|
-
const want = Math.max(preferred, laneRight[l]! + FAN_GAP, minLeft);
|
|
602
|
-
if (want <= maxLeft) {
|
|
603
|
-
lane = l;
|
|
604
|
-
left = want;
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
p.lane = lane;
|
|
609
|
-
p.left = Math.max(6, left); // clamp the LEFT edge only — never the right
|
|
610
|
-
laneRight[lane] = p.left + CARD_W;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
|
|
615
|
-
interface Layout {
|
|
616
|
-
contentW: number;
|
|
617
|
-
contentH: number; // unclamped content height — used to target the aspect
|
|
618
|
-
totalH: number;
|
|
619
|
-
spineY: number;
|
|
620
|
-
eraBaseY: number;
|
|
621
|
-
laneNear: (p: Placed) => number;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Derive all size-dependent geometry from the current placement.
|
|
625
|
-
const derive = (): Layout => {
|
|
626
|
-
// Grow the canvas to fit the widest card edge so nothing clips or overlaps.
|
|
627
|
-
const contentW = Math.max(
|
|
628
|
-
width,
|
|
629
|
-
Math.max(...placed.map((p) => p.x + p.spanHalf)) + H_MARGIN,
|
|
630
|
-
Math.max(...placed.map((p) => p.left + CARD_W)) + 6
|
|
631
|
-
);
|
|
632
|
-
// Lanes are packed with VARIABLE heights: each lane is only as tall as its own
|
|
633
|
-
// tallest card, so a single 3-bullet card no longer pushes every lane on that
|
|
634
|
-
// side down by its height. `laneOffset[side][lane]` is the distance from the
|
|
635
|
-
// spine-side leader to that lane's near edge = Σ(prior lane heights + gap).
|
|
636
|
-
const laneOffset: Record<Side, number[]> = { above: [], below: [] };
|
|
637
|
-
for (const side of ['above', 'below'] as Side[]) {
|
|
638
|
-
const laneH: number[] = [];
|
|
639
|
-
for (const p of placed) {
|
|
640
|
-
if (p.side !== side) continue;
|
|
641
|
-
laneH[p.lane] = Math.max(laneH[p.lane] ?? 0, p.cardH);
|
|
642
|
-
}
|
|
643
|
-
const off: number[] = [];
|
|
644
|
-
let acc = 0;
|
|
645
|
-
for (let l = 0; l < laneH.length; l++) {
|
|
646
|
-
off[l] = acc;
|
|
647
|
-
acc += (laneH[l] ?? 0) + LANE_GAP;
|
|
648
|
-
}
|
|
649
|
-
laneOffset[side] = off;
|
|
650
|
-
}
|
|
651
|
-
const laneNear = (p: Placed): number =>
|
|
652
|
-
(p.side === 'above' ? LEADER_ABOVE : LEADER_BELOW) +
|
|
653
|
-
(laneOffset[p.side][p.lane] ?? 0);
|
|
654
|
-
const ext = (side: Side): number =>
|
|
655
|
-
Math.max(
|
|
656
|
-
0,
|
|
657
|
-
...placed
|
|
658
|
-
.filter((p) => p.side === side)
|
|
659
|
-
.map((p) => laneNear(p) + p.cardH)
|
|
660
|
-
);
|
|
661
|
-
// Dates ride inside the cards; the spine carries no tick ruler, so no extra
|
|
662
|
-
// band is reserved below it.
|
|
663
|
-
const contentAbove = ext('above');
|
|
664
|
-
const contentBelow = ext('below');
|
|
665
|
-
// The era `]` bracket band lives beyond the content opposite the cards.
|
|
666
|
-
const aboveExt =
|
|
667
|
-
contentAbove + (hasExpandedEra && eraSide === 'above' ? ERA_BLOCK : 0);
|
|
668
|
-
const belowExt =
|
|
669
|
-
contentBelow + (hasExpandedEra && eraSide === 'below' ? ERA_BLOCK : 0);
|
|
670
|
-
const TOP_PAD = 14;
|
|
671
|
-
const BOT_PAD = 14;
|
|
672
|
-
const spineY = topUsed + TOP_PAD + aboveExt;
|
|
673
|
-
const eraBaseY =
|
|
674
|
-
eraSide === 'above'
|
|
675
|
-
? spineY - (contentAbove + 14)
|
|
676
|
-
: spineY + (contentBelow + 14);
|
|
677
|
-
const contentH = spineY + belowExt + BOT_PAD;
|
|
678
|
-
// Live preview fills the panel (heightHint floor) so the diagram doesn't
|
|
679
|
-
// float in a short panel; export crops tight to content so the rasterized
|
|
680
|
-
// PNG/SVG has no dead whitespace below the cards (mirrors raci's renderer).
|
|
681
|
-
const totalH = exportMode ? contentH : Math.max(heightHint, contentH);
|
|
682
|
-
return { contentW, contentH, totalH, spineY, eraBaseY, laneNear };
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
// Choose the axis stretch. The preview/export both fit the whole diagram
|
|
686
|
-
// (`meet`), so when the natural layout is TALLER than the available panel the
|
|
687
|
-
// fit is height-limited and wastes horizontal space. Widen the date axis — which
|
|
688
|
-
// also shortens the layout (fewer lanes) — until the content aspect reaches the
|
|
689
|
-
// panel aspect, where the fit-scale peaks. Past that, extra width only shrinks
|
|
690
|
-
// it, so stop there. `DGMO_EVT_STRETCH` forces a fixed multiplier.
|
|
691
|
-
const envStretch = Number(
|
|
692
|
-
(globalThis as { process?: { env?: Record<string, string> } }).process
|
|
693
|
-
?.env?.['DGMO_EVT_STRETCH']
|
|
694
|
-
);
|
|
695
|
-
let layout: Layout;
|
|
696
|
-
if (Number.isFinite(envStretch) && envStretch > 0) {
|
|
697
|
-
place(envStretch);
|
|
698
|
-
layout = derive();
|
|
699
|
-
} else {
|
|
700
|
-
place(1);
|
|
701
|
-
layout = derive();
|
|
702
|
-
const panelAspect = width / heightHint;
|
|
703
|
-
const aspectOf = (l: Layout): number => l.contentW / l.contentH;
|
|
704
|
-
if (
|
|
705
|
-
scaled &&
|
|
706
|
-
layout.contentH > heightHint &&
|
|
707
|
-
aspectOf(layout) < panelAspect
|
|
708
|
-
) {
|
|
709
|
-
place(STRETCH_CAP);
|
|
710
|
-
const capped = derive();
|
|
711
|
-
if (aspectOf(capped) <= panelAspect) {
|
|
712
|
-
layout = capped; // even the max stretch can't reach the panel aspect
|
|
713
|
-
} else {
|
|
714
|
-
let loF = 1;
|
|
715
|
-
let hiF = STRETCH_CAP;
|
|
716
|
-
for (let it = 0; it < 8; it++) {
|
|
717
|
-
const mid = (loF + hiF) / 2;
|
|
718
|
-
place(mid);
|
|
719
|
-
if (aspectOf(derive()) >= panelAspect) hiF = mid;
|
|
720
|
-
else loF = mid;
|
|
721
|
-
}
|
|
722
|
-
place(hiF);
|
|
723
|
-
layout = derive();
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
const { contentW, totalH, spineY, eraBaseY, laneNear } = layout;
|
|
728
|
-
|
|
729
|
-
// ── SVG root ──
|
|
730
|
-
const svg = d3Selection
|
|
731
|
-
.select(container)
|
|
732
|
-
.append('svg')
|
|
733
|
-
.attr('width', contentW)
|
|
734
|
-
.attr('height', totalH)
|
|
735
|
-
.attr('viewBox', `0 0 ${contentW} ${totalH}`)
|
|
736
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
737
|
-
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
738
|
-
.style('font-family', FONT_FAMILY);
|
|
739
|
-
|
|
740
|
-
if (!exportMode) svg.append('style').text(HOVER_CSS);
|
|
741
|
-
|
|
742
|
-
// No opaque full-canvas background rect: the SVG's CSS `background` style
|
|
743
|
-
// (and resvg's background option on PNG export) supplies the fill, so
|
|
744
|
-
// transparent-theme / Obsidian embeds blend with the host instead of
|
|
745
|
-
// showing a dark box — matching every other chart type.
|
|
746
|
-
|
|
747
|
-
if (showTitle) {
|
|
748
|
-
const t = svg
|
|
749
|
-
.append('text')
|
|
750
|
-
.attr('class', 'chart-title')
|
|
751
|
-
.attr('x', contentW / 2)
|
|
752
|
-
.attr('y', TITLE_Y)
|
|
753
|
-
.attr('text-anchor', 'middle')
|
|
754
|
-
.attr('fill', palette.text)
|
|
755
|
-
.attr('font-family', FONT_FAMILY)
|
|
756
|
-
.attr('font-size', TITLE_FONT_SIZE)
|
|
757
|
-
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
758
|
-
.attr('data-line-number', parsed.titleLineNumber ?? 1)
|
|
759
|
-
.text(parsed.title!);
|
|
760
|
-
if (onClickItem && parsed.titleLineNumber) {
|
|
761
|
-
const ln = parsed.titleLineNumber;
|
|
762
|
-
t.style('cursor', 'pointer').on('click', () => onClickItem(ln));
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// ── Shared tag legend ──
|
|
767
|
-
if (hasLegend) {
|
|
768
|
-
const legendG = svg
|
|
769
|
-
.append('g')
|
|
770
|
-
.attr('transform', `translate(0, ${titleH})`);
|
|
771
|
-
const groups: LegendGroupData[] = parsed.tagGroups.map((g) => ({
|
|
772
|
-
name: g.name,
|
|
773
|
-
entries: g.entries.map((e) => ({
|
|
774
|
-
value: e.value,
|
|
775
|
-
color: resolveColor(e.color, palette) ?? e.color,
|
|
776
|
-
})),
|
|
777
|
-
}));
|
|
778
|
-
renderIntegratedLegend(legendG, {
|
|
779
|
-
groups,
|
|
780
|
-
palette,
|
|
781
|
-
isDark,
|
|
782
|
-
width: contentW,
|
|
783
|
-
mode: exportMode ? 'export' : 'preview',
|
|
784
|
-
activeGroup,
|
|
785
|
-
});
|
|
786
|
-
// Mute-toggling a category (§28.5) means clicking its legend entry, but the
|
|
787
|
-
// painted swatch + label are a tiny target. Back each entry with a
|
|
788
|
-
// transparent hit rect spanning its whole bounding box (+ padding) so the
|
|
789
|
-
// entire swatch-and-label region is clickable. Preview only — exports carry
|
|
790
|
-
// no interaction, so their snapshots are untouched.
|
|
791
|
-
if (!exportMode) addLegendEntryHitAreas(legendG);
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// ── Spine ──
|
|
795
|
-
// Extend to cover collapsed-era brackets, which reach p.x ± spanHalf.
|
|
796
|
-
const x0 = Math.min(...placed.map((p) => p.x - p.spanHalf));
|
|
797
|
-
const x1 = Math.max(...placed.map((p) => p.x + p.spanHalf));
|
|
798
|
-
const spineLeft = x0 - 20;
|
|
799
|
-
const spineRight = x1 + 20;
|
|
800
|
-
// A trailing TBD (open horizon) turns the spine DASHED past the last real
|
|
801
|
-
// event — the timeline literally trails off into the unscheduled future.
|
|
802
|
-
const hasTrailingFuture =
|
|
803
|
-
scaled && placed.some((p) => p.future && !p.futureSpan);
|
|
804
|
-
const realRightX = placed.length
|
|
805
|
-
? Math.max(
|
|
806
|
-
spineLeft,
|
|
807
|
-
...placed.filter((p) => !p.future).map((p) => p.x + p.spanHalf)
|
|
808
|
-
)
|
|
809
|
-
: spineRight;
|
|
810
|
-
const solidRight = hasTrailingFuture ? realRightX : spineRight;
|
|
811
|
-
svg
|
|
812
|
-
.append('line')
|
|
813
|
-
.attr('x1', spineLeft)
|
|
814
|
-
.attr('y1', spineY)
|
|
815
|
-
.attr('x2', solidRight)
|
|
816
|
-
.attr('y2', spineY)
|
|
817
|
-
.attr('stroke', palette.text)
|
|
818
|
-
.attr('stroke-width', 2.5)
|
|
819
|
-
.attr('stroke-linecap', 'round');
|
|
820
|
-
if (hasTrailingFuture) {
|
|
821
|
-
svg
|
|
822
|
-
.append('line')
|
|
823
|
-
.attr('x1', solidRight)
|
|
824
|
-
.attr('y1', spineY)
|
|
825
|
-
.attr('x2', spineRight)
|
|
826
|
-
.attr('y2', spineY)
|
|
827
|
-
.attr('stroke', palette.text)
|
|
828
|
-
.attr('stroke-width', 2.5)
|
|
829
|
-
.attr('stroke-linecap', 'round')
|
|
830
|
-
.attr('stroke-opacity', 0.45)
|
|
831
|
-
.attr('stroke-dasharray', '2 6');
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// A bracketed TBD plots at an INFERRED point inside a known gap. The hollow
|
|
835
|
-
// dot + "TBD" caption carry the uncertain read on their own — no dashed
|
|
836
|
-
// "somewhere in here" whisker is drawn across the spine.
|
|
837
|
-
|
|
838
|
-
// ── Leaders + cards ──
|
|
839
|
-
// Box geometry up front so leaders can test box crossings and so leaders draw
|
|
840
|
-
// BEHIND every card (a card always covers the leaders beneath it).
|
|
841
|
-
const geo = placed.map((p) => {
|
|
842
|
-
const near =
|
|
843
|
-
p.side === 'above' ? spineY - laneNear(p) : spineY + laneNear(p);
|
|
844
|
-
const top = p.side === 'above' ? near - p.cardH : near;
|
|
845
|
-
return { p, near, top };
|
|
846
|
-
});
|
|
847
|
-
// A leader runs vertically at its dot's x from the spine to its card. If that
|
|
848
|
-
// segment passes through ANOTHER card's box, fade it so the text stays clear.
|
|
849
|
-
const leaderCrossesBox = (owner: Placed, near: number): boolean => {
|
|
850
|
-
const lo = Math.min(spineY, near);
|
|
851
|
-
const hi = Math.max(spineY, near);
|
|
852
|
-
return geo.some(
|
|
853
|
-
(g) =>
|
|
854
|
-
g.p !== owner &&
|
|
855
|
-
owner.x > g.p.left &&
|
|
856
|
-
owner.x < g.p.left + CARD_W &&
|
|
857
|
-
hi > g.top &&
|
|
858
|
-
lo < g.top + g.p.cardH
|
|
859
|
-
);
|
|
860
|
-
};
|
|
861
|
-
|
|
862
|
-
// A collapsed era's `⊓` bar floats on the card's side; the era card's leader
|
|
863
|
-
// stops AT the bar rather than running to the spine.
|
|
864
|
-
const eraBarY = (p: Placed): number =>
|
|
865
|
-
spineY + ERA_LEG * (p.side === 'above' ? -1 : 1);
|
|
866
|
-
|
|
867
|
-
// Leaders first (behind all cards).
|
|
868
|
-
for (const { p, near } of geo) {
|
|
869
|
-
const spineEnd = p.kind === 'era' ? eraBarY(p) : spineY;
|
|
870
|
-
const leader = svg
|
|
871
|
-
.append('line')
|
|
872
|
-
.attr('class', 'dgmo-event-leader')
|
|
873
|
-
.attr('x1', p.x)
|
|
874
|
-
.attr('y1', spineEnd)
|
|
875
|
-
.attr('x2', p.x)
|
|
876
|
-
.attr('y2', near)
|
|
877
|
-
.attr('stroke', p.color)
|
|
878
|
-
.attr('stroke-width', 1.5)
|
|
879
|
-
.attr(
|
|
880
|
-
'stroke-opacity',
|
|
881
|
-
leaderCrossesBox(p, near) ? 0.18 : p.future ? 0.4 : 0.65
|
|
882
|
-
);
|
|
883
|
-
// A future (TBD) event's leader stays SOLID but faded to 40% — the tentative
|
|
884
|
-
// read comes from the fade, matching the faded card bar and dot (no dashes).
|
|
885
|
-
applyHoverHooks(leader, p);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Cards on top.
|
|
889
|
-
let shelfClipSeq = 0;
|
|
890
|
-
for (const { p, top } of geo) {
|
|
891
|
-
const left = p.left;
|
|
892
|
-
|
|
893
|
-
const cardG = svg
|
|
894
|
-
.append('g')
|
|
895
|
-
.attr('class', 'dgmo-event-card')
|
|
896
|
-
.attr('transform', `translate(${left}, ${top})`)
|
|
897
|
-
.attr('data-line-number', p.lineNumber);
|
|
898
|
-
applyHoverHooks(cardG, p);
|
|
899
|
-
if (p.kind === 'era') {
|
|
900
|
-
// App hook: a collapsed-era summary card toggles back open on click.
|
|
901
|
-
cardG.attr('data-era', p.era!.name).attr('data-era-collapsed', 'true');
|
|
902
|
-
}
|
|
903
|
-
if (onClickItem) {
|
|
904
|
-
const ln = p.lineNumber;
|
|
905
|
-
cardG.style('cursor', 'pointer').on('click', () => onClickItem(ln));
|
|
906
|
-
// A transparent hit-rect over the whole card bounds so the ENTIRE card is
|
|
907
|
-
// clickable — in no-box mode only the shelf + text glyphs are painted, so
|
|
908
|
-
// without this the empty space around a collapsed era's bullet list would
|
|
909
|
-
// not register clicks. Added first → sits beneath the visible content.
|
|
910
|
-
cardG
|
|
911
|
-
.append('rect')
|
|
912
|
-
.attr('x', 0)
|
|
913
|
-
.attr('y', 0)
|
|
914
|
-
.attr('width', CARD_W)
|
|
915
|
-
.attr('height', p.cardH)
|
|
916
|
-
.attr('fill', 'transparent')
|
|
917
|
-
.attr('pointer-events', 'all');
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// The date rides as a muted subtitle adjacent to the title, on the side AWAY
|
|
921
|
-
// from the spine (so it reads title → date going outward). Collapsed-era cards
|
|
922
|
-
// carry no date (their members list their own dates).
|
|
923
|
-
const dateStr =
|
|
924
|
-
p.kind === 'event' && p.date
|
|
925
|
-
? p.future
|
|
926
|
-
? 'TBD'
|
|
927
|
-
: formatDateLabel(p.date)
|
|
928
|
-
: null;
|
|
929
|
-
const dateH = dateStr ? DATE_SUBTITLE_H : 0;
|
|
930
|
-
|
|
931
|
-
if (parsed.options.noBox) {
|
|
932
|
-
// Card-less style: a tag-colored label + muted date sitting on a soft
|
|
933
|
-
// tag-tinted "shelf", with the leader docking into the shelf's colored
|
|
934
|
-
// spine-side edge so the dot→block link stays solid in dense charts. The
|
|
935
|
-
// title (the anchor) sits nearest the spine; for above-side blocks the
|
|
936
|
-
// header order flips to description → date → title.
|
|
937
|
-
const titleNearTop = p.side === 'below';
|
|
938
|
-
const headBandTop = titleNearTop ? 0 : p.cardH - HEADER_HEIGHT;
|
|
939
|
-
// The shelf wraps the whole header band (title + date). Its spine-side edge
|
|
940
|
-
// (top for below-side blocks, bottom otherwise) is where the leader lands.
|
|
941
|
-
const shelfTop = titleNearTop ? 0 : p.cardH - HEADER_HEIGHT - dateH;
|
|
942
|
-
const shelfH = HEADER_HEIGHT + dateH;
|
|
943
|
-
// Clip both the tint and the colored edge to ONE rounded-rect mask so the
|
|
944
|
-
// edge carries through the shelf's curved corners (the collapse-bar idiom,
|
|
945
|
-
// utils/card.ts) instead of reading as a straight bar bolted on top.
|
|
946
|
-
const clipId = `dgmo-evt-shelf-${shelfClipSeq++}`;
|
|
947
|
-
cardG
|
|
948
|
-
.append('clipPath')
|
|
949
|
-
.attr('id', clipId)
|
|
950
|
-
.append('rect')
|
|
951
|
-
.attr('x', 0)
|
|
952
|
-
.attr('y', shelfTop)
|
|
953
|
-
.attr('width', CARD_W)
|
|
954
|
-
.attr('height', shelfH)
|
|
955
|
-
.attr('rx', SHELF_EDGE);
|
|
956
|
-
cardG
|
|
957
|
-
.append('rect')
|
|
958
|
-
.attr('x', 0)
|
|
959
|
-
.attr('y', shelfTop)
|
|
960
|
-
.attr('width', CARD_W)
|
|
961
|
-
.attr('height', shelfH)
|
|
962
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
963
|
-
.attr('fill', mix(p.color, themeBaseBg(palette, isDark), SHELF_TINT));
|
|
964
|
-
const edgeY = titleNearTop ? shelfTop : shelfTop + shelfH - SHELF_EDGE;
|
|
965
|
-
// The colored leader-landing edge. A future (TBD) card draws the SAME solid
|
|
966
|
-
// edge faded to 40% — the tentative read comes from the fade, matching the
|
|
967
|
-
// faded leader + dot (no dashes).
|
|
968
|
-
cardG
|
|
969
|
-
.append('rect')
|
|
970
|
-
.attr('x', 0)
|
|
971
|
-
.attr('y', edgeY)
|
|
972
|
-
.attr('width', CARD_W)
|
|
973
|
-
.attr('height', SHELF_EDGE)
|
|
974
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
975
|
-
.attr('fill', p.color)
|
|
976
|
-
.attr('fill-opacity', p.future ? 0.4 : 1);
|
|
977
|
-
cardG
|
|
978
|
-
.append('text')
|
|
979
|
-
.attr('x', CARD_PAD)
|
|
980
|
-
.attr('y', headBandTop + HEADER_HEIGHT / 2 + LABEL_FONT_SIZE / 2 - 2)
|
|
981
|
-
.attr('fill', p.color)
|
|
982
|
-
.attr('font-family', FONT_FAMILY)
|
|
983
|
-
.attr('font-size', LABEL_FONT_SIZE)
|
|
984
|
-
.attr('font-weight', 700)
|
|
985
|
-
.text(p.label);
|
|
986
|
-
if (dateStr) {
|
|
987
|
-
cardG
|
|
988
|
-
.append('text')
|
|
989
|
-
.attr('x', CARD_PAD)
|
|
990
|
-
.attr(
|
|
991
|
-
'y',
|
|
992
|
-
(titleNearTop ? HEADER_HEIGHT : headBandTop - dateH) +
|
|
993
|
-
DATE_SUBTITLE_FONT +
|
|
994
|
-
2
|
|
995
|
-
)
|
|
996
|
-
.attr('fill', mix(palette.text, palette.bg, 55))
|
|
997
|
-
.attr('font-family', FONT_FAMILY)
|
|
998
|
-
.attr('font-size', DATE_SUBTITLE_FONT)
|
|
999
|
-
.attr('font-weight', 600)
|
|
1000
|
-
.text(dateStr);
|
|
1001
|
-
}
|
|
1002
|
-
if (p.lines.length > 0) {
|
|
1003
|
-
const startBaseline = titleNearTop
|
|
1004
|
-
? CARD_BODY_TOP + dateH + DESC_FONT
|
|
1005
|
-
: CARD_PAD + DESC_FONT;
|
|
1006
|
-
renderBody(
|
|
1007
|
-
cardG,
|
|
1008
|
-
p.lines,
|
|
1009
|
-
palette.text,
|
|
1010
|
-
palette,
|
|
1011
|
-
startBaseline,
|
|
1012
|
-
p.bulletColors,
|
|
1013
|
-
p.bulletMeta
|
|
1014
|
-
);
|
|
1015
|
-
}
|
|
1016
|
-
} else {
|
|
1017
|
-
// Org-card chrome: rect + bold centered title (reuses utils/card.ts).
|
|
1018
|
-
renderNodeCard(cardG, {
|
|
1019
|
-
width: CARD_W,
|
|
1020
|
-
height: p.cardH,
|
|
1021
|
-
rx: CARD_RADIUS,
|
|
1022
|
-
fill: p.cardFill,
|
|
1023
|
-
stroke: p.color,
|
|
1024
|
-
strokeWidth: NODE_STROKE_WIDTH,
|
|
1025
|
-
label: p.label,
|
|
1026
|
-
labelColor: p.titleColor,
|
|
1027
|
-
labelFontSize: LABEL_FONT_SIZE,
|
|
1028
|
-
headerHeight: HEADER_HEIGHT,
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
if (dateStr) {
|
|
1032
|
-
cardG
|
|
1033
|
-
.append('text')
|
|
1034
|
-
.attr('x', CARD_W / 2)
|
|
1035
|
-
.attr('y', HEADER_HEIGHT + DATE_SUBTITLE_FONT + 1)
|
|
1036
|
-
.attr('text-anchor', 'middle')
|
|
1037
|
-
.attr('fill', mix(p.titleColor, p.cardFill, 60))
|
|
1038
|
-
.attr('font-family', FONT_FAMILY)
|
|
1039
|
-
.attr('font-size', DATE_SUBTITLE_FONT)
|
|
1040
|
-
.attr('font-weight', 600)
|
|
1041
|
-
.text(dateStr);
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
if (p.lines.length > 0) {
|
|
1045
|
-
// Divider (org convention: 1px, 30% opacity) below the title + date band.
|
|
1046
|
-
cardG
|
|
1047
|
-
.append('line')
|
|
1048
|
-
.attr('x1', 0)
|
|
1049
|
-
.attr('y1', HEADER_HEIGHT + dateH)
|
|
1050
|
-
.attr('x2', CARD_W)
|
|
1051
|
-
.attr('y2', HEADER_HEIGHT + dateH)
|
|
1052
|
-
.attr('stroke', p.titleColor)
|
|
1053
|
-
.attr('stroke-opacity', 0.3)
|
|
1054
|
-
.attr('stroke-width', 1);
|
|
1055
|
-
renderBody(
|
|
1056
|
-
cardG,
|
|
1057
|
-
p.lines,
|
|
1058
|
-
p.titleColor,
|
|
1059
|
-
palette,
|
|
1060
|
-
CARD_BODY_TOP + dateH + DESC_FONT,
|
|
1061
|
-
p.bulletColors,
|
|
1062
|
-
p.bulletMeta
|
|
1063
|
-
);
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// No date ruler: dates ride inside the cards, and the spine carries no
|
|
1069
|
-
// year/period tick markers (removed by request — the timeline shows order,
|
|
1070
|
-
// not a measured date axis).
|
|
1071
|
-
|
|
1072
|
-
// ── Dots (events) + span brackets (collapsed eras) on the spine ──
|
|
1073
|
-
for (const p of placed) {
|
|
1074
|
-
if (p.kind === 'era') {
|
|
1075
|
-
// A collapsed era terminates on the spine as a `⊓` stretched across its
|
|
1076
|
-
// folded date range (`p.spanHalf`), so it keeps its true timeline footprint;
|
|
1077
|
-
// a minimum width keeps a single-date era visible. No dot, no separate bottom
|
|
1078
|
-
// bracket — the card title names it. Matches the card's color so the leader
|
|
1079
|
-
// reads straight through.
|
|
1080
|
-
const col = p.color;
|
|
1081
|
-
const half = Math.max(16, p.spanHalf);
|
|
1082
|
-
// Clamp the legs to the drawn spine extent so the feet always land ON the
|
|
1083
|
-
// line (never poking off the left/right end of the timeline).
|
|
1084
|
-
const spineLeft = x0 - 20;
|
|
1085
|
-
const spineRight = x1 + 20;
|
|
1086
|
-
const bx0 = Math.max(spineLeft, p.x - half);
|
|
1087
|
-
const bx1 = Math.min(spineRight, p.x + half);
|
|
1088
|
-
// A `⊓`: the bar floats off the spine on the card's side (where the leader
|
|
1089
|
-
// lands), and a leg drops from each end down to rest its foot ON the spine.
|
|
1090
|
-
const barY = eraBarY(p);
|
|
1091
|
-
const eg = svg
|
|
1092
|
-
.append('g')
|
|
1093
|
-
.attr('class', 'dgmo-event-era')
|
|
1094
|
-
.attr('data-era', p.era!.name)
|
|
1095
|
-
.attr('data-era-collapsed', 'true')
|
|
1096
|
-
.attr('data-line-number', p.lineNumber);
|
|
1097
|
-
if (onClickItem) {
|
|
1098
|
-
// Transparent hit-rect spanning the whole ⊓ + squiggle so the entire
|
|
1099
|
-
// bracket area is clickable, not just the thin strokes. Added first →
|
|
1100
|
-
// beneath the visible glyph.
|
|
1101
|
-
const hy0 = Math.min(barY, spineY) - 4;
|
|
1102
|
-
const hy1 = Math.max(barY, spineY) + 8;
|
|
1103
|
-
eg.append('rect')
|
|
1104
|
-
.attr('x', bx0)
|
|
1105
|
-
.attr('y', hy0)
|
|
1106
|
-
.attr('width', bx1 - bx0)
|
|
1107
|
-
.attr('height', hy1 - hy0)
|
|
1108
|
-
.attr('fill', 'transparent')
|
|
1109
|
-
.attr('pointer-events', 'all');
|
|
1110
|
-
}
|
|
1111
|
-
eg.append('path')
|
|
1112
|
-
.attr(
|
|
1113
|
-
'd',
|
|
1114
|
-
`M${bx0},${spineY} L${bx0},${barY} L${bx1},${barY} L${bx1},${spineY}`
|
|
1115
|
-
)
|
|
1116
|
-
.attr('fill', 'none')
|
|
1117
|
-
.attr('stroke', col)
|
|
1118
|
-
.attr('stroke-width', 1.5)
|
|
1119
|
-
.attr('stroke-linecap', 'round')
|
|
1120
|
-
.attr('stroke-linejoin', 'round');
|
|
1121
|
-
// Axis-break glyph bisecting the bracket: the folded span is not to scale.
|
|
1122
|
-
// Blank the spine between the two waves first so the timeline reads as
|
|
1123
|
-
// genuinely broken, then draw the squiggles over the gap.
|
|
1124
|
-
const breakCx = (bx0 + bx1) / 2;
|
|
1125
|
-
eg.append('line')
|
|
1126
|
-
.attr('x1', breakCx - BREAK_GAP / 2)
|
|
1127
|
-
.attr('y1', spineY)
|
|
1128
|
-
.attr('x2', breakCx + BREAK_GAP / 2)
|
|
1129
|
-
.attr('y2', spineY)
|
|
1130
|
-
.attr('stroke', palette.bg)
|
|
1131
|
-
.attr('stroke-width', 5)
|
|
1132
|
-
.attr('stroke-linecap', 'butt');
|
|
1133
|
-
for (const d of axisBreakPaths(breakCx, spineY)) {
|
|
1134
|
-
eg.append('path')
|
|
1135
|
-
.attr('d', d)
|
|
1136
|
-
.attr('fill', 'none')
|
|
1137
|
-
.attr('stroke', palette.text)
|
|
1138
|
-
.attr('stroke-width', 2)
|
|
1139
|
-
.attr('stroke-linecap', 'round')
|
|
1140
|
-
.attr('stroke-linejoin', 'round');
|
|
1141
|
-
}
|
|
1142
|
-
if (onClickItem) {
|
|
1143
|
-
const ln = p.lineNumber;
|
|
1144
|
-
eg.style('cursor', 'pointer').on('click', () => onClickItem(ln));
|
|
1145
|
-
}
|
|
1146
|
-
continue;
|
|
1147
|
-
}
|
|
1148
|
-
// A real event is a SOLID colored dot; a TBD is a colored RING with a
|
|
1149
|
-
// background fill (hollow) — opaque, never transparent. The tentative read
|
|
1150
|
-
// also carries through its faded leader + card bar and "TBD" caption.
|
|
1151
|
-
const dot = svg
|
|
1152
|
-
.append('circle')
|
|
1153
|
-
.attr('class', 'dgmo-event-dot')
|
|
1154
|
-
.attr('cx', p.x)
|
|
1155
|
-
.attr('cy', spineY)
|
|
1156
|
-
.attr('r', DOT_R)
|
|
1157
|
-
.attr('fill', p.future ? palette.bg : p.color)
|
|
1158
|
-
.attr('stroke', p.future ? p.color : palette.bg)
|
|
1159
|
-
.attr('stroke-width', 2)
|
|
1160
|
-
.attr('data-line-number', p.lineNumber);
|
|
1161
|
-
applyHoverHooks(dot, p);
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
// ── Era `]` brackets ─────────────────────────────────────────
|
|
1165
|
-
// A horizontal bracket on the side OPPOSITE the cards marks each era's run.
|
|
1166
|
-
// Expanded: spans the run. Collapsed: a short bracket centered on the summary,
|
|
1167
|
-
// so the era stays on the timeline even when folded. No chevrons — the bracket
|
|
1168
|
-
// (and the summary card) are the affordance; the app wires click-to-toggle.
|
|
1169
|
-
if (hasEras) {
|
|
1170
|
-
interface EraRun {
|
|
1171
|
-
era: EventLineEra;
|
|
1172
|
-
collapsed: boolean;
|
|
1173
|
-
firstX: number;
|
|
1174
|
-
lastX: number;
|
|
1175
|
-
}
|
|
1176
|
-
const runs: EraRun[] = [];
|
|
1177
|
-
let prevName: string | null = null;
|
|
1178
|
-
for (const p of placed) {
|
|
1179
|
-
const name = p.eraName;
|
|
1180
|
-
if (name && eraByName.has(name)) {
|
|
1181
|
-
if (prevName === name && runs.length > 0) {
|
|
1182
|
-
const r = runs[runs.length - 1]!;
|
|
1183
|
-
r.firstX = Math.min(r.firstX, p.x);
|
|
1184
|
-
r.lastX = Math.max(r.lastX, p.x);
|
|
1185
|
-
} else {
|
|
1186
|
-
runs.push({
|
|
1187
|
-
era: eraByName.get(name)!,
|
|
1188
|
-
collapsed: p.kind === 'era',
|
|
1189
|
-
firstX: p.x,
|
|
1190
|
-
lastX: p.x,
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
prevName = name;
|
|
1194
|
-
} else {
|
|
1195
|
-
prevName = null;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
const clampX = (v: number): number =>
|
|
1199
|
-
Math.max(4, Math.min(contentW - 4, v));
|
|
1200
|
-
const cap = eraSide === 'above' ? 1 : -1; // bracket caps point toward the spine
|
|
1201
|
-
// Each expanded era's bracket pads its run by ±ERA_BRACKET_PAD. When two
|
|
1202
|
-
// adjacent eras' boundary events sit close on the date scale those pads
|
|
1203
|
-
// collide, so split the seam at the midpoint between the runs (kept outside
|
|
1204
|
-
// each run's own dots) — neighbouring brackets then never overlap.
|
|
1205
|
-
const drawn = runs
|
|
1206
|
-
.filter((r) => !r.collapsed) // collapsed eras are marked on the spine instead
|
|
1207
|
-
.map((r) => ({
|
|
1208
|
-
run: r,
|
|
1209
|
-
x0: clampX(r.firstX - ERA_BRACKET_PAD),
|
|
1210
|
-
x1: clampX(r.lastX + ERA_BRACKET_PAD),
|
|
1211
|
-
}))
|
|
1212
|
-
.sort((a, b) => a.run.firstX - b.run.firstX);
|
|
1213
|
-
for (let i = 1; i < drawn.length; i++) {
|
|
1214
|
-
const prev = drawn[i - 1]!;
|
|
1215
|
-
const cur = drawn[i]!;
|
|
1216
|
-
if (prev.x1 <= cur.x0) continue;
|
|
1217
|
-
const mid = (prev.run.lastX + cur.run.firstX) / 2;
|
|
1218
|
-
prev.x1 = Math.max(prev.run.lastX, Math.min(prev.x1, mid - ERA_SEAM_GAP));
|
|
1219
|
-
cur.x0 = Math.min(cur.run.firstX, Math.max(cur.x0, mid + ERA_SEAM_GAP));
|
|
1220
|
-
}
|
|
1221
|
-
for (const { run: r, x0, x1 } of drawn) {
|
|
1222
|
-
const neutral = !r.era.color;
|
|
1223
|
-
const col = neutral
|
|
1224
|
-
? palette.text
|
|
1225
|
-
: (resolveColor(r.era.color!, palette) ?? palette.text);
|
|
1226
|
-
const op = neutral ? 0.5 : 0.85;
|
|
1227
|
-
const y = eraBaseY;
|
|
1228
|
-
const eg = svg
|
|
1229
|
-
.append('g')
|
|
1230
|
-
.attr('class', 'dgmo-event-era')
|
|
1231
|
-
.attr('data-era', r.era.name)
|
|
1232
|
-
.attr('data-era-collapsed', String(r.collapsed))
|
|
1233
|
-
.attr('data-line-number', r.era.lineNumber);
|
|
1234
|
-
eg.append('path')
|
|
1235
|
-
.attr(
|
|
1236
|
-
'd',
|
|
1237
|
-
`M${x0},${y + ERA_BRACKET_CAP * cap} L${x0},${y} L${x1},${y} L${x1},${y + ERA_BRACKET_CAP * cap}`
|
|
1238
|
-
)
|
|
1239
|
-
.attr('fill', 'none')
|
|
1240
|
-
.attr('stroke', col)
|
|
1241
|
-
.attr('stroke-width', 1.5)
|
|
1242
|
-
.attr('stroke-linecap', 'round')
|
|
1243
|
-
.attr('stroke-linejoin', 'round')
|
|
1244
|
-
.attr('stroke-opacity', op);
|
|
1245
|
-
eg.append('text')
|
|
1246
|
-
.attr('x', (r.firstX + r.lastX) / 2)
|
|
1247
|
-
.attr('y', eraSide === 'above' ? y - 6 : y + ERA_LABEL_FONT + 4)
|
|
1248
|
-
.attr('text-anchor', 'middle')
|
|
1249
|
-
.attr('font-family', FONT_FAMILY)
|
|
1250
|
-
.attr('font-size', ERA_LABEL_FONT)
|
|
1251
|
-
.attr('font-weight', 700)
|
|
1252
|
-
.attr('fill', col)
|
|
1253
|
-
.attr('fill-opacity', neutral ? 0.85 : 1)
|
|
1254
|
-
.text(r.era.name);
|
|
1255
|
-
if (onClickItem) {
|
|
1256
|
-
const ln = r.era.lineNumber;
|
|
1257
|
-
eg.style('cursor', 'pointer').on('click', () => onClickItem(ln));
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// ── Hover + legend-toggle wiring (preview only) ──
|
|
1263
|
-
// Delegated handlers on the SVG root, so they re-bind with every render and
|
|
1264
|
-
// never leak per-node listeners. Hover priority: legend entry → category
|
|
1265
|
-
// focus, else era bracket → era + members, else any event piece → whole-event
|
|
1266
|
-
// glow. A legend CLICK mutes that category and re-renders.
|
|
1267
|
-
if (!exportMode) {
|
|
1268
|
-
const root = svg.node();
|
|
1269
|
-
wireEventLineHover(root);
|
|
1270
|
-
if (root) {
|
|
1271
|
-
wireLegendToggle(container, root, () =>
|
|
1272
|
-
renderEventLine(
|
|
1273
|
-
container,
|
|
1274
|
-
parsed,
|
|
1275
|
-
palette,
|
|
1276
|
-
isDark,
|
|
1277
|
-
onClickItem,
|
|
1278
|
-
exportDims,
|
|
1279
|
-
tagOverride
|
|
1280
|
-
)
|
|
1281
|
-
);
|
|
1282
|
-
// Re-apply the persisted muted state to the freshly built SVG (live event
|
|
1283
|
-
// cards collapse-to-dot + legend entries struck/hollow). Era bullets are
|
|
1284
|
-
// already excluded at build time above.
|
|
1285
|
-
applyEvtHidden(container, root);
|
|
1286
|
-
}
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
/** Share the same hover hooks across every piece of one event: a common
|
|
1291
|
-
* `data-evt` id (hover any → light all), the enclosing era for era focus, and
|
|
1292
|
-
* `data-tag-<group>` values mirroring the legend's `data-legend-entry`. */
|
|
1293
|
-
function applyHoverHooks<E extends d3Selection.BaseType>(
|
|
1294
|
-
sel: d3Selection.Selection<E, unknown, null, undefined>,
|
|
1295
|
-
p: Placed
|
|
1296
|
-
): void {
|
|
1297
|
-
sel.attr('data-evt', p.lineNumber);
|
|
1298
|
-
if (p.eraName) sel.attr('data-evt-era', p.eraName);
|
|
1299
|
-
if (p.event) {
|
|
1300
|
-
for (const [k, v] of Object.entries(p.event.metadata)) {
|
|
1301
|
-
sel.attr(`data-tag-${k.toLowerCase()}`, String(v).toLowerCase());
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// Everything that can dim. The spine, the year ruler, the title, and the legend
|
|
1307
|
-
// carry none of these classes, so they always stay lit — focusing anything
|
|
1308
|
-
// fades the rest, never the timeline itself.
|
|
1309
|
-
const DIMMABLE_SEL =
|
|
1310
|
-
'.dgmo-event-dot,.dgmo-event-leader,.dgmo-event-card,.dgmo-event-era';
|
|
1311
|
-
const PIN_ATTR = 'data-evt-pin';
|
|
1312
|
-
|
|
1313
|
-
/** A focus target: a single event (by its `data-evt` id, = source line), an era
|
|
1314
|
-
* (by name), or a tag value (a legend category). `null` clears the focus. */
|
|
1315
|
-
export type EventLineFocus =
|
|
1316
|
-
| { readonly kind: 'event'; readonly id: string }
|
|
1317
|
-
| { readonly kind: 'era'; readonly name: string }
|
|
1318
|
-
| { readonly kind: 'tag'; readonly group: string; readonly value: string };
|
|
1319
|
-
|
|
1320
|
-
function clearEvtFocus(root: Element): void {
|
|
1321
|
-
root
|
|
1322
|
-
.querySelectorAll(`.${HL},.${DIM},.${ERA_HL}`)
|
|
1323
|
-
.forEach((el) => el.classList.remove(HL, DIM, ERA_HL));
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
// Dim every dimmable element that isn't part of the subject; glow/emphasize the
|
|
1327
|
-
// subject. A spec that matches nothing is a no-op (never an all-dim void).
|
|
1328
|
-
function applyEvtFocus(root: Element, spec: EventLineFocus | null): void {
|
|
1329
|
-
clearEvtFocus(root);
|
|
1330
|
-
if (!spec) return;
|
|
1331
|
-
const els = [...root.querySelectorAll(DIMMABLE_SEL)];
|
|
1332
|
-
if (spec.kind === 'event') {
|
|
1333
|
-
const keep = (el: Element): boolean =>
|
|
1334
|
-
el.getAttribute('data-evt') === spec.id;
|
|
1335
|
-
if (!els.some(keep)) return;
|
|
1336
|
-
els.forEach((el) => el.classList.add(keep(el) ? HL : DIM));
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
if (spec.kind === 'era') {
|
|
1340
|
-
const keep = (el: Element): boolean =>
|
|
1341
|
-
el.getAttribute('data-evt-era') === spec.name ||
|
|
1342
|
-
(el.classList.contains('dgmo-event-era') &&
|
|
1343
|
-
el.getAttribute('data-era') === spec.name);
|
|
1344
|
-
if (!els.some(keep)) return;
|
|
1345
|
-
els.forEach((el) => {
|
|
1346
|
-
if (!keep(el)) el.classList.add(DIM);
|
|
1347
|
-
else if (el.classList.contains('dgmo-event-era'))
|
|
1348
|
-
el.classList.add(ERA_HL);
|
|
1349
|
-
});
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
const attr = `data-tag-${spec.group}`;
|
|
1353
|
-
const matches = (el: Element): boolean =>
|
|
1354
|
-
el.getAttribute(attr) === spec.value;
|
|
1355
|
-
// Member bullets folded inside a collapsed era carry the same `data-tag-*` as
|
|
1356
|
-
// their source events, so a legend hover can surface them too.
|
|
1357
|
-
const bullets = [...root.querySelectorAll('.dgmo-evt-bullet')];
|
|
1358
|
-
// An era that holds ≥1 matching member bullet stays lit (its card, spine glyph,
|
|
1359
|
-
// and leader), with the non-matching member bullets dimmed inside it.
|
|
1360
|
-
const litEras = new Set<string>();
|
|
1361
|
-
for (const b of bullets) {
|
|
1362
|
-
if (!matches(b)) continue;
|
|
1363
|
-
const card = b.closest('.dgmo-event-card');
|
|
1364
|
-
const era =
|
|
1365
|
-
card?.getAttribute('data-era') ?? card?.getAttribute('data-evt-era');
|
|
1366
|
-
if (era) litEras.add(era);
|
|
1367
|
-
}
|
|
1368
|
-
if (!els.some(matches) && litEras.size === 0) return;
|
|
1369
|
-
const inLitEra = (el: Element): boolean => {
|
|
1370
|
-
const era = el.getAttribute('data-era') ?? el.getAttribute('data-evt-era');
|
|
1371
|
-
return !!era && litEras.has(era);
|
|
1372
|
-
};
|
|
1373
|
-
els.forEach((el) => {
|
|
1374
|
-
if (!matches(el) && !inLitEra(el)) el.classList.add(DIM);
|
|
1375
|
-
});
|
|
1376
|
-
for (const b of bullets) if (!matches(b)) b.classList.add(DIM);
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
function readPin(root: Element): EventLineFocus | null {
|
|
1380
|
-
const raw = root.getAttribute(PIN_ATTR);
|
|
1381
|
-
if (!raw) return null;
|
|
1382
|
-
try {
|
|
1383
|
-
return JSON.parse(raw) as EventLineFocus;
|
|
1384
|
-
} catch {
|
|
1385
|
-
return null;
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
/**
|
|
1390
|
-
* Clear the preview-only legend-muted tag set persisted on a container (the
|
|
1391
|
-
* collapsed-to-dot categories from §28.5). View state is per-document, so a host
|
|
1392
|
-
* resets it when the source changes (switching files / edits) — the same
|
|
1393
|
-
* contract the app uses for live era collapse toggles. No-op if nothing is set.
|
|
1394
|
-
*/
|
|
1395
|
-
export function clearEventLineMuted(container: HTMLElement): void {
|
|
1396
|
-
container.removeAttribute(HIDDEN_ATTR);
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
/**
|
|
1400
|
-
* Pin a persistent focus on a rendered event-line — e.g. driven by the editor
|
|
1401
|
-
* cursor — dimming everything except the target, exactly like hover. Hovering
|
|
1402
|
-
* temporarily overrides the pin; leaving the diagram reverts to it. Pass `null`
|
|
1403
|
-
* to clear. No-op when the container holds no event-line SVG.
|
|
1404
|
-
*/
|
|
1405
|
-
export function focusEventLine(
|
|
1406
|
-
container: HTMLElement,
|
|
1407
|
-
spec: EventLineFocus | null
|
|
1408
|
-
): void {
|
|
1409
|
-
const root = container.querySelector('svg');
|
|
1410
|
-
if (!root) return;
|
|
1411
|
-
if (spec) root.setAttribute(PIN_ATTR, JSON.stringify(spec));
|
|
1412
|
-
else root.removeAttribute(PIN_ATTR);
|
|
1413
|
-
applyEvtFocus(root, spec);
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// ── Legend-toggled card hiding (preview only) ───────────────
|
|
1417
|
-
// A muted category is recorded on the CONTAINER (not the throwaway SVG) as a
|
|
1418
|
-
// `"group:value"` list, so it survives every re-render — era collapse/expand,
|
|
1419
|
-
// resize, palette change. The hidden set is independent of focus state
|
|
1420
|
-
// (HL/DIM): collapse and focus compose because they live in different classes.
|
|
1421
|
-
function readHidden(el: Element): Set<string> {
|
|
1422
|
-
const raw = el.getAttribute(HIDDEN_ATTR);
|
|
1423
|
-
if (!raw) return new Set();
|
|
1424
|
-
try {
|
|
1425
|
-
return new Set(JSON.parse(raw) as string[]);
|
|
1426
|
-
} catch {
|
|
1427
|
-
return new Set();
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// Prepend a transparent hit rect to each legend entry so the full swatch+label
|
|
1432
|
-
// region toggles its category, not just the tiny dot/glyphs. Sized from the
|
|
1433
|
-
// entry's own bbox (+ padding) and inserted first so it sits behind the visible
|
|
1434
|
-
// marks; a delegated click on it still resolves to the entry via `closest()`.
|
|
1435
|
-
function addLegendEntryHitAreas(
|
|
1436
|
-
legendG: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
1437
|
-
): void {
|
|
1438
|
-
const PAD_X = 6;
|
|
1439
|
-
const PAD_Y = 7;
|
|
1440
|
-
legendG
|
|
1441
|
-
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
1442
|
-
.each(function () {
|
|
1443
|
-
let bb: { x: number; y: number; width: number; height: number };
|
|
1444
|
-
try {
|
|
1445
|
-
bb = this.getBBox();
|
|
1446
|
-
} catch {
|
|
1447
|
-
return; // no layout (e.g. jsdom) — the entry itself stays clickable
|
|
1448
|
-
}
|
|
1449
|
-
if (!bb.width || !bb.height) return;
|
|
1450
|
-
d3Selection
|
|
1451
|
-
.select(this)
|
|
1452
|
-
.insert('rect', ':first-child')
|
|
1453
|
-
.attr('x', bb.x - PAD_X)
|
|
1454
|
-
.attr('y', bb.y - PAD_Y)
|
|
1455
|
-
.attr('width', bb.width + PAD_X * 2)
|
|
1456
|
-
.attr('height', bb.height + PAD_Y * 2)
|
|
1457
|
-
.attr('fill', 'transparent')
|
|
1458
|
-
.attr('data-legend-hit', '');
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
// True when an event carries any legend-muted `group:value`. The keying matches
|
|
1463
|
-
// `applyHoverHooks` (metadata key + value, both lowercased) so it lines up with
|
|
1464
|
-
// the `data-tag-<group>` attributes and the legend's `data-legend-entry`.
|
|
1465
|
-
function eventTagHidden(
|
|
1466
|
-
metadata: Record<string, string>,
|
|
1467
|
-
hidden: ReadonlySet<string>
|
|
1468
|
-
): boolean {
|
|
1469
|
-
if (hidden.size === 0) return false;
|
|
1470
|
-
for (const [k, v] of Object.entries(metadata)) {
|
|
1471
|
-
if (hidden.has(`${k.toLowerCase()}:${String(v).toLowerCase()}`))
|
|
1472
|
-
return true;
|
|
1473
|
-
}
|
|
1474
|
-
return false;
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
// Reconcile the rendered SVG to the container's hidden set: collapse matching
|
|
1478
|
-
// cards/leaders (never the dot) and mark the matching legend entries muted
|
|
1479
|
-
// (struck label + hollow swatch). The collapsed-era BULLETS are handled at build
|
|
1480
|
-
// time (excluded from the summary card); this covers the live event cards and
|
|
1481
|
-
// the legend chrome. Idempotent — safe to call on every render.
|
|
1482
|
-
function applyEvtHidden(container: Element, root: Element): void {
|
|
1483
|
-
const hidden = readHidden(container);
|
|
1484
|
-
const isHidden = (el: Element): boolean => {
|
|
1485
|
-
for (const key of hidden) {
|
|
1486
|
-
const sep = key.indexOf(':');
|
|
1487
|
-
if (sep < 0) continue;
|
|
1488
|
-
const group = key.slice(0, sep);
|
|
1489
|
-
const value = key.slice(sep + 1);
|
|
1490
|
-
if (el.getAttribute(`data-tag-${group}`) === value) return true;
|
|
1491
|
-
}
|
|
1492
|
-
return false;
|
|
1493
|
-
};
|
|
1494
|
-
root
|
|
1495
|
-
.querySelectorAll('.dgmo-event-card,.dgmo-event-leader')
|
|
1496
|
-
.forEach((el) => el.classList.toggle(COLLAPSED, isHidden(el)));
|
|
1497
|
-
root.querySelectorAll('[data-legend-entry]').forEach((entry) => {
|
|
1498
|
-
const group = entry
|
|
1499
|
-
.closest('[data-legend-group]')
|
|
1500
|
-
?.getAttribute('data-legend-group');
|
|
1501
|
-
const value = entry.getAttribute('data-legend-entry');
|
|
1502
|
-
setLegendEntryOff(entry, !!group && hidden.has(`${group}:${value}`));
|
|
1503
|
-
});
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
// Hollow the legend swatch (fill → none, stroke = its own color) for the muted
|
|
1507
|
-
// state, restoring the stashed fill when un-muted.
|
|
1508
|
-
function setLegendEntryOff(entry: Element, off: boolean): void {
|
|
1509
|
-
entry.classList.toggle(OFF, off);
|
|
1510
|
-
const c = entry.querySelector('circle');
|
|
1511
|
-
if (!c) return;
|
|
1512
|
-
if (off) {
|
|
1513
|
-
if (!c.hasAttribute('data-fill0'))
|
|
1514
|
-
c.setAttribute('data-fill0', c.getAttribute('fill') ?? '');
|
|
1515
|
-
c.setAttribute('fill', 'none');
|
|
1516
|
-
c.setAttribute('stroke', c.getAttribute('data-fill0') || 'currentColor');
|
|
1517
|
-
c.setAttribute('stroke-width', '1.5');
|
|
1518
|
-
} else {
|
|
1519
|
-
const fill0 = c.getAttribute('data-fill0');
|
|
1520
|
-
if (fill0 !== null) c.setAttribute('fill', fill0);
|
|
1521
|
-
c.removeAttribute('stroke');
|
|
1522
|
-
c.removeAttribute('stroke-width');
|
|
1523
|
-
}
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
function toggleHidden(container: Element, group: string, value: string): void {
|
|
1527
|
-
const hidden = readHidden(container);
|
|
1528
|
-
const key = `${group}:${value}`;
|
|
1529
|
-
if (hidden.has(key)) hidden.delete(key);
|
|
1530
|
-
else hidden.add(key);
|
|
1531
|
-
container.setAttribute(HIDDEN_ATTR, JSON.stringify([...hidden]));
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
// A click on a legend entry mutes/un-mutes that tag value, then re-renders so
|
|
1535
|
-
// both effects flow from one build: live event cards collapse-to-dot AND any
|
|
1536
|
-
// member folded inside a collapsed era drops out of its summary bullets. The
|
|
1537
|
-
// hidden set lives on the container, so the rebuild (and any later era toggle /
|
|
1538
|
-
// resize) reads it back and stays in sync. Renderer-owned, so it behaves the
|
|
1539
|
-
// same in the app, web editor, and Obsidian.
|
|
1540
|
-
function wireLegendToggle(
|
|
1541
|
-
container: HTMLElement,
|
|
1542
|
-
root: SVGSVGElement,
|
|
1543
|
-
rerender: () => void
|
|
1544
|
-
): void {
|
|
1545
|
-
root.addEventListener('click', (e) => {
|
|
1546
|
-
const t = e.target as Element | null;
|
|
1547
|
-
if (!t || typeof t.closest !== 'function') return;
|
|
1548
|
-
const entry = t.closest('[data-legend-entry]');
|
|
1549
|
-
if (!entry) return;
|
|
1550
|
-
const value = entry.getAttribute('data-legend-entry');
|
|
1551
|
-
const group = entry
|
|
1552
|
-
.closest('[data-legend-group]')
|
|
1553
|
-
?.getAttribute('data-legend-group');
|
|
1554
|
-
if (!group || !value) return;
|
|
1555
|
-
toggleHidden(container, group, value);
|
|
1556
|
-
// Preserve the editor-cursor focus pin across the rebuild — the host
|
|
1557
|
-
// re-applies it only on its own renders, not on this renderer-owned one.
|
|
1558
|
-
const pin = readPin(root);
|
|
1559
|
-
rerender();
|
|
1560
|
-
if (pin) focusEventLine(container, pin);
|
|
1561
|
-
// This rebuild replaced the SVG, so any post-render normalization the host
|
|
1562
|
-
// applied to the previous root (e.g. fit-to-canvas sizing) is gone. Signal
|
|
1563
|
-
// the host to re-apply it. Hosts that render the renderer's intrinsic size
|
|
1564
|
-
// verbatim can ignore the event.
|
|
1565
|
-
if (typeof CustomEvent !== 'undefined')
|
|
1566
|
-
container.dispatchEvent(new CustomEvent('dgmo-event-line-rerender'));
|
|
1567
|
-
});
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
// One delegated handler: hover focuses transiently; on mouseout (or over bare
|
|
1571
|
-
// canvas) it reverts to the pinned cursor focus rather than clearing outright.
|
|
1572
|
-
function wireEventLineHover(root: SVGSVGElement | null): void {
|
|
1573
|
-
if (!root) return;
|
|
1574
|
-
root.addEventListener('mouseover', (e) => {
|
|
1575
|
-
const t = e.target as Element | null;
|
|
1576
|
-
if (!t || typeof t.closest !== 'function') return;
|
|
1577
|
-
|
|
1578
|
-
const entry = t.closest('[data-legend-entry]');
|
|
1579
|
-
if (entry) {
|
|
1580
|
-
const value = entry.getAttribute('data-legend-entry');
|
|
1581
|
-
const group = entry
|
|
1582
|
-
.closest('[data-legend-group]')
|
|
1583
|
-
?.getAttribute('data-legend-group');
|
|
1584
|
-
applyEvtFocus(
|
|
1585
|
-
root,
|
|
1586
|
-
group && value ? { kind: 'tag', group, value } : readPin(root)
|
|
1587
|
-
);
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
const era = t.closest('.dgmo-event-era');
|
|
1591
|
-
if (era) {
|
|
1592
|
-
applyEvtFocus(root, {
|
|
1593
|
-
kind: 'era',
|
|
1594
|
-
name: era.getAttribute('data-era') ?? '',
|
|
1595
|
-
});
|
|
1596
|
-
return;
|
|
1597
|
-
}
|
|
1598
|
-
const evt = t.closest('[data-evt]');
|
|
1599
|
-
if (evt) {
|
|
1600
|
-
// A collapsed-era summary card carries `data-era` as well as `data-evt`;
|
|
1601
|
-
// focus it as an ERA so its `⊓` bracket + axis-break squiggle (a
|
|
1602
|
-
// `.dgmo-event-era` group, no `data-evt`) stay lit with the card instead
|
|
1603
|
-
// of fading. Plain events focus by their `data-evt` id.
|
|
1604
|
-
const eraName = evt.getAttribute('data-era');
|
|
1605
|
-
applyEvtFocus(
|
|
1606
|
-
root,
|
|
1607
|
-
eraName
|
|
1608
|
-
? { kind: 'era', name: eraName }
|
|
1609
|
-
: { kind: 'event', id: evt.getAttribute('data-evt') ?? '' }
|
|
1610
|
-
);
|
|
1611
|
-
return;
|
|
1612
|
-
}
|
|
1613
|
-
// Bare canvas / title / legend chrome → revert to the pinned focus.
|
|
1614
|
-
applyEvtFocus(root, readPin(root));
|
|
1615
|
-
});
|
|
1616
|
-
root.addEventListener('mouseout', (e) => {
|
|
1617
|
-
const to = (e as MouseEvent).relatedTarget as Node | null;
|
|
1618
|
-
if (!to || !root.contains(to)) applyEvtFocus(root, readPin(root));
|
|
1619
|
-
});
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
export function renderEventLineForExport(
|
|
1623
|
-
container: HTMLDivElement,
|
|
1624
|
-
parsed: ParsedEventLine,
|
|
1625
|
-
palette: PaletteColors,
|
|
1626
|
-
isDark: boolean,
|
|
1627
|
-
exportDims?: D3ExportDimensions,
|
|
1628
|
-
tagOverride?: string
|
|
1629
|
-
): void {
|
|
1630
|
-
renderEventLine(
|
|
1631
|
-
container,
|
|
1632
|
-
parsed,
|
|
1633
|
-
palette,
|
|
1634
|
-
isDark,
|
|
1635
|
-
undefined,
|
|
1636
|
-
exportDims,
|
|
1637
|
-
tagOverride
|
|
1638
|
-
);
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
// ── helpers ──
|
|
1642
|
-
|
|
1643
|
-
// A vertical "≈ rotated 90°" axis-break glyph: two parallel wavy strokes that
|
|
1644
|
-
// cross the timeline at a collapsed era to signal the span there is folded and
|
|
1645
|
-
// NOT drawn to scale. Returns the two path `d` strings, centered on (cx, cy).
|
|
1646
|
-
function axisBreakPaths(cx: number, cy: number): [string, string] {
|
|
1647
|
-
const HUMPS = 2;
|
|
1648
|
-
const STEPS = 48;
|
|
1649
|
-
const wave = (x0: number): string => {
|
|
1650
|
-
let d = '';
|
|
1651
|
-
for (let i = 0; i <= STEPS; i++) {
|
|
1652
|
-
const t = i / STEPS;
|
|
1653
|
-
const y = cy - BREAK_HALF_H + t * 2 * BREAK_HALF_H;
|
|
1654
|
-
const x = x0 + BREAK_AMP * Math.sin(t * Math.PI * HUMPS);
|
|
1655
|
-
d += `${i === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
|
|
1656
|
-
if (i < STEPS) d += ' ';
|
|
1657
|
-
}
|
|
1658
|
-
return d;
|
|
1659
|
-
};
|
|
1660
|
-
return [wave(cx - BREAK_GAP / 2), wave(cx + BREAK_GAP / 2)];
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
function wrapDescription(
|
|
1664
|
-
lines: readonly string[],
|
|
1665
|
-
charsPerLine: number
|
|
1666
|
-
): WrappedDescLine[] {
|
|
1667
|
-
const out: WrappedDescLine[] = [];
|
|
1668
|
-
for (const line of lines) {
|
|
1669
|
-
if (line === '') {
|
|
1670
|
-
out.push({ text: '', kind: 'plain' });
|
|
1671
|
-
continue;
|
|
1672
|
-
}
|
|
1673
|
-
out.push(...wrapDescriptionLines([line], charsPerLine));
|
|
1674
|
-
}
|
|
1675
|
-
return out;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
function renderBody(
|
|
1679
|
-
cardG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1680
|
-
lines: WrappedDescLine[],
|
|
1681
|
-
bodyColor: string,
|
|
1682
|
-
palette: PaletteColors,
|
|
1683
|
-
startBaseline = CARD_BODY_TOP + DESC_FONT,
|
|
1684
|
-
// Per-bullet color (collapsed-era member list — each bullet takes its event's
|
|
1685
|
-
// tag color). Consumed in bullet-first order; falls back to bodyColor.
|
|
1686
|
-
bulletColors: readonly string[] = [],
|
|
1687
|
-
// Per-bullet tag metadata, same order as bulletColors. Stamped on each bullet
|
|
1688
|
-
// line as `data-tag-<group>` (+ the `dgmo-evt-bullet` class) so a legend hover
|
|
1689
|
-
// can highlight matching members inside a folded era.
|
|
1690
|
-
bulletMeta: readonly Readonly<Record<string, string>>[] = []
|
|
1691
|
-
): void {
|
|
1692
|
-
let y = startBaseline;
|
|
1693
|
-
let bulletIdx = 0;
|
|
1694
|
-
// The whole bulleted entry (marker + text) takes its member's tag color so a
|
|
1695
|
-
// collapsed era reads like a mini tag-colored list; continuation lines keep
|
|
1696
|
-
// it, plain lines (e.g. "+N more") fall back to the body color.
|
|
1697
|
-
let lineColor = bodyColor;
|
|
1698
|
-
let lineMeta: Readonly<Record<string, string>> | null = null;
|
|
1699
|
-
const tagBullet = (
|
|
1700
|
-
el: d3Selection.Selection<SVGTextElement, unknown, null, undefined>
|
|
1701
|
-
): void => {
|
|
1702
|
-
if (!lineMeta) return;
|
|
1703
|
-
el.attr('class', 'dgmo-evt-bullet');
|
|
1704
|
-
for (const [k, v] of Object.entries(lineMeta))
|
|
1705
|
-
el.attr(`data-tag-${k.toLowerCase()}`, String(v).toLowerCase());
|
|
1706
|
-
};
|
|
1707
|
-
for (const line of lines) {
|
|
1708
|
-
const isBullet =
|
|
1709
|
-
line.kind === 'bullet-first' || line.kind === 'bullet-cont';
|
|
1710
|
-
const bodyX = CARD_PAD + (isBullet ? 12 : 0);
|
|
1711
|
-
if (line.kind === 'bullet-first') {
|
|
1712
|
-
lineColor = bulletColors[bulletIdx] ?? bodyColor;
|
|
1713
|
-
lineMeta = bulletMeta[bulletIdx] ?? null;
|
|
1714
|
-
bulletIdx++;
|
|
1715
|
-
const marker = cardG
|
|
1716
|
-
.append('text')
|
|
1717
|
-
.attr('x', CARD_PAD)
|
|
1718
|
-
.attr('y', y)
|
|
1719
|
-
.attr('text-anchor', 'start')
|
|
1720
|
-
.attr('fill', lineColor)
|
|
1721
|
-
.attr('font-family', FONT_FAMILY)
|
|
1722
|
-
.attr('font-size', DESC_FONT)
|
|
1723
|
-
.attr('font-weight', 700)
|
|
1724
|
-
.text('•');
|
|
1725
|
-
tagBullet(marker);
|
|
1726
|
-
} else if (!isBullet) {
|
|
1727
|
-
lineColor = bodyColor;
|
|
1728
|
-
lineMeta = null;
|
|
1729
|
-
}
|
|
1730
|
-
const t = cardG
|
|
1731
|
-
.append('text')
|
|
1732
|
-
.attr('x', bodyX)
|
|
1733
|
-
.attr('y', y)
|
|
1734
|
-
.attr('text-anchor', 'start')
|
|
1735
|
-
.attr('fill', isBullet ? lineColor : bodyColor)
|
|
1736
|
-
.attr('font-family', FONT_FAMILY)
|
|
1737
|
-
.attr('font-size', DESC_FONT);
|
|
1738
|
-
if (isBullet) tagBullet(t);
|
|
1739
|
-
renderInlineText(t, line.text, palette);
|
|
1740
|
-
y += DESC_LINE_H;
|
|
1741
|
-
}
|
|
1742
|
-
}
|