@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.
Files changed (319) hide show
  1. package/dist/auto.js +1 -1
  2. package/dist/auto.mjs +1 -1
  3. package/dist/element.js +1 -1
  4. package/dist/element.mjs +1 -1
  5. package/package.json +19 -70
  6. package/dist/advanced.cjs +0 -71648
  7. package/dist/advanced.d.cts +0 -5719
  8. package/dist/auto.cjs +0 -70648
  9. package/dist/auto.d.cts +0 -54
  10. package/dist/block.cjs +0 -70208
  11. package/dist/block.d.cts +0 -115
  12. package/dist/editor.cjs +0 -526
  13. package/dist/editor.d.cts +0 -26
  14. package/dist/element.cjs +0 -70530
  15. package/dist/element.d.cts +0 -62
  16. package/dist/highlight.cjs +0 -852
  17. package/dist/highlight.d.cts +0 -49
  18. package/dist/index.cjs +0 -70491
  19. package/dist/index.d.cts +0 -160
  20. package/dist/pert.cjs +0 -325
  21. package/dist/pert.d.cts +0 -559
  22. package/dist/themes-1-CuKpeH.d.cts +0 -908
  23. package/src/advanced.ts +0 -873
  24. package/src/arc/parser.ts +0 -15
  25. package/src/arc/renderer.ts +0 -633
  26. package/src/auto/index.ts +0 -621
  27. package/src/auto/shared.ts +0 -370
  28. package/src/auto/styles.ts +0 -73
  29. package/src/block/layout.ts +0 -198
  30. package/src/block/parser.ts +0 -427
  31. package/src/block/renderer.ts +0 -484
  32. package/src/block/types.ts +0 -67
  33. package/src/boxes-and-lines/collapse.ts +0 -96
  34. package/src/boxes-and-lines/focus.ts +0 -257
  35. package/src/boxes-and-lines/label-placement.ts +0 -339
  36. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  37. package/src/boxes-and-lines/layout-layered.ts +0 -722
  38. package/src/boxes-and-lines/layout-search.ts +0 -1678
  39. package/src/boxes-and-lines/layout.ts +0 -398
  40. package/src/boxes-and-lines/parser.ts +0 -1181
  41. package/src/boxes-and-lines/renderer.ts +0 -1465
  42. package/src/boxes-and-lines/types.ts +0 -66
  43. package/src/c4/layout.ts +0 -2441
  44. package/src/c4/parser.ts +0 -1158
  45. package/src/c4/renderer.ts +0 -2008
  46. package/src/c4/types.ts +0 -89
  47. package/src/chart-type-registry.ts +0 -529
  48. package/src/chart-types.ts +0 -225
  49. package/src/chart.ts +0 -685
  50. package/src/charts-d3/bar.ts +0 -338
  51. package/src/charts-d3/chord.ts +0 -140
  52. package/src/charts-d3/function.ts +0 -147
  53. package/src/charts-d3/funnel.ts +0 -104
  54. package/src/charts-d3/heatmap.ts +0 -145
  55. package/src/charts-d3/index.ts +0 -337
  56. package/src/charts-d3/interactions.ts +0 -477
  57. package/src/charts-d3/line.ts +0 -344
  58. package/src/charts-d3/mount.ts +0 -151
  59. package/src/charts-d3/pie.ts +0 -117
  60. package/src/charts-d3/polar.ts +0 -89
  61. package/src/charts-d3/radar.ts +0 -0
  62. package/src/charts-d3/sankey.ts +0 -0
  63. package/src/charts-d3/scatter.ts +0 -185
  64. package/src/charts-d3/shared.ts +0 -208
  65. package/src/class/layout.ts +0 -326
  66. package/src/class/parser.ts +0 -635
  67. package/src/class/renderer.ts +0 -793
  68. package/src/class/types.ts +0 -58
  69. package/src/cli-banner.ts +0 -106
  70. package/src/cli.ts +0 -1080
  71. package/src/colors-diagnostics.ts +0 -35
  72. package/src/colors.ts +0 -379
  73. package/src/completion-types.ts +0 -27
  74. package/src/completion.ts +0 -2668
  75. package/src/cycle/layout.ts +0 -1026
  76. package/src/cycle/parser.ts +0 -387
  77. package/src/cycle/renderer.ts +0 -653
  78. package/src/cycle/types.ts +0 -101
  79. package/src/d3.ts +0 -1434
  80. package/src/data-chart-parser.ts +0 -1007
  81. package/src/dgmo-router.ts +0 -296
  82. package/src/diagnostics-registry.ts +0 -108
  83. package/src/diagnostics.ts +0 -0
  84. package/src/dimensions.ts +0 -20
  85. package/src/directives-registry.ts +0 -396
  86. package/src/editor/dgmo.grammar +0 -76
  87. package/src/editor/dgmo.grammar.d.ts +0 -2
  88. package/src/editor/dgmo.grammar.js +0 -18
  89. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  90. package/src/editor/dgmo.grammar.terms.js +0 -35
  91. package/src/editor/highlight-api.ts +0 -596
  92. package/src/editor/highlight.ts +0 -36
  93. package/src/editor/index.ts +0 -27
  94. package/src/editor/keywords.ts +0 -128
  95. package/src/editor/tokens.ts +0 -30
  96. package/src/element/index.ts +0 -432
  97. package/src/embed/css.ts +0 -312
  98. package/src/embed/escape.ts +0 -15
  99. package/src/embed/index.ts +0 -280
  100. package/src/er/classify.ts +0 -228
  101. package/src/er/layout.ts +0 -530
  102. package/src/er/parser.ts +0 -750
  103. package/src/er/renderer.ts +0 -725
  104. package/src/er/types.ts +0 -52
  105. package/src/error-card.ts +0 -269
  106. package/src/event-line/diagnostics.ts +0 -117
  107. package/src/event-line/parser.ts +0 -643
  108. package/src/event-line/renderer.ts +0 -1742
  109. package/src/event-line/types.ts +0 -74
  110. package/src/fonts.ts +0 -3
  111. package/src/gantt/calculator.ts +0 -979
  112. package/src/gantt/parser.ts +0 -1641
  113. package/src/gantt/renderer.ts +0 -3787
  114. package/src/gantt/resolver.ts +0 -172
  115. package/src/gantt/types.ts +0 -206
  116. package/src/graph/diagnostics.ts +0 -64
  117. package/src/graph/edge-spline.ts +0 -29
  118. package/src/graph/flowchart-parser.ts +0 -759
  119. package/src/graph/flowchart-renderer.ts +0 -817
  120. package/src/graph/layout.ts +0 -472
  121. package/src/graph/notes.ts +0 -21
  122. package/src/graph/state-collapse.ts +0 -78
  123. package/src/graph/state-parser.ts +0 -574
  124. package/src/graph/state-renderer.ts +0 -653
  125. package/src/graph/types.ts +0 -65
  126. package/src/index.ts +0 -261
  127. package/src/infra/compute.ts +0 -1385
  128. package/src/infra/layout.ts +0 -888
  129. package/src/infra/parser.ts +0 -1234
  130. package/src/infra/renderer.ts +0 -2534
  131. package/src/infra/roles.ts +0 -75
  132. package/src/infra/types.ts +0 -216
  133. package/src/infra/validation.ts +0 -201
  134. package/src/journey-map/layout.ts +0 -490
  135. package/src/journey-map/parser.ts +0 -557
  136. package/src/journey-map/renderer.ts +0 -1580
  137. package/src/journey-map/types.ts +0 -47
  138. package/src/kanban/mutations.ts +0 -175
  139. package/src/kanban/parser.ts +0 -509
  140. package/src/kanban/renderer.ts +0 -1449
  141. package/src/kanban/types.ts +0 -39
  142. package/src/label-layout.ts +0 -368
  143. package/src/map/colorize.ts +0 -54
  144. package/src/map/completion.ts +0 -316
  145. package/src/map/context-labels.ts +0 -609
  146. package/src/map/data/PROVENANCE.json +0 -1
  147. package/src/map/data/README.md +0 -40
  148. package/src/map/data/airport-collisions.json +0 -1
  149. package/src/map/data/airports.json +0 -1
  150. package/src/map/data/gazetteer.json +0 -1
  151. package/src/map/data/lakes.json +0 -1
  152. package/src/map/data/mountain-ranges.json +0 -1
  153. package/src/map/data/na-lakes.json +0 -1
  154. package/src/map/data/na-land.json +0 -1
  155. package/src/map/data/region-names.json +0 -1
  156. package/src/map/data/rivers.json +0 -1
  157. package/src/map/data/types.ts +0 -121
  158. package/src/map/data/us-states.json +0 -1
  159. package/src/map/data/water-bodies.json +0 -1
  160. package/src/map/data/world-coarse.json +0 -1
  161. package/src/map/data/world-detail.json +0 -1
  162. package/src/map/diagnostics.ts +0 -134
  163. package/src/map/dimensions.ts +0 -150
  164. package/src/map/geo-query.ts +0 -295
  165. package/src/map/geo.ts +0 -438
  166. package/src/map/invert.ts +0 -111
  167. package/src/map/layout.ts +0 -5069
  168. package/src/map/legend-band.ts +0 -127
  169. package/src/map/load-data.ts +0 -186
  170. package/src/map/parser.ts +0 -783
  171. package/src/map/renderer.ts +0 -1445
  172. package/src/map/resolved-types.ts +0 -160
  173. package/src/map/resolver.ts +0 -1146
  174. package/src/map/types.ts +0 -194
  175. package/src/mindmap/collapse.ts +0 -67
  176. package/src/mindmap/layout.ts +0 -649
  177. package/src/mindmap/parser.ts +0 -430
  178. package/src/mindmap/renderer.ts +0 -637
  179. package/src/mindmap/text-wrap.ts +0 -218
  180. package/src/mindmap/types.ts +0 -56
  181. package/src/org/collapse.ts +0 -151
  182. package/src/org/layout.ts +0 -1262
  183. package/src/org/parser.ts +0 -519
  184. package/src/org/renderer.ts +0 -866
  185. package/src/org/resolver.ts +0 -636
  186. package/src/palettes/atlas.ts +0 -77
  187. package/src/palettes/blueprint.ts +0 -73
  188. package/src/palettes/catppuccin.ts +0 -84
  189. package/src/palettes/color-utils.ts +0 -443
  190. package/src/palettes/index.ts +0 -67
  191. package/src/palettes/nord.ts +0 -67
  192. package/src/palettes/registry.ts +0 -114
  193. package/src/palettes/slate.ts +0 -73
  194. package/src/palettes/tidewater.ts +0 -73
  195. package/src/palettes/tokyo-night.ts +0 -86
  196. package/src/palettes/types.ts +0 -88
  197. package/src/pert/analyzer.ts +0 -1183
  198. package/src/pert/diagnostics.ts +0 -110
  199. package/src/pert/index.ts +0 -43
  200. package/src/pert/internal.ts +0 -252
  201. package/src/pert/layout.ts +0 -971
  202. package/src/pert/monte-carlo.ts +0 -446
  203. package/src/pert/parser.ts +0 -1606
  204. package/src/pert/renderer.ts +0 -4009
  205. package/src/pert/share-normalize.ts +0 -62
  206. package/src/pert/types.ts +0 -604
  207. package/src/pyramid/parser.ts +0 -185
  208. package/src/pyramid/renderer.ts +0 -768
  209. package/src/pyramid/types.ts +0 -28
  210. package/src/quadrant/parser.ts +0 -18
  211. package/src/quadrant/renderer.ts +0 -638
  212. package/src/raci/diagnostics.ts +0 -213
  213. package/src/raci/index.ts +0 -23
  214. package/src/raci/mutations.ts +0 -321
  215. package/src/raci/parser.ts +0 -699
  216. package/src/raci/renderer.ts +0 -1627
  217. package/src/raci/types.ts +0 -82
  218. package/src/raci/variants.ts +0 -320
  219. package/src/render.ts +0 -268
  220. package/src/ring/parser.ts +0 -212
  221. package/src/ring/renderer.ts +0 -490
  222. package/src/ring/types.ts +0 -27
  223. package/src/sequence/collapse.ts +0 -174
  224. package/src/sequence/parser.ts +0 -1596
  225. package/src/sequence/participant-inference.ts +0 -152
  226. package/src/sequence/renderer.ts +0 -3168
  227. package/src/sequence/tag-resolution.ts +0 -164
  228. package/src/sharing.ts +0 -211
  229. package/src/sitemap/collapse.ts +0 -100
  230. package/src/sitemap/layout.ts +0 -925
  231. package/src/sitemap/parser.ts +0 -755
  232. package/src/sitemap/renderer.ts +0 -828
  233. package/src/sitemap/types.ts +0 -42
  234. package/src/slope/parser.ts +0 -18
  235. package/src/slope/renderer.ts +0 -378
  236. package/src/swimlane/diagnostics.ts +0 -94
  237. package/src/swimlane/layout.ts +0 -597
  238. package/src/swimlane/parser.ts +0 -613
  239. package/src/swimlane/renderer.ts +0 -681
  240. package/src/swimlane/types.ts +0 -151
  241. package/src/tech-radar/interactive.ts +0 -1129
  242. package/src/tech-radar/layout.ts +0 -197
  243. package/src/tech-radar/parser.ts +0 -436
  244. package/src/tech-radar/renderer.ts +0 -1239
  245. package/src/tech-radar/shared.ts +0 -151
  246. package/src/tech-radar/types.ts +0 -86
  247. package/src/themes.ts +0 -22
  248. package/src/timeline/parser.ts +0 -472
  249. package/src/timeline/renderer.ts +0 -3024
  250. package/src/timeline/types.ts +0 -33
  251. package/src/timeline/viz-parser.ts +0 -21
  252. package/src/treemap/diagnostics.ts +0 -62
  253. package/src/treemap/layout-radial.ts +0 -233
  254. package/src/treemap/layout.ts +0 -189
  255. package/src/treemap/parser.ts +0 -463
  256. package/src/treemap/renderer-radial.ts +0 -438
  257. package/src/treemap/renderer.ts +0 -572
  258. package/src/treemap/treemap-shared.ts +0 -309
  259. package/src/treemap/types.ts +0 -60
  260. package/src/utils/arrow-markers.ts +0 -51
  261. package/src/utils/arrows-diagnostics.ts +0 -62
  262. package/src/utils/arrows.ts +0 -227
  263. package/src/utils/brand.ts +0 -61
  264. package/src/utils/card.ts +0 -183
  265. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  266. package/src/utils/collapse-engine/tree.ts +0 -117
  267. package/src/utils/d3-helpers.ts +0 -219
  268. package/src/utils/d3-types.ts +0 -9
  269. package/src/utils/description-helpers.ts +0 -38
  270. package/src/utils/duration.ts +0 -312
  271. package/src/utils/export-container.ts +0 -41
  272. package/src/utils/expr-eval.ts +0 -27
  273. package/src/utils/fit-canvas.ts +0 -64
  274. package/src/utils/hover-styles.ts +0 -673
  275. package/src/utils/inline-markdown.ts +0 -96
  276. package/src/utils/legend-constants.ts +0 -48
  277. package/src/utils/legend-d3.ts +0 -618
  278. package/src/utils/legend-integration.ts +0 -107
  279. package/src/utils/legend-layout.ts +0 -776
  280. package/src/utils/legend-svg.ts +0 -214
  281. package/src/utils/legend-types.ts +0 -272
  282. package/src/utils/name-normalize.ts +0 -163
  283. package/src/utils/note-box/constants.ts +0 -25
  284. package/src/utils/note-box/index.ts +0 -11
  285. package/src/utils/note-box/metrics.ts +0 -90
  286. package/src/utils/note-box/svg.ts +0 -331
  287. package/src/utils/notes/bounds.ts +0 -30
  288. package/src/utils/notes/build.ts +0 -131
  289. package/src/utils/notes/index.ts +0 -18
  290. package/src/utils/notes/model.ts +0 -19
  291. package/src/utils/notes/parse.ts +0 -131
  292. package/src/utils/notes/place.ts +0 -177
  293. package/src/utils/notes/resolve.ts +0 -88
  294. package/src/utils/number-format.ts +0 -36
  295. package/src/utils/parsing.ts +0 -953
  296. package/src/utils/reserved-key-registry.ts +0 -239
  297. package/src/utils/safe-href.ts +0 -38
  298. package/src/utils/scaling.ts +0 -97
  299. package/src/utils/svg-embed.ts +0 -251
  300. package/src/utils/tag-groups.ts +0 -607
  301. package/src/utils/text-measure.ts +0 -122
  302. package/src/utils/time-ticks.ts +0 -253
  303. package/src/utils/title-constants.ts +0 -24
  304. package/src/utils/visual-conventions.ts +0 -61
  305. package/src/utils/wrapped-desc.ts +0 -87
  306. package/src/venn/parser.ts +0 -18
  307. package/src/venn/renderer.ts +0 -1047
  308. package/src/version-control/diagnostics.ts +0 -67
  309. package/src/version-control/parser.ts +0 -544
  310. package/src/version-control/renderer.ts +0 -724
  311. package/src/version-control/types.ts +0 -100
  312. package/src/visualizations/parse.ts +0 -1560
  313. package/src/visualizations/types.ts +0 -262
  314. package/src/wireframe/layout.ts +0 -479
  315. package/src/wireframe/parser.ts +0 -1114
  316. package/src/wireframe/renderer.ts +0 -1318
  317. package/src/wireframe/types.ts +0 -110
  318. package/src/wordcloud/parser.ts +0 -18
  319. 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
- }