@diagrammo/dgmo 0.47.0 → 0.48.1

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