@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.
- package/dist/advanced.js +11 -11
- package/dist/auto.js +16 -16
- package/dist/auto.mjs +41 -12
- package/dist/block.js +4 -4
- package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
- package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
- package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
- package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
- package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
- package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
- package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
- package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
- package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
- package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
- package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
- package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
- package/dist/cli.cjs +15 -15
- package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
- package/dist/element.js +16 -16
- package/dist/element.mjs +41 -12
- package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -4
- package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
- package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
- package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
- package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
- package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
- package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
- package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
- package/package.json +23 -71
- package/dist/advanced.cjs +0 -71648
- package/dist/advanced.d.cts +0 -5719
- package/dist/auto.cjs +0 -70648
- package/dist/auto.d.cts +0 -54
- package/dist/block.cjs +0 -70208
- package/dist/block.d.cts +0 -115
- package/dist/editor.cjs +0 -526
- package/dist/editor.d.cts +0 -26
- package/dist/element.cjs +0 -70530
- package/dist/element.d.cts +0 -62
- package/dist/highlight.cjs +0 -852
- package/dist/highlight.d.cts +0 -49
- package/dist/index.cjs +0 -70491
- package/dist/index.d.cts +0 -160
- package/dist/pert.cjs +0 -325
- package/dist/pert.d.cts +0 -559
- package/dist/themes-1-CuKpeH.d.cts +0 -908
- package/src/advanced.ts +0 -873
- package/src/arc/parser.ts +0 -15
- package/src/arc/renderer.ts +0 -633
- package/src/auto/index.ts +0 -621
- package/src/auto/shared.ts +0 -370
- package/src/auto/styles.ts +0 -73
- package/src/block/layout.ts +0 -198
- package/src/block/parser.ts +0 -427
- package/src/block/renderer.ts +0 -484
- package/src/block/types.ts +0 -67
- package/src/boxes-and-lines/collapse.ts +0 -96
- package/src/boxes-and-lines/focus.ts +0 -257
- package/src/boxes-and-lines/label-placement.ts +0 -339
- package/src/boxes-and-lines/layout-grouped.ts +0 -912
- package/src/boxes-and-lines/layout-layered.ts +0 -722
- package/src/boxes-and-lines/layout-search.ts +0 -1678
- package/src/boxes-and-lines/layout.ts +0 -398
- package/src/boxes-and-lines/parser.ts +0 -1181
- package/src/boxes-and-lines/renderer.ts +0 -1465
- package/src/boxes-and-lines/types.ts +0 -66
- package/src/c4/layout.ts +0 -2441
- package/src/c4/parser.ts +0 -1158
- package/src/c4/renderer.ts +0 -2008
- package/src/c4/types.ts +0 -89
- package/src/chart-type-registry.ts +0 -529
- package/src/chart-types.ts +0 -225
- package/src/chart.ts +0 -685
- package/src/charts-d3/bar.ts +0 -338
- package/src/charts-d3/chord.ts +0 -140
- package/src/charts-d3/function.ts +0 -147
- package/src/charts-d3/funnel.ts +0 -104
- package/src/charts-d3/heatmap.ts +0 -145
- package/src/charts-d3/index.ts +0 -337
- package/src/charts-d3/interactions.ts +0 -477
- package/src/charts-d3/line.ts +0 -344
- package/src/charts-d3/mount.ts +0 -151
- package/src/charts-d3/pie.ts +0 -117
- package/src/charts-d3/polar.ts +0 -89
- package/src/charts-d3/radar.ts +0 -0
- package/src/charts-d3/sankey.ts +0 -0
- package/src/charts-d3/scatter.ts +0 -185
- package/src/charts-d3/shared.ts +0 -208
- package/src/class/layout.ts +0 -326
- package/src/class/parser.ts +0 -635
- package/src/class/renderer.ts +0 -793
- package/src/class/types.ts +0 -58
- package/src/cli-banner.ts +0 -106
- package/src/cli.ts +0 -1080
- package/src/colors-diagnostics.ts +0 -35
- package/src/colors.ts +0 -379
- package/src/completion-types.ts +0 -27
- package/src/completion.ts +0 -2668
- package/src/cycle/layout.ts +0 -1026
- package/src/cycle/parser.ts +0 -387
- package/src/cycle/renderer.ts +0 -653
- package/src/cycle/types.ts +0 -101
- package/src/d3.ts +0 -1434
- package/src/data-chart-parser.ts +0 -1007
- package/src/dgmo-router.ts +0 -296
- package/src/diagnostics-registry.ts +0 -108
- package/src/diagnostics.ts +0 -0
- package/src/dimensions.ts +0 -20
- package/src/directives-registry.ts +0 -396
- package/src/editor/dgmo.grammar +0 -76
- package/src/editor/dgmo.grammar.d.ts +0 -2
- package/src/editor/dgmo.grammar.js +0 -18
- package/src/editor/dgmo.grammar.terms.d.ts +0 -5
- package/src/editor/dgmo.grammar.terms.js +0 -35
- package/src/editor/highlight-api.ts +0 -596
- package/src/editor/highlight.ts +0 -36
- package/src/editor/index.ts +0 -27
- package/src/editor/keywords.ts +0 -128
- package/src/editor/tokens.ts +0 -30
- package/src/element/index.ts +0 -432
- package/src/embed/css.ts +0 -312
- package/src/embed/escape.ts +0 -15
- package/src/embed/index.ts +0 -280
- package/src/er/classify.ts +0 -228
- package/src/er/layout.ts +0 -530
- package/src/er/parser.ts +0 -750
- package/src/er/renderer.ts +0 -725
- package/src/er/types.ts +0 -52
- package/src/error-card.ts +0 -269
- package/src/event-line/diagnostics.ts +0 -117
- package/src/event-line/parser.ts +0 -643
- package/src/event-line/renderer.ts +0 -1742
- package/src/event-line/types.ts +0 -74
- package/src/fonts.ts +0 -3
- package/src/gantt/calculator.ts +0 -979
- package/src/gantt/parser.ts +0 -1641
- package/src/gantt/renderer.ts +0 -3787
- package/src/gantt/resolver.ts +0 -172
- package/src/gantt/types.ts +0 -206
- package/src/graph/diagnostics.ts +0 -64
- package/src/graph/edge-spline.ts +0 -29
- package/src/graph/flowchart-parser.ts +0 -759
- package/src/graph/flowchart-renderer.ts +0 -817
- package/src/graph/layout.ts +0 -472
- package/src/graph/notes.ts +0 -21
- package/src/graph/state-collapse.ts +0 -78
- package/src/graph/state-parser.ts +0 -574
- package/src/graph/state-renderer.ts +0 -653
- package/src/graph/types.ts +0 -65
- package/src/index.ts +0 -261
- package/src/infra/compute.ts +0 -1385
- package/src/infra/layout.ts +0 -888
- package/src/infra/parser.ts +0 -1234
- package/src/infra/renderer.ts +0 -2534
- package/src/infra/roles.ts +0 -75
- package/src/infra/types.ts +0 -216
- package/src/infra/validation.ts +0 -201
- package/src/journey-map/layout.ts +0 -490
- package/src/journey-map/parser.ts +0 -557
- package/src/journey-map/renderer.ts +0 -1580
- package/src/journey-map/types.ts +0 -47
- package/src/kanban/mutations.ts +0 -175
- package/src/kanban/parser.ts +0 -509
- package/src/kanban/renderer.ts +0 -1449
- package/src/kanban/types.ts +0 -39
- package/src/label-layout.ts +0 -368
- package/src/map/colorize.ts +0 -54
- package/src/map/completion.ts +0 -316
- package/src/map/context-labels.ts +0 -609
- package/src/map/data/PROVENANCE.json +0 -1
- package/src/map/data/README.md +0 -40
- package/src/map/data/airport-collisions.json +0 -1
- package/src/map/data/airports.json +0 -1
- package/src/map/data/gazetteer.json +0 -1
- package/src/map/data/lakes.json +0 -1
- package/src/map/data/mountain-ranges.json +0 -1
- package/src/map/data/na-lakes.json +0 -1
- package/src/map/data/na-land.json +0 -1
- package/src/map/data/region-names.json +0 -1
- package/src/map/data/rivers.json +0 -1
- package/src/map/data/types.ts +0 -121
- package/src/map/data/us-states.json +0 -1
- package/src/map/data/water-bodies.json +0 -1
- package/src/map/data/world-coarse.json +0 -1
- package/src/map/data/world-detail.json +0 -1
- package/src/map/diagnostics.ts +0 -134
- package/src/map/dimensions.ts +0 -150
- package/src/map/geo-query.ts +0 -295
- package/src/map/geo.ts +0 -438
- package/src/map/invert.ts +0 -111
- package/src/map/layout.ts +0 -5069
- package/src/map/legend-band.ts +0 -127
- package/src/map/load-data.ts +0 -186
- package/src/map/parser.ts +0 -783
- package/src/map/renderer.ts +0 -1445
- package/src/map/resolved-types.ts +0 -160
- package/src/map/resolver.ts +0 -1146
- package/src/map/types.ts +0 -194
- package/src/mindmap/collapse.ts +0 -67
- package/src/mindmap/layout.ts +0 -649
- package/src/mindmap/parser.ts +0 -430
- package/src/mindmap/renderer.ts +0 -637
- package/src/mindmap/text-wrap.ts +0 -218
- package/src/mindmap/types.ts +0 -56
- package/src/org/collapse.ts +0 -151
- package/src/org/layout.ts +0 -1262
- package/src/org/parser.ts +0 -519
- package/src/org/renderer.ts +0 -866
- package/src/org/resolver.ts +0 -636
- package/src/palettes/atlas.ts +0 -77
- package/src/palettes/blueprint.ts +0 -73
- package/src/palettes/catppuccin.ts +0 -84
- package/src/palettes/color-utils.ts +0 -443
- package/src/palettes/index.ts +0 -67
- package/src/palettes/nord.ts +0 -67
- package/src/palettes/registry.ts +0 -114
- package/src/palettes/slate.ts +0 -73
- package/src/palettes/tidewater.ts +0 -73
- package/src/palettes/tokyo-night.ts +0 -86
- package/src/palettes/types.ts +0 -88
- package/src/pert/analyzer.ts +0 -1183
- package/src/pert/diagnostics.ts +0 -110
- package/src/pert/index.ts +0 -43
- package/src/pert/internal.ts +0 -252
- package/src/pert/layout.ts +0 -971
- package/src/pert/monte-carlo.ts +0 -446
- package/src/pert/parser.ts +0 -1606
- package/src/pert/renderer.ts +0 -4009
- package/src/pert/share-normalize.ts +0 -62
- package/src/pert/types.ts +0 -604
- package/src/pyramid/parser.ts +0 -185
- package/src/pyramid/renderer.ts +0 -768
- package/src/pyramid/types.ts +0 -28
- package/src/quadrant/parser.ts +0 -18
- package/src/quadrant/renderer.ts +0 -638
- package/src/raci/diagnostics.ts +0 -213
- package/src/raci/index.ts +0 -23
- package/src/raci/mutations.ts +0 -321
- package/src/raci/parser.ts +0 -699
- package/src/raci/renderer.ts +0 -1627
- package/src/raci/types.ts +0 -82
- package/src/raci/variants.ts +0 -320
- package/src/render.ts +0 -268
- package/src/ring/parser.ts +0 -212
- package/src/ring/renderer.ts +0 -490
- package/src/ring/types.ts +0 -27
- package/src/sequence/collapse.ts +0 -174
- package/src/sequence/parser.ts +0 -1596
- package/src/sequence/participant-inference.ts +0 -152
- package/src/sequence/renderer.ts +0 -3168
- package/src/sequence/tag-resolution.ts +0 -164
- package/src/sharing.ts +0 -211
- package/src/sitemap/collapse.ts +0 -100
- package/src/sitemap/layout.ts +0 -925
- package/src/sitemap/parser.ts +0 -755
- package/src/sitemap/renderer.ts +0 -828
- package/src/sitemap/types.ts +0 -42
- package/src/slope/parser.ts +0 -18
- package/src/slope/renderer.ts +0 -378
- package/src/swimlane/diagnostics.ts +0 -94
- package/src/swimlane/layout.ts +0 -597
- package/src/swimlane/parser.ts +0 -613
- package/src/swimlane/renderer.ts +0 -681
- package/src/swimlane/types.ts +0 -151
- package/src/tech-radar/interactive.ts +0 -1129
- package/src/tech-radar/layout.ts +0 -197
- package/src/tech-radar/parser.ts +0 -436
- package/src/tech-radar/renderer.ts +0 -1239
- package/src/tech-radar/shared.ts +0 -151
- package/src/tech-radar/types.ts +0 -86
- package/src/themes.ts +0 -22
- package/src/timeline/parser.ts +0 -472
- package/src/timeline/renderer.ts +0 -3024
- package/src/timeline/types.ts +0 -33
- package/src/timeline/viz-parser.ts +0 -21
- package/src/treemap/diagnostics.ts +0 -62
- package/src/treemap/layout-radial.ts +0 -233
- package/src/treemap/layout.ts +0 -189
- package/src/treemap/parser.ts +0 -463
- package/src/treemap/renderer-radial.ts +0 -438
- package/src/treemap/renderer.ts +0 -572
- package/src/treemap/treemap-shared.ts +0 -309
- package/src/treemap/types.ts +0 -60
- package/src/utils/arrow-markers.ts +0 -51
- package/src/utils/arrows-diagnostics.ts +0 -62
- package/src/utils/arrows.ts +0 -227
- package/src/utils/brand.ts +0 -61
- package/src/utils/card.ts +0 -183
- package/src/utils/collapse-engine/tree-edges.ts +0 -115
- package/src/utils/collapse-engine/tree.ts +0 -117
- package/src/utils/d3-helpers.ts +0 -219
- package/src/utils/d3-types.ts +0 -9
- package/src/utils/description-helpers.ts +0 -38
- package/src/utils/duration.ts +0 -312
- package/src/utils/export-container.ts +0 -41
- package/src/utils/expr-eval.ts +0 -27
- package/src/utils/fit-canvas.ts +0 -64
- package/src/utils/hover-styles.ts +0 -673
- package/src/utils/inline-markdown.ts +0 -96
- package/src/utils/legend-constants.ts +0 -48
- package/src/utils/legend-d3.ts +0 -618
- package/src/utils/legend-integration.ts +0 -107
- package/src/utils/legend-layout.ts +0 -776
- package/src/utils/legend-svg.ts +0 -214
- package/src/utils/legend-types.ts +0 -272
- package/src/utils/name-normalize.ts +0 -163
- package/src/utils/note-box/constants.ts +0 -25
- package/src/utils/note-box/index.ts +0 -11
- package/src/utils/note-box/metrics.ts +0 -90
- package/src/utils/note-box/svg.ts +0 -331
- package/src/utils/notes/bounds.ts +0 -30
- package/src/utils/notes/build.ts +0 -131
- package/src/utils/notes/index.ts +0 -18
- package/src/utils/notes/model.ts +0 -19
- package/src/utils/notes/parse.ts +0 -131
- package/src/utils/notes/place.ts +0 -177
- package/src/utils/notes/resolve.ts +0 -88
- package/src/utils/number-format.ts +0 -36
- package/src/utils/parsing.ts +0 -953
- package/src/utils/reserved-key-registry.ts +0 -239
- package/src/utils/safe-href.ts +0 -38
- package/src/utils/scaling.ts +0 -97
- package/src/utils/svg-embed.ts +0 -251
- package/src/utils/tag-groups.ts +0 -607
- package/src/utils/text-measure.ts +0 -122
- package/src/utils/time-ticks.ts +0 -253
- package/src/utils/title-constants.ts +0 -24
- package/src/utils/visual-conventions.ts +0 -61
- package/src/utils/wrapped-desc.ts +0 -87
- package/src/venn/parser.ts +0 -18
- package/src/venn/renderer.ts +0 -1047
- package/src/version-control/diagnostics.ts +0 -67
- package/src/version-control/parser.ts +0 -544
- package/src/version-control/renderer.ts +0 -724
- package/src/version-control/types.ts +0 -100
- package/src/visualizations/parse.ts +0 -1560
- package/src/visualizations/types.ts +0 -262
- package/src/wireframe/layout.ts +0 -479
- package/src/wireframe/parser.ts +0 -1114
- package/src/wireframe/renderer.ts +0 -1318
- package/src/wireframe/types.ts +0 -110
- package/src/wordcloud/parser.ts +0 -18
- package/src/wordcloud/renderer.ts +0 -357
package/src/timeline/renderer.ts
DELETED
|
@@ -1,3024 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// Timeline renderer — Story 109.2 (arch-review). Extracted from d3.ts.
|
|
3
|
-
// ============================================================
|
|
4
|
-
|
|
5
|
-
import { tagAttrKey } from '../utils/tag-groups';
|
|
6
|
-
import * as d3Scale from 'd3-scale';
|
|
7
|
-
import * as d3Selection from 'd3-selection';
|
|
8
|
-
import { FONT_FAMILY } from '../fonts';
|
|
9
|
-
import { MONTH_ABBR, computeTimeTicks } from '../utils/time-ticks';
|
|
10
|
-
import type { D3ExportDimensions } from '../utils/d3-types';
|
|
11
|
-
import { ScaleContext } from '../utils/scaling';
|
|
12
|
-
import {
|
|
13
|
-
createTooltip,
|
|
14
|
-
hideTooltip,
|
|
15
|
-
renderChartTitle,
|
|
16
|
-
showTooltip,
|
|
17
|
-
} from '../utils/d3-helpers';
|
|
18
|
-
import type { ParsedTimeline } from '../visualizations/types';
|
|
19
|
-
import type {
|
|
20
|
-
TimelineEvent,
|
|
21
|
-
TimelineGroup,
|
|
22
|
-
TimelineEra,
|
|
23
|
-
TimelineMarker,
|
|
24
|
-
} from '../timeline/types';
|
|
25
|
-
import { parseTimelineDate } from '../timeline/parser';
|
|
26
|
-
import type { PaletteColors } from '../palettes';
|
|
27
|
-
import { getSeriesColors } from '../palettes';
|
|
28
|
-
import { mix, shapeFill, themeBaseBg } from '../palettes/color-utils';
|
|
29
|
-
import { resolveTagColor } from '../utils/tag-groups';
|
|
30
|
-
import type { TagGroup } from '../utils/tag-groups';
|
|
31
|
-
import {
|
|
32
|
-
LEGEND_HEIGHT as TL_LEGEND_HEIGHT,
|
|
33
|
-
LEGEND_PILL_PAD as TL_LEGEND_PILL_PAD,
|
|
34
|
-
LEGEND_PILL_FONT_SIZE as TL_LEGEND_PILL_FONT_SIZE,
|
|
35
|
-
LEGEND_CAPSULE_PAD as TL_LEGEND_CAPSULE_PAD,
|
|
36
|
-
LEGEND_DOT_R as TL_LEGEND_DOT_R,
|
|
37
|
-
LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
|
|
38
|
-
LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
|
|
39
|
-
LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
|
|
40
|
-
measureLegendText,
|
|
41
|
-
truncateLegendText,
|
|
42
|
-
} from '../utils/legend-constants';
|
|
43
|
-
import { renderIntegratedLegend } from '../utils/legend-integration';
|
|
44
|
-
import type { LegendConfig, LegendCallbacks } from '../utils/legend-types';
|
|
45
|
-
|
|
46
|
-
function getEraColors(palette: PaletteColors): string[] {
|
|
47
|
-
return [
|
|
48
|
-
palette.colors.blue,
|
|
49
|
-
palette.colors.green,
|
|
50
|
-
palette.colors.yellow,
|
|
51
|
-
palette.colors.orange,
|
|
52
|
-
palette.colors.purple,
|
|
53
|
-
];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Renders semi-transparent era background bands behind timeline events.
|
|
58
|
-
*/
|
|
59
|
-
function renderEras(
|
|
60
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
61
|
-
eras: TimelineEra[],
|
|
62
|
-
scale: d3Scale.ScaleLinear<number, number>,
|
|
63
|
-
isVertical: boolean,
|
|
64
|
-
innerWidth: number,
|
|
65
|
-
innerHeight: number,
|
|
66
|
-
onEnter: (eraStart: number, eraEnd: number) => void,
|
|
67
|
-
onLeave: () => void,
|
|
68
|
-
hasScale: boolean = false,
|
|
69
|
-
_tooltip: HTMLDivElement | null = null,
|
|
70
|
-
palette?: PaletteColors,
|
|
71
|
-
// When provided (horizontal reserved-row mode), eras render their label
|
|
72
|
-
// in the dedicated header row at this Y, the rect stays inside the chart
|
|
73
|
-
// (rectTop=0), and the label is truncated to fit the era's span. Hover
|
|
74
|
-
// restores the full text.
|
|
75
|
-
reservedLabelY?: number
|
|
76
|
-
): void {
|
|
77
|
-
const eraColors = palette
|
|
78
|
-
? getEraColors(palette)
|
|
79
|
-
: ['#3b6ea5', '#5b9357', '#c9a227', '#cc7a33', '#7d5ba6'];
|
|
80
|
-
eras.forEach((era, i) => {
|
|
81
|
-
const startVal = parseTimelineDate(era.startDate);
|
|
82
|
-
const endVal = parseTimelineDate(era.endDate);
|
|
83
|
-
if (!Number.isFinite(startVal) || !Number.isFinite(endVal)) return;
|
|
84
|
-
const start = scale(startVal);
|
|
85
|
-
const end = scale(endVal);
|
|
86
|
-
if (!Number.isFinite(start) || !Number.isFinite(end)) return;
|
|
87
|
-
// eraColors is non-empty; modulo guarantees in-bounds.
|
|
88
|
-
const color = era.color || eraColors[i % eraColors.length]!;
|
|
89
|
-
|
|
90
|
-
const eraG = g
|
|
91
|
-
.append('g')
|
|
92
|
-
.attr('class', 'tl-era')
|
|
93
|
-
.attr('data-line-number', String(era.lineNumber))
|
|
94
|
-
.attr('data-era-start', String(startVal))
|
|
95
|
-
.attr('data-era-end', String(endVal))
|
|
96
|
-
.style('cursor', 'pointer');
|
|
97
|
-
|
|
98
|
-
let labelEl: d3Selection.Selection<
|
|
99
|
-
SVGTextElement,
|
|
100
|
-
unknown,
|
|
101
|
-
null,
|
|
102
|
-
undefined
|
|
103
|
-
>;
|
|
104
|
-
let displayLabel = era.label;
|
|
105
|
-
let truncated = false;
|
|
106
|
-
|
|
107
|
-
if (isVertical) {
|
|
108
|
-
const y = Math.min(start, end);
|
|
109
|
-
const h = Math.abs(end - start);
|
|
110
|
-
eraG
|
|
111
|
-
.append('rect')
|
|
112
|
-
.attr('x', 0)
|
|
113
|
-
.attr('y', y)
|
|
114
|
-
.attr('width', innerWidth)
|
|
115
|
-
.attr('height', h)
|
|
116
|
-
.attr('fill', color)
|
|
117
|
-
.attr('opacity', 0.08);
|
|
118
|
-
labelEl = eraG
|
|
119
|
-
.append('text')
|
|
120
|
-
.attr('x', 6)
|
|
121
|
-
.attr('y', y + 18)
|
|
122
|
-
.attr('text-anchor', 'start')
|
|
123
|
-
.attr('fill', color)
|
|
124
|
-
.attr('font-size', '13px')
|
|
125
|
-
.attr('font-weight', '600')
|
|
126
|
-
.attr('opacity', 0.8)
|
|
127
|
-
.text(displayLabel);
|
|
128
|
-
} else {
|
|
129
|
-
const x = Math.min(start, end);
|
|
130
|
-
const w = Math.abs(end - start);
|
|
131
|
-
// Reserved-row mode: rect lives inside the chart, label sits in its
|
|
132
|
-
// own row above. Legacy mode (no reserved row): keep the era rect
|
|
133
|
-
// extending above the chart so the label has space when scale is on.
|
|
134
|
-
const useReservedRow = reservedLabelY != null;
|
|
135
|
-
const rectTop = useReservedRow ? 0 : hasScale ? -48 : 0;
|
|
136
|
-
const labelY = useReservedRow ? reservedLabelY! : hasScale ? -32 : 18;
|
|
137
|
-
eraG
|
|
138
|
-
.append('rect')
|
|
139
|
-
.attr('x', x)
|
|
140
|
-
.attr('y', rectTop)
|
|
141
|
-
.attr('width', w)
|
|
142
|
-
.attr('height', innerHeight - rectTop)
|
|
143
|
-
.attr('fill', color)
|
|
144
|
-
.attr('opacity', 0.08);
|
|
145
|
-
if (useReservedRow) {
|
|
146
|
-
// Truncate to era's own span so labels stay inside their tinted band.
|
|
147
|
-
const maxW = Math.max(0, w - 8);
|
|
148
|
-
displayLabel = truncateLegendText(era.label, 13, maxW);
|
|
149
|
-
truncated = displayLabel !== era.label;
|
|
150
|
-
}
|
|
151
|
-
labelEl = eraG
|
|
152
|
-
.append('text')
|
|
153
|
-
.attr('x', x + w / 2)
|
|
154
|
-
.attr('y', labelY)
|
|
155
|
-
.attr('text-anchor', 'middle')
|
|
156
|
-
.attr('fill', color)
|
|
157
|
-
.attr('font-size', '13px')
|
|
158
|
-
.attr('font-weight', '600')
|
|
159
|
-
.attr('opacity', 0.8)
|
|
160
|
-
.text(displayLabel);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
eraG
|
|
164
|
-
.on('mouseenter', function () {
|
|
165
|
-
onEnter(startVal, endVal);
|
|
166
|
-
if (truncated) labelEl.text(era.label);
|
|
167
|
-
})
|
|
168
|
-
.on('mouseleave', function () {
|
|
169
|
-
onLeave();
|
|
170
|
-
if (truncated) labelEl.text(displayLabel);
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Renders timeline markers as dashed vertical lines with diamond indicators and labels.
|
|
177
|
-
*/
|
|
178
|
-
function renderMarkers(
|
|
179
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
180
|
-
markers: TimelineMarker[],
|
|
181
|
-
scale: d3Scale.ScaleLinear<number, number>,
|
|
182
|
-
isVertical: boolean,
|
|
183
|
-
innerWidth: number,
|
|
184
|
-
innerHeight: number,
|
|
185
|
-
onEnter: (markerDate: number) => void,
|
|
186
|
-
onLeave: () => void,
|
|
187
|
-
_hasScale: boolean = false,
|
|
188
|
-
_tooltip: HTMLDivElement | null = null,
|
|
189
|
-
palette?: PaletteColors,
|
|
190
|
-
// When provided (horizontal reserved-row mode), labels render at this Y
|
|
191
|
-
// above the chart edge instead of inside the chart at y=6, and are
|
|
192
|
-
// truncated symmetrically based on neighbor distance.
|
|
193
|
-
reservedLabelY?: number
|
|
194
|
-
): void {
|
|
195
|
-
// Default marker color - bright orange/red that "pops"
|
|
196
|
-
const defaultColor = palette?.accent || '#3a9188';
|
|
197
|
-
|
|
198
|
-
// Pre-compute marker positions so each can size its label based on the
|
|
199
|
-
// distance to its nearest neighbor (or chart edge).
|
|
200
|
-
const positions = markers.map((m) => {
|
|
201
|
-
const v = parseTimelineDate(m.date);
|
|
202
|
-
return Number.isFinite(v) ? scale(v) : NaN;
|
|
203
|
-
});
|
|
204
|
-
const useReservedRow = reservedLabelY != null && !isVertical;
|
|
205
|
-
|
|
206
|
-
markers.forEach((marker, i) => {
|
|
207
|
-
const dateVal = parseTimelineDate(marker.date);
|
|
208
|
-
if (!Number.isFinite(dateVal)) return;
|
|
209
|
-
// positions is produced by markers.map(), so i is in-bounds.
|
|
210
|
-
const pos = positions[i]!;
|
|
211
|
-
if (!Number.isFinite(pos)) return;
|
|
212
|
-
const color = marker.color || defaultColor;
|
|
213
|
-
const lineOpacity = 0.5;
|
|
214
|
-
const diamondSize = 5;
|
|
215
|
-
|
|
216
|
-
const markerG = g
|
|
217
|
-
.append('g')
|
|
218
|
-
.attr('class', 'tl-marker')
|
|
219
|
-
.attr('data-marker-date', String(dateVal))
|
|
220
|
-
.attr('data-line-number', String(marker.lineNumber))
|
|
221
|
-
.style('cursor', 'pointer');
|
|
222
|
-
|
|
223
|
-
if (isVertical) {
|
|
224
|
-
// Vertical orientation: horizontal dashed line across the chart
|
|
225
|
-
markerG
|
|
226
|
-
.append('line')
|
|
227
|
-
.attr('x1', 0)
|
|
228
|
-
.attr('y1', pos)
|
|
229
|
-
.attr('x2', innerWidth)
|
|
230
|
-
.attr('y2', pos)
|
|
231
|
-
.attr('stroke', color)
|
|
232
|
-
.attr('stroke-width', 1.5)
|
|
233
|
-
.attr('stroke-dasharray', '6 4')
|
|
234
|
-
.attr('opacity', lineOpacity);
|
|
235
|
-
|
|
236
|
-
// Label above diamond
|
|
237
|
-
markerG
|
|
238
|
-
.append('text')
|
|
239
|
-
.attr('x', -diamondSize - 8)
|
|
240
|
-
.attr('y', pos - diamondSize - 4)
|
|
241
|
-
.attr('text-anchor', 'middle')
|
|
242
|
-
.attr('fill', color)
|
|
243
|
-
.attr('font-size', '11px')
|
|
244
|
-
.attr('font-weight', '600')
|
|
245
|
-
.text(marker.label);
|
|
246
|
-
|
|
247
|
-
// Diamond at the left edge
|
|
248
|
-
markerG
|
|
249
|
-
.append('path')
|
|
250
|
-
.attr(
|
|
251
|
-
'd',
|
|
252
|
-
`M${-diamondSize - 8},${pos} l${diamondSize},-${diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} Z`
|
|
253
|
-
)
|
|
254
|
-
.attr('fill', color)
|
|
255
|
-
.attr('opacity', 0.9);
|
|
256
|
-
|
|
257
|
-
markerG
|
|
258
|
-
.on('mouseenter', function () {
|
|
259
|
-
onEnter(dateVal);
|
|
260
|
-
})
|
|
261
|
-
.on('mouseleave', function () {
|
|
262
|
-
onLeave();
|
|
263
|
-
});
|
|
264
|
-
} else {
|
|
265
|
-
// Horizontal orientation: vertical dashed line down the chart.
|
|
266
|
-
// Reserved-row mode lifts the label above the chart edge; legacy mode
|
|
267
|
-
// keeps it at y=6 inside the chart top.
|
|
268
|
-
const labelY = useReservedRow ? reservedLabelY! : 6;
|
|
269
|
-
// Diamond sits fully above the chart edge in reserved-row mode so it
|
|
270
|
-
// isn't clipped by group bars that start at y=0; legacy mode keeps the
|
|
271
|
-
// diamond inside the chart top below the label.
|
|
272
|
-
const diamondY = useReservedRow ? -(diamondSize + 1) : labelY + 14;
|
|
273
|
-
const lineTop = diamondY + diamondSize;
|
|
274
|
-
|
|
275
|
-
// Compute available label width based on nearest-neighbor distance.
|
|
276
|
-
// Both labels truncate symmetrically and meet in the middle of the gap.
|
|
277
|
-
let displayLabel = marker.label;
|
|
278
|
-
let truncated = false;
|
|
279
|
-
if (useReservedRow) {
|
|
280
|
-
let nearestDist = Math.min(pos, innerWidth - pos);
|
|
281
|
-
for (let j = 0; j < positions.length; j++) {
|
|
282
|
-
if (j === i) continue;
|
|
283
|
-
// In-bounds by loop guard.
|
|
284
|
-
const other = positions[j]!;
|
|
285
|
-
if (!Number.isFinite(other)) continue;
|
|
286
|
-
const d = Math.abs(other - pos);
|
|
287
|
-
if (d < nearestDist) nearestDist = d;
|
|
288
|
-
}
|
|
289
|
-
const maxW = Math.max(0, nearestDist - 8);
|
|
290
|
-
displayLabel = truncateLegendText(marker.label, 11, maxW);
|
|
291
|
-
truncated = displayLabel !== marker.label;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const labelEl = markerG
|
|
295
|
-
.append('text')
|
|
296
|
-
.attr('x', pos)
|
|
297
|
-
.attr('y', labelY)
|
|
298
|
-
.attr('text-anchor', 'middle')
|
|
299
|
-
.attr('fill', color)
|
|
300
|
-
.attr('font-size', '11px')
|
|
301
|
-
.attr('font-weight', '600')
|
|
302
|
-
.text(displayLabel);
|
|
303
|
-
|
|
304
|
-
// Diamond
|
|
305
|
-
markerG
|
|
306
|
-
.append('path')
|
|
307
|
-
.attr(
|
|
308
|
-
'd',
|
|
309
|
-
`M${pos},${diamondY - diamondSize} l${diamondSize},${diamondSize} l-${diamondSize},${diamondSize} l-${diamondSize},-${diamondSize} Z`
|
|
310
|
-
)
|
|
311
|
-
.attr('fill', color)
|
|
312
|
-
.attr('opacity', 0.9);
|
|
313
|
-
|
|
314
|
-
// Dashed line down the chart
|
|
315
|
-
markerG
|
|
316
|
-
.append('line')
|
|
317
|
-
.attr('x1', pos)
|
|
318
|
-
.attr('y1', lineTop)
|
|
319
|
-
.attr('x2', pos)
|
|
320
|
-
.attr('y2', innerHeight)
|
|
321
|
-
.attr('stroke', color)
|
|
322
|
-
.attr('stroke-width', 1.5)
|
|
323
|
-
.attr('stroke-dasharray', '6 4')
|
|
324
|
-
.attr('opacity', lineOpacity);
|
|
325
|
-
|
|
326
|
-
markerG
|
|
327
|
-
.on('mouseenter', function () {
|
|
328
|
-
onEnter(dateVal);
|
|
329
|
-
if (truncated) labelEl.text(marker.label);
|
|
330
|
-
})
|
|
331
|
-
.on('mouseleave', function () {
|
|
332
|
-
onLeave();
|
|
333
|
-
if (truncated) labelEl.text(displayLabel);
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ============================================================
|
|
340
|
-
// Timeline Time Scale
|
|
341
|
-
// ============================================================
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Converts a DSL date string to a human-readable label.
|
|
345
|
-
* '1718' → '1718'
|
|
346
|
-
* '1718-05' → 'May 1718'
|
|
347
|
-
* '1718-05-22' → 'May 22, 1718'
|
|
348
|
-
* '2024-06-15 14:30' → 'Jun 15, 2024 14:30'
|
|
349
|
-
* '2024-06-15 14:30:45' → 'Jun 15, 2024 14:30:45'
|
|
350
|
-
* '-753' → '753 BCE' (BCE years stored signed)
|
|
351
|
-
* '-0044-03' → 'Mar 44 BCE'
|
|
352
|
-
*/
|
|
353
|
-
export function formatDateLabel(dateStr: string): string {
|
|
354
|
-
// A leading '-' marks a BCE year (internal signed form); strip it, format the
|
|
355
|
-
// positive date, then re-attach the era marker.
|
|
356
|
-
let bce = false;
|
|
357
|
-
if (dateStr.startsWith('-')) {
|
|
358
|
-
bce = true;
|
|
359
|
-
dateStr = dateStr.slice(1);
|
|
360
|
-
}
|
|
361
|
-
const era = bce ? ' BCE' : '';
|
|
362
|
-
|
|
363
|
-
// Split off optional time component (HH:MM or HH:MM:SS — passed through as-is).
|
|
364
|
-
const spaceIdx = dateStr.indexOf(' ');
|
|
365
|
-
let datePart = dateStr;
|
|
366
|
-
let timeSuffix = '';
|
|
367
|
-
|
|
368
|
-
if (spaceIdx !== -1) {
|
|
369
|
-
datePart = dateStr.slice(0, spaceIdx);
|
|
370
|
-
timeSuffix = ' ' + dateStr.slice(spaceIdx + 1);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const parts = datePart.split('-');
|
|
374
|
-
// split returns at least one element; strip any zero-padding on the year.
|
|
375
|
-
const year = String(parseInt(parts[0]!, 10));
|
|
376
|
-
if (parts.length === 1) return `${year}${timeSuffix}${era}`;
|
|
377
|
-
// In-bounds by length check above.
|
|
378
|
-
const month = MONTH_ABBR[parseInt(parts[1]!, 10) - 1];
|
|
379
|
-
if (parts.length === 2) return `${month} ${year}${timeSuffix}${era}`;
|
|
380
|
-
// In-bounds by length check above.
|
|
381
|
-
const day = parseInt(parts[2]!, 10);
|
|
382
|
-
return `${month} ${day}, ${year}${timeSuffix}${era}`;
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Formats a boundary label for the time axis.
|
|
387
|
-
* When both boundaries fall on the same calendar day and have a time component,
|
|
388
|
-
* returns just the time (e.g. "12:15") to avoid collisions with regular ticks.
|
|
389
|
-
* Otherwise falls back to the full formatDateLabel.
|
|
390
|
-
*/
|
|
391
|
-
function formatBoundaryLabel(dateStr: string, otherDateStr: string): string {
|
|
392
|
-
const spaceIdx = dateStr.indexOf(' ');
|
|
393
|
-
const otherSpaceIdx = otherDateStr.indexOf(' ');
|
|
394
|
-
// Both must have time components and share the same date portion
|
|
395
|
-
if (spaceIdx !== -1 && otherSpaceIdx !== -1) {
|
|
396
|
-
const datePart = dateStr.slice(0, spaceIdx);
|
|
397
|
-
const otherDatePart = otherDateStr.slice(0, otherSpaceIdx);
|
|
398
|
-
if (datePart === otherDatePart) {
|
|
399
|
-
return dateStr.slice(spaceIdx + 1); // just "HH:MM"
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return formatDateLabel(dateStr);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Renders adaptive tick marks along the time axis.
|
|
407
|
-
* Optional boundary parameters add ticks at exact data start/end.
|
|
408
|
-
*/
|
|
409
|
-
function renderTimeScale(
|
|
410
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
411
|
-
scale: d3Scale.ScaleLinear<number, number>,
|
|
412
|
-
isVertical: boolean,
|
|
413
|
-
innerWidth: number,
|
|
414
|
-
innerHeight: number,
|
|
415
|
-
textColor: string,
|
|
416
|
-
boundaryStart?: number,
|
|
417
|
-
boundaryEnd?: number,
|
|
418
|
-
boundaryStartLabel?: string,
|
|
419
|
-
boundaryEndLabel?: string
|
|
420
|
-
): void {
|
|
421
|
-
// d3 linear scales always return a 2-element domain.
|
|
422
|
-
const [domainMin, domainMax] = scale.domain() as [number, number];
|
|
423
|
-
const ticks = computeTimeTicks(
|
|
424
|
-
domainMin,
|
|
425
|
-
domainMax,
|
|
426
|
-
scale,
|
|
427
|
-
boundaryStart,
|
|
428
|
-
boundaryEnd,
|
|
429
|
-
boundaryStartLabel,
|
|
430
|
-
boundaryEndLabel
|
|
431
|
-
);
|
|
432
|
-
if (ticks.length < 2) return;
|
|
433
|
-
|
|
434
|
-
const tickLen = 6;
|
|
435
|
-
const opacity = 0.4;
|
|
436
|
-
|
|
437
|
-
const guideOpacity = 0.15;
|
|
438
|
-
|
|
439
|
-
for (const tick of ticks) {
|
|
440
|
-
if (isVertical) {
|
|
441
|
-
// Guide line spanning full width
|
|
442
|
-
g.append('line')
|
|
443
|
-
.attr('x1', 0)
|
|
444
|
-
.attr('y1', tick.pos)
|
|
445
|
-
.attr('x2', innerWidth)
|
|
446
|
-
.attr('y2', tick.pos)
|
|
447
|
-
.attr('stroke', textColor)
|
|
448
|
-
.attr('stroke-width', 1)
|
|
449
|
-
.attr('stroke-dasharray', '4 4')
|
|
450
|
-
.attr('opacity', guideOpacity);
|
|
451
|
-
|
|
452
|
-
// Left edge
|
|
453
|
-
g.append('line')
|
|
454
|
-
.attr('x1', -tickLen)
|
|
455
|
-
.attr('y1', tick.pos)
|
|
456
|
-
.attr('x2', 0)
|
|
457
|
-
.attr('y2', tick.pos)
|
|
458
|
-
.attr('stroke', textColor)
|
|
459
|
-
.attr('stroke-width', 1)
|
|
460
|
-
.attr('opacity', opacity);
|
|
461
|
-
|
|
462
|
-
g.append('text')
|
|
463
|
-
.attr('x', -tickLen - 3)
|
|
464
|
-
.attr('y', tick.pos)
|
|
465
|
-
.attr('dy', '0.35em')
|
|
466
|
-
.attr('text-anchor', 'end')
|
|
467
|
-
.attr('fill', textColor)
|
|
468
|
-
.attr('font-size', '10px')
|
|
469
|
-
.attr('opacity', opacity)
|
|
470
|
-
.text(tick.label);
|
|
471
|
-
|
|
472
|
-
// Right edge
|
|
473
|
-
g.append('line')
|
|
474
|
-
.attr('x1', innerWidth)
|
|
475
|
-
.attr('y1', tick.pos)
|
|
476
|
-
.attr('x2', innerWidth + tickLen)
|
|
477
|
-
.attr('y2', tick.pos)
|
|
478
|
-
.attr('stroke', textColor)
|
|
479
|
-
.attr('stroke-width', 1)
|
|
480
|
-
.attr('opacity', opacity);
|
|
481
|
-
|
|
482
|
-
g.append('text')
|
|
483
|
-
.attr('x', innerWidth + tickLen + 3)
|
|
484
|
-
.attr('y', tick.pos)
|
|
485
|
-
.attr('dy', '0.35em')
|
|
486
|
-
.attr('text-anchor', 'start')
|
|
487
|
-
.attr('fill', textColor)
|
|
488
|
-
.attr('font-size', '10px')
|
|
489
|
-
.attr('opacity', opacity)
|
|
490
|
-
.text(tick.label);
|
|
491
|
-
} else {
|
|
492
|
-
// Guide line spanning full height
|
|
493
|
-
g.append('line')
|
|
494
|
-
.attr('class', 'tl-scale-tick')
|
|
495
|
-
.attr('x1', tick.pos)
|
|
496
|
-
.attr('y1', 0)
|
|
497
|
-
.attr('x2', tick.pos)
|
|
498
|
-
.attr('y2', innerHeight)
|
|
499
|
-
.attr('stroke', textColor)
|
|
500
|
-
.attr('stroke-width', 1)
|
|
501
|
-
.attr('stroke-dasharray', '4 4')
|
|
502
|
-
.attr('opacity', guideOpacity);
|
|
503
|
-
|
|
504
|
-
// Bottom edge
|
|
505
|
-
g.append('line')
|
|
506
|
-
.attr('class', 'tl-scale-tick')
|
|
507
|
-
.attr('x1', tick.pos)
|
|
508
|
-
.attr('y1', innerHeight)
|
|
509
|
-
.attr('x2', tick.pos)
|
|
510
|
-
.attr('y2', innerHeight + tickLen)
|
|
511
|
-
.attr('stroke', textColor)
|
|
512
|
-
.attr('stroke-width', 1)
|
|
513
|
-
.attr('opacity', opacity);
|
|
514
|
-
|
|
515
|
-
g.append('text')
|
|
516
|
-
.attr('class', 'tl-scale-tick')
|
|
517
|
-
.attr('x', tick.pos)
|
|
518
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
519
|
-
.attr('text-anchor', 'middle')
|
|
520
|
-
.attr('fill', textColor)
|
|
521
|
-
.attr('font-size', '10px')
|
|
522
|
-
.attr('opacity', opacity)
|
|
523
|
-
.text(tick.label);
|
|
524
|
-
|
|
525
|
-
// Top edge
|
|
526
|
-
g.append('line')
|
|
527
|
-
.attr('class', 'tl-scale-tick')
|
|
528
|
-
.attr('x1', tick.pos)
|
|
529
|
-
.attr('y1', -tickLen)
|
|
530
|
-
.attr('x2', tick.pos)
|
|
531
|
-
.attr('y2', 0)
|
|
532
|
-
.attr('stroke', textColor)
|
|
533
|
-
.attr('stroke-width', 1)
|
|
534
|
-
.attr('opacity', opacity);
|
|
535
|
-
|
|
536
|
-
g.append('text')
|
|
537
|
-
.attr('class', 'tl-scale-tick')
|
|
538
|
-
.attr('x', tick.pos)
|
|
539
|
-
.attr('y', -tickLen - 4)
|
|
540
|
-
.attr('text-anchor', 'middle')
|
|
541
|
-
.attr('fill', textColor)
|
|
542
|
-
.attr('font-size', '10px')
|
|
543
|
-
.attr('opacity', opacity)
|
|
544
|
-
.text(tick.label);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ============================================================
|
|
550
|
-
// Timeline Event Date Scale Helpers
|
|
551
|
-
// ============================================================
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Shows event start/end dates on the scale, fading existing scale ticks.
|
|
555
|
-
* For horizontal timelines, displays dates at the top of the scale.
|
|
556
|
-
*/
|
|
557
|
-
function showEventDatesOnScale(
|
|
558
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
559
|
-
scale: d3Scale.ScaleLinear<number, number>,
|
|
560
|
-
startDate: string,
|
|
561
|
-
endDate: string | null,
|
|
562
|
-
innerHeight: number,
|
|
563
|
-
accentColor: string
|
|
564
|
-
): void {
|
|
565
|
-
// Fade existing scale ticks
|
|
566
|
-
g.selectAll('.tl-scale-tick').attr('opacity', 0.1);
|
|
567
|
-
|
|
568
|
-
const tickLen = 6;
|
|
569
|
-
const startPos = scale(parseTimelineDate(startDate));
|
|
570
|
-
const startLabel = formatDateLabel(startDate);
|
|
571
|
-
|
|
572
|
-
// Start date - top
|
|
573
|
-
g.append('line')
|
|
574
|
-
.attr('class', 'tl-event-date')
|
|
575
|
-
.attr('x1', startPos)
|
|
576
|
-
.attr('y1', -tickLen)
|
|
577
|
-
.attr('x2', startPos)
|
|
578
|
-
.attr('y2', innerHeight)
|
|
579
|
-
.attr('stroke', accentColor)
|
|
580
|
-
.attr('stroke-width', 1.5)
|
|
581
|
-
.attr('stroke-dasharray', '4 4')
|
|
582
|
-
.attr('opacity', 0.6);
|
|
583
|
-
|
|
584
|
-
g.append('text')
|
|
585
|
-
.attr('class', 'tl-event-date')
|
|
586
|
-
.attr('x', startPos)
|
|
587
|
-
.attr('y', -tickLen - 4)
|
|
588
|
-
.attr('text-anchor', 'middle')
|
|
589
|
-
.attr('fill', accentColor)
|
|
590
|
-
.attr('font-size', '10px')
|
|
591
|
-
.attr('font-weight', '600')
|
|
592
|
-
.text(startLabel);
|
|
593
|
-
|
|
594
|
-
// Start date - bottom
|
|
595
|
-
g.append('text')
|
|
596
|
-
.attr('class', 'tl-event-date')
|
|
597
|
-
.attr('x', startPos)
|
|
598
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
599
|
-
.attr('text-anchor', 'middle')
|
|
600
|
-
.attr('fill', accentColor)
|
|
601
|
-
.attr('font-size', '10px')
|
|
602
|
-
.attr('font-weight', '600')
|
|
603
|
-
.text(startLabel);
|
|
604
|
-
|
|
605
|
-
if (endDate) {
|
|
606
|
-
const endPos = scale(parseTimelineDate(endDate));
|
|
607
|
-
const endLabel = formatDateLabel(endDate);
|
|
608
|
-
|
|
609
|
-
// End date - top
|
|
610
|
-
g.append('line')
|
|
611
|
-
.attr('class', 'tl-event-date')
|
|
612
|
-
.attr('x1', endPos)
|
|
613
|
-
.attr('y1', -tickLen)
|
|
614
|
-
.attr('x2', endPos)
|
|
615
|
-
.attr('y2', innerHeight)
|
|
616
|
-
.attr('stroke', accentColor)
|
|
617
|
-
.attr('stroke-width', 1.5)
|
|
618
|
-
.attr('stroke-dasharray', '4 4')
|
|
619
|
-
.attr('opacity', 0.6);
|
|
620
|
-
|
|
621
|
-
g.append('text')
|
|
622
|
-
.attr('class', 'tl-event-date')
|
|
623
|
-
.attr('x', endPos)
|
|
624
|
-
.attr('y', -tickLen - 4)
|
|
625
|
-
.attr('text-anchor', 'middle')
|
|
626
|
-
.attr('fill', accentColor)
|
|
627
|
-
.attr('font-size', '10px')
|
|
628
|
-
.attr('font-weight', '600')
|
|
629
|
-
.text(endLabel);
|
|
630
|
-
|
|
631
|
-
// End date - bottom
|
|
632
|
-
g.append('text')
|
|
633
|
-
.attr('class', 'tl-event-date')
|
|
634
|
-
.attr('x', endPos)
|
|
635
|
-
.attr('y', innerHeight + tickLen + 12)
|
|
636
|
-
.attr('text-anchor', 'middle')
|
|
637
|
-
.attr('fill', accentColor)
|
|
638
|
-
.attr('font-size', '10px')
|
|
639
|
-
.attr('font-weight', '600')
|
|
640
|
-
.text(endLabel);
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Hides event dates and restores scale tick visibility.
|
|
646
|
-
*/
|
|
647
|
-
function hideEventDatesOnScale(
|
|
648
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
649
|
-
): void {
|
|
650
|
-
// Remove event date elements
|
|
651
|
-
g.selectAll('.tl-event-date').remove();
|
|
652
|
-
|
|
653
|
-
// Restore scale tick visibility
|
|
654
|
-
g.selectAll('.tl-scale-tick').each(function () {
|
|
655
|
-
const el = d3Selection.select(this);
|
|
656
|
-
// Restore original opacity based on element type
|
|
657
|
-
const isDashed = el.attr('stroke-dasharray');
|
|
658
|
-
el.attr('opacity', isDashed ? 0.15 : 0.4);
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
function buildEventTooltipHtml(ev: TimelineEvent): string {
|
|
663
|
-
const datePart = ev.endDate
|
|
664
|
-
? `${formatDateLabel(ev.date)} → ${formatDateLabel(ev.endDate)}`
|
|
665
|
-
: formatDateLabel(ev.date);
|
|
666
|
-
return `<strong>${ev.label}</strong><br>${datePart}`;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// ============================================================
|
|
670
|
-
// Timeline Renderer
|
|
671
|
-
// ============================================================
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* Renders timeline group legend as pills (colored dot + text in rounded rect),
|
|
675
|
-
* matching the centralized legend pill style.
|
|
676
|
-
*/
|
|
677
|
-
function renderTimelineGroupLegend(
|
|
678
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
679
|
-
groups: TimelineGroup[],
|
|
680
|
-
groupColorMap: Map<string, string>,
|
|
681
|
-
textColor: string,
|
|
682
|
-
palette: PaletteColors,
|
|
683
|
-
isDark: boolean,
|
|
684
|
-
legendY: number,
|
|
685
|
-
onHover: (name: string) => void,
|
|
686
|
-
onLeave: () => void
|
|
687
|
-
): void {
|
|
688
|
-
const PILL_H = 22;
|
|
689
|
-
const DOT_R = 4;
|
|
690
|
-
const DOT_GAP = 4;
|
|
691
|
-
const PAD_X = 10;
|
|
692
|
-
const FONT_SIZE = 11;
|
|
693
|
-
const GAP = 8;
|
|
694
|
-
const pillBg = isDark
|
|
695
|
-
? mix(palette.surface, palette.bg, 50)
|
|
696
|
-
: mix(palette.surface, palette.bg, 30);
|
|
697
|
-
|
|
698
|
-
let legendX = 0;
|
|
699
|
-
for (const grp of groups) {
|
|
700
|
-
const color = groupColorMap.get(grp.name) ?? textColor;
|
|
701
|
-
const textW = measureLegendText(grp.name, FONT_SIZE);
|
|
702
|
-
const pillW = PAD_X + DOT_R * 2 + DOT_GAP + textW + PAD_X;
|
|
703
|
-
|
|
704
|
-
const itemG = g
|
|
705
|
-
.append('g')
|
|
706
|
-
.attr('class', 'tl-legend-item')
|
|
707
|
-
.attr('data-group', grp.name)
|
|
708
|
-
.style('cursor', 'pointer')
|
|
709
|
-
.on('mouseenter', () => onHover(grp.name))
|
|
710
|
-
.on('mouseleave', () => onLeave());
|
|
711
|
-
|
|
712
|
-
// Pill background
|
|
713
|
-
itemG
|
|
714
|
-
.append('rect')
|
|
715
|
-
.attr('x', legendX)
|
|
716
|
-
.attr('y', legendY - PILL_H / 2)
|
|
717
|
-
.attr('width', pillW)
|
|
718
|
-
.attr('height', PILL_H)
|
|
719
|
-
.attr('rx', PILL_H / 2)
|
|
720
|
-
.attr('fill', pillBg);
|
|
721
|
-
|
|
722
|
-
// Colored dot
|
|
723
|
-
itemG
|
|
724
|
-
.append('circle')
|
|
725
|
-
.attr('cx', legendX + PAD_X + DOT_R)
|
|
726
|
-
.attr('cy', legendY)
|
|
727
|
-
.attr('r', DOT_R)
|
|
728
|
-
.attr('fill', color);
|
|
729
|
-
|
|
730
|
-
// Label text
|
|
731
|
-
itemG
|
|
732
|
-
.append('text')
|
|
733
|
-
.attr('x', legendX + PAD_X + DOT_R * 2 + DOT_GAP)
|
|
734
|
-
.attr('y', legendY)
|
|
735
|
-
.attr('dy', '0.35em')
|
|
736
|
-
.attr('fill', textColor)
|
|
737
|
-
.attr('font-size', `${FONT_SIZE}px`)
|
|
738
|
-
.attr('font-family', FONT_FAMILY)
|
|
739
|
-
.text(grp.name);
|
|
740
|
-
|
|
741
|
-
legendX += pillW + GAP;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// ============================================================
|
|
746
|
-
// Timeline — setup helper (extracted from renderTimeline)
|
|
747
|
-
// ============================================================
|
|
748
|
-
|
|
749
|
-
type Lane = { name: string; events: TimelineEvent[] };
|
|
750
|
-
|
|
751
|
-
type TimelineSetup = {
|
|
752
|
-
width: number;
|
|
753
|
-
height: number;
|
|
754
|
-
isVertical: boolean;
|
|
755
|
-
tooltip: HTMLDivElement;
|
|
756
|
-
solid: boolean;
|
|
757
|
-
textColor: string;
|
|
758
|
-
mutedColor: string;
|
|
759
|
-
bgColor: string;
|
|
760
|
-
bg: string;
|
|
761
|
-
swimlaneTagGroup: string | null;
|
|
762
|
-
groupColorMap: Map<string, string>;
|
|
763
|
-
tagLanes: Lane[] | null;
|
|
764
|
-
eventColor: (ev: TimelineEvent) => string;
|
|
765
|
-
minDate: number;
|
|
766
|
-
maxDate: number;
|
|
767
|
-
datePadding: number;
|
|
768
|
-
earliestStartDateStr: string;
|
|
769
|
-
latestEndDateStr: string;
|
|
770
|
-
tagLegendReserve: number;
|
|
771
|
-
ctx: ScaleContext;
|
|
772
|
-
};
|
|
773
|
-
|
|
774
|
-
/**
|
|
775
|
-
* Computes layout context (dimensions, colors, date domain, tag lanes,
|
|
776
|
-
* event-color resolver) for a timeline before the orientation-specific
|
|
777
|
-
* rendering branch runs. Returns null when there is nothing to render
|
|
778
|
-
* (empty events or zero-sized container).
|
|
779
|
-
*
|
|
780
|
-
* Side effects: clears the container and creates the tooltip element.
|
|
781
|
-
*/
|
|
782
|
-
function setupTimeline(
|
|
783
|
-
container: HTMLDivElement,
|
|
784
|
-
parsed: ParsedTimeline,
|
|
785
|
-
palette: PaletteColors,
|
|
786
|
-
isDark: boolean,
|
|
787
|
-
exportDims: D3ExportDimensions | undefined,
|
|
788
|
-
activeTagGroup: string | null | undefined,
|
|
789
|
-
swimlaneTagGroup: string | null | undefined
|
|
790
|
-
): TimelineSetup | null {
|
|
791
|
-
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
792
|
-
const solid = parsed.solidFill === true;
|
|
793
|
-
|
|
794
|
-
const {
|
|
795
|
-
timelineEvents,
|
|
796
|
-
timelineGroups,
|
|
797
|
-
timelineEras,
|
|
798
|
-
timelineMarkers,
|
|
799
|
-
timelineSort,
|
|
800
|
-
orientation,
|
|
801
|
-
} = parsed;
|
|
802
|
-
if (timelineEvents.length === 0) return null;
|
|
803
|
-
|
|
804
|
-
let resolvedSwimlaneTG: string | null = swimlaneTagGroup ?? null;
|
|
805
|
-
if (
|
|
806
|
-
resolvedSwimlaneTG == null &&
|
|
807
|
-
timelineSort === 'tag' &&
|
|
808
|
-
parsed.timelineDefaultSwimlaneTG
|
|
809
|
-
) {
|
|
810
|
-
resolvedSwimlaneTG = parsed.timelineDefaultSwimlaneTG;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const tooltip = createTooltip(container, palette, isDark);
|
|
814
|
-
|
|
815
|
-
const width = exportDims?.width ?? container.clientWidth;
|
|
816
|
-
const height = exportDims?.height ?? container.clientHeight;
|
|
817
|
-
if (width <= 0 || height <= 0) return null;
|
|
818
|
-
|
|
819
|
-
const isVertical = orientation === 'vertical';
|
|
820
|
-
|
|
821
|
-
const textColor = palette.text;
|
|
822
|
-
const mutedColor = palette.border;
|
|
823
|
-
const bgColor = palette.bg;
|
|
824
|
-
const bg = themeBaseBg(palette, isDark);
|
|
825
|
-
const colors = getSeriesColors(palette);
|
|
826
|
-
|
|
827
|
-
const groupColorMap = new Map<string, string>();
|
|
828
|
-
timelineGroups.forEach((grp, i) => {
|
|
829
|
-
// colors is non-empty; modulo guarantees in-bounds.
|
|
830
|
-
groupColorMap.set(grp.name, grp.color ?? colors[i % colors.length]!);
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
let tagLanes: Lane[] | null = null;
|
|
834
|
-
|
|
835
|
-
if (resolvedSwimlaneTG) {
|
|
836
|
-
const tagKey = tagAttrKey(resolvedSwimlaneTG);
|
|
837
|
-
const tagGroup = parsed.timelineTagGroups.find(
|
|
838
|
-
(g) => g.name.toLowerCase() === tagKey
|
|
839
|
-
);
|
|
840
|
-
if (tagGroup) {
|
|
841
|
-
const buckets = new Map<string, TimelineEvent[]>();
|
|
842
|
-
const otherEvents: TimelineEvent[] = [];
|
|
843
|
-
for (const ev of timelineEvents) {
|
|
844
|
-
const val = ev.metadata[tagKey];
|
|
845
|
-
if (val) {
|
|
846
|
-
const list = buckets.get(val) ?? [];
|
|
847
|
-
list.push(ev);
|
|
848
|
-
buckets.set(val, list);
|
|
849
|
-
} else {
|
|
850
|
-
otherEvents.push(ev);
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const laneEntries = [...buckets.entries()].sort((a, b) => {
|
|
855
|
-
const aMin = Math.min(...a[1].map((e) => parseTimelineDate(e.date)));
|
|
856
|
-
const bMin = Math.min(...b[1].map((e) => parseTimelineDate(e.date)));
|
|
857
|
-
return aMin - bMin;
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
tagLanes = laneEntries.map(([name, events]) => ({ name, events }));
|
|
861
|
-
if (otherEvents.length > 0) {
|
|
862
|
-
tagLanes.push({ name: '(Other)', events: otherEvents });
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
for (const entry of tagGroup.entries) {
|
|
866
|
-
groupColorMap.set(entry.value, entry.color);
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
const effectiveColorTG = activeTagGroup ?? resolvedSwimlaneTG ?? null;
|
|
872
|
-
|
|
873
|
-
function eventColor(ev: TimelineEvent): string {
|
|
874
|
-
if (effectiveColorTG) {
|
|
875
|
-
const tagColor = resolveTagColor(
|
|
876
|
-
ev.metadata,
|
|
877
|
-
parsed.timelineTagGroups,
|
|
878
|
-
effectiveColorTG
|
|
879
|
-
);
|
|
880
|
-
if (tagColor) return tagColor;
|
|
881
|
-
}
|
|
882
|
-
if (ev.group && groupColorMap.has(ev.group)) {
|
|
883
|
-
return groupColorMap.get(ev.group)!;
|
|
884
|
-
}
|
|
885
|
-
return textColor;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
let minDate = Infinity;
|
|
889
|
-
let maxDate = -Infinity;
|
|
890
|
-
let earliestStartDateStr = '';
|
|
891
|
-
let latestEndDateStr = '';
|
|
892
|
-
|
|
893
|
-
for (const ev of timelineEvents) {
|
|
894
|
-
const startNum = parseTimelineDate(ev.date);
|
|
895
|
-
const endNum = ev.endDate ? parseTimelineDate(ev.endDate) : startNum;
|
|
896
|
-
|
|
897
|
-
if (startNum < minDate) {
|
|
898
|
-
minDate = startNum;
|
|
899
|
-
earliestStartDateStr = ev.date;
|
|
900
|
-
}
|
|
901
|
-
if (endNum > maxDate) {
|
|
902
|
-
maxDate = endNum;
|
|
903
|
-
latestEndDateStr = ev.endDate ?? ev.date;
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
for (const era of timelineEras) {
|
|
908
|
-
const eraStartNum = parseTimelineDate(era.startDate);
|
|
909
|
-
const eraEndNum = parseTimelineDate(era.endDate);
|
|
910
|
-
if (Number.isFinite(eraStartNum) && eraStartNum < minDate) {
|
|
911
|
-
minDate = eraStartNum;
|
|
912
|
-
earliestStartDateStr = era.startDate;
|
|
913
|
-
}
|
|
914
|
-
if (Number.isFinite(eraEndNum) && eraEndNum > maxDate) {
|
|
915
|
-
maxDate = eraEndNum;
|
|
916
|
-
latestEndDateStr = era.endDate;
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
for (const marker of timelineMarkers) {
|
|
920
|
-
const markerNum = parseTimelineDate(marker.date);
|
|
921
|
-
if (!Number.isFinite(markerNum)) continue;
|
|
922
|
-
if (markerNum < minDate) {
|
|
923
|
-
minDate = markerNum;
|
|
924
|
-
earliestStartDateStr = marker.date;
|
|
925
|
-
}
|
|
926
|
-
if (markerNum > maxDate) {
|
|
927
|
-
maxDate = markerNum;
|
|
928
|
-
latestEndDateStr = marker.date;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
const datePadding = (maxDate - minDate) * 0.05 || 0.5;
|
|
932
|
-
|
|
933
|
-
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
934
|
-
|
|
935
|
-
const idealWidth = isVertical
|
|
936
|
-
? 500
|
|
937
|
-
: Math.max(600, timelineEvents.length * 40 + 200);
|
|
938
|
-
const ctx = exportDims
|
|
939
|
-
? ScaleContext.identity()
|
|
940
|
-
: ScaleContext.from(width, idealWidth);
|
|
941
|
-
|
|
942
|
-
return {
|
|
943
|
-
width,
|
|
944
|
-
height,
|
|
945
|
-
isVertical,
|
|
946
|
-
tooltip,
|
|
947
|
-
solid,
|
|
948
|
-
textColor,
|
|
949
|
-
mutedColor,
|
|
950
|
-
bgColor,
|
|
951
|
-
bg,
|
|
952
|
-
swimlaneTagGroup: resolvedSwimlaneTG,
|
|
953
|
-
groupColorMap,
|
|
954
|
-
tagLanes,
|
|
955
|
-
eventColor,
|
|
956
|
-
minDate,
|
|
957
|
-
maxDate,
|
|
958
|
-
datePadding,
|
|
959
|
-
earliestStartDateStr,
|
|
960
|
-
latestEndDateStr,
|
|
961
|
-
tagLegendReserve,
|
|
962
|
-
ctx,
|
|
963
|
-
};
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
// ============================================================
|
|
967
|
-
// Timeline — hover helpers (extracted from renderTimeline)
|
|
968
|
-
// ============================================================
|
|
969
|
-
|
|
970
|
-
type TimelineHoverHelpers = {
|
|
971
|
-
FADE_OPACITY: number;
|
|
972
|
-
fadeToGroup: (
|
|
973
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
974
|
-
groupName: string
|
|
975
|
-
) => void;
|
|
976
|
-
fadeToEra: (
|
|
977
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
978
|
-
eraStart: number,
|
|
979
|
-
eraEnd: number
|
|
980
|
-
) => void;
|
|
981
|
-
fadeToMarker: (
|
|
982
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
983
|
-
markerDate: number
|
|
984
|
-
) => void;
|
|
985
|
-
fadeReset: (
|
|
986
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
987
|
-
) => void;
|
|
988
|
-
fadeToTagValue: (
|
|
989
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
990
|
-
tagKey: string,
|
|
991
|
-
tagValue: string
|
|
992
|
-
) => void;
|
|
993
|
-
setTagAttrs: (
|
|
994
|
-
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
995
|
-
ev: TimelineEvent
|
|
996
|
-
) => void;
|
|
997
|
-
};
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Shared hover helpers for timeline rendering. Operate on CSS classes,
|
|
1001
|
-
* orientation-agnostic. Used by all three rendering branches.
|
|
1002
|
-
*/
|
|
1003
|
-
function makeTimelineHoverHelpers(): TimelineHoverHelpers {
|
|
1004
|
-
const FADE_OPACITY = 0.1;
|
|
1005
|
-
|
|
1006
|
-
function fadeToGroup(
|
|
1007
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1008
|
-
groupName: string
|
|
1009
|
-
) {
|
|
1010
|
-
g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
1011
|
-
const el = d3Selection.select(this);
|
|
1012
|
-
const evGroup = el.attr('data-group');
|
|
1013
|
-
el.attr('opacity', evGroup === groupName ? 1 : FADE_OPACITY);
|
|
1014
|
-
});
|
|
1015
|
-
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').each(
|
|
1016
|
-
function () {
|
|
1017
|
-
const el = d3Selection.select(this);
|
|
1018
|
-
const name = el.attr('data-group');
|
|
1019
|
-
el.attr('opacity', name === groupName ? 1 : FADE_OPACITY);
|
|
1020
|
-
}
|
|
1021
|
-
);
|
|
1022
|
-
g.selectAll<SVGGElement, unknown>('.tl-marker').attr(
|
|
1023
|
-
'opacity',
|
|
1024
|
-
FADE_OPACITY
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
function fadeToEra(
|
|
1029
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1030
|
-
eraStart: number,
|
|
1031
|
-
eraEnd: number
|
|
1032
|
-
) {
|
|
1033
|
-
g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
1034
|
-
const el = d3Selection.select(this);
|
|
1035
|
-
const date = parseFloat(el.attr('data-date')!);
|
|
1036
|
-
const endDate = el.attr('data-end-date');
|
|
1037
|
-
const evEnd = endDate ? parseFloat(endDate) : date;
|
|
1038
|
-
const inside = evEnd >= eraStart && date <= eraEnd;
|
|
1039
|
-
el.attr('opacity', inside ? 1 : FADE_OPACITY);
|
|
1040
|
-
});
|
|
1041
|
-
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
|
|
1042
|
-
'opacity',
|
|
1043
|
-
FADE_OPACITY
|
|
1044
|
-
);
|
|
1045
|
-
g.selectAll<SVGGElement, unknown>('.tl-era').each(function () {
|
|
1046
|
-
const el = d3Selection.select(this);
|
|
1047
|
-
const s = parseFloat(el.attr('data-era-start')!);
|
|
1048
|
-
const e = parseFloat(el.attr('data-era-end')!);
|
|
1049
|
-
const isSelf = s === eraStart && e === eraEnd;
|
|
1050
|
-
el.attr('opacity', isSelf ? 1 : FADE_OPACITY);
|
|
1051
|
-
});
|
|
1052
|
-
g.selectAll<SVGGElement, unknown>('.tl-marker').each(function () {
|
|
1053
|
-
const el = d3Selection.select(this);
|
|
1054
|
-
const date = parseFloat(el.attr('data-marker-date')!);
|
|
1055
|
-
const inside = date >= eraStart && date <= eraEnd;
|
|
1056
|
-
el.attr('opacity', inside ? 1 : FADE_OPACITY);
|
|
1057
|
-
});
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function fadeToMarker(
|
|
1061
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1062
|
-
markerDate: number
|
|
1063
|
-
) {
|
|
1064
|
-
g.selectAll<SVGGElement, unknown>('.tl-event').attr(
|
|
1065
|
-
'opacity',
|
|
1066
|
-
FADE_OPACITY
|
|
1067
|
-
);
|
|
1068
|
-
g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', FADE_OPACITY);
|
|
1069
|
-
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
|
|
1070
|
-
'opacity',
|
|
1071
|
-
FADE_OPACITY
|
|
1072
|
-
);
|
|
1073
|
-
g.selectAll<SVGGElement, unknown>('.tl-marker').each(function () {
|
|
1074
|
-
const el = d3Selection.select(this);
|
|
1075
|
-
const date = parseFloat(el.attr('data-marker-date')!);
|
|
1076
|
-
el.attr('opacity', date === markerDate ? 1 : FADE_OPACITY);
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
|
|
1080
|
-
function fadeReset(
|
|
1081
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>
|
|
1082
|
-
) {
|
|
1083
|
-
g.selectAll<SVGGElement, unknown>(
|
|
1084
|
-
'.tl-event, .tl-legend-item, .tl-lane-header, .tl-marker, .tl-tag-legend-entry'
|
|
1085
|
-
).attr('opacity', 1);
|
|
1086
|
-
g.selectAll<SVGGElement, unknown>('.tl-era').attr('opacity', 1);
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
function fadeToTagValue(
|
|
1090
|
-
g: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1091
|
-
tagKey: string,
|
|
1092
|
-
tagValue: string
|
|
1093
|
-
) {
|
|
1094
|
-
const attrName = `data-tag-${tagKey}`;
|
|
1095
|
-
g.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
1096
|
-
const el = d3Selection.select(this);
|
|
1097
|
-
const val = el.attr(attrName);
|
|
1098
|
-
el.attr('opacity', val === tagValue ? 1 : FADE_OPACITY);
|
|
1099
|
-
});
|
|
1100
|
-
g.selectAll<SVGGElement, unknown>('.tl-legend-item, .tl-lane-header').attr(
|
|
1101
|
-
'opacity',
|
|
1102
|
-
FADE_OPACITY
|
|
1103
|
-
);
|
|
1104
|
-
g.selectAll<SVGGElement, unknown>('.tl-marker').attr(
|
|
1105
|
-
'opacity',
|
|
1106
|
-
FADE_OPACITY
|
|
1107
|
-
);
|
|
1108
|
-
g.selectAll<SVGGElement, unknown>('.tl-tag-legend-entry').each(function () {
|
|
1109
|
-
const el = d3Selection.select(this);
|
|
1110
|
-
const entryValue = el.attr('data-legend-entry');
|
|
1111
|
-
if (entryValue === '__group__') return;
|
|
1112
|
-
const entryGroup = el.attr('data-tag-group');
|
|
1113
|
-
el.attr(
|
|
1114
|
-
'opacity',
|
|
1115
|
-
entryGroup === tagKey && entryValue === tagValue ? 1 : FADE_OPACITY
|
|
1116
|
-
);
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
/** Attach data-tag-* attributes on an event group element */
|
|
1121
|
-
function setTagAttrs(
|
|
1122
|
-
evG: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1123
|
-
ev: TimelineEvent
|
|
1124
|
-
) {
|
|
1125
|
-
for (const [key, value] of Object.entries(ev.metadata)) {
|
|
1126
|
-
evG.attr(`data-tag-${key}`, value.toLowerCase());
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
return {
|
|
1131
|
-
FADE_OPACITY,
|
|
1132
|
-
fadeToGroup,
|
|
1133
|
-
fadeToEra,
|
|
1134
|
-
fadeToMarker,
|
|
1135
|
-
fadeReset,
|
|
1136
|
-
fadeToTagValue,
|
|
1137
|
-
setTagAttrs,
|
|
1138
|
-
};
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// ============================================================
|
|
1142
|
-
// Timeline — tag-legend overlay (extracted from renderTimeline)
|
|
1143
|
-
// ============================================================
|
|
1144
|
-
|
|
1145
|
-
function renderTimelineTagLegendOverlay(
|
|
1146
|
-
container: HTMLDivElement,
|
|
1147
|
-
parsed: ParsedTimeline,
|
|
1148
|
-
palette: PaletteColors,
|
|
1149
|
-
isDark: boolean,
|
|
1150
|
-
setup: TimelineSetup,
|
|
1151
|
-
hovers: TimelineHoverHelpers,
|
|
1152
|
-
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
1153
|
-
exportDims: D3ExportDimensions | undefined,
|
|
1154
|
-
swimlaneTagGroup: string | null | undefined,
|
|
1155
|
-
activeTagGroup: string | null | undefined,
|
|
1156
|
-
onTagStateChange:
|
|
1157
|
-
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
1158
|
-
| undefined,
|
|
1159
|
-
viewMode: boolean | undefined,
|
|
1160
|
-
exportMode?: boolean
|
|
1161
|
-
): void {
|
|
1162
|
-
if (parsed.timelineTagGroups.length === 0) return;
|
|
1163
|
-
|
|
1164
|
-
const { width, textColor, groupColorMap, solid } = setup;
|
|
1165
|
-
const { FADE_OPACITY, fadeReset, fadeToTagValue } = hovers;
|
|
1166
|
-
const title = parsed.noTitle ? null : parsed.title;
|
|
1167
|
-
const { timelineEvents } = parsed;
|
|
1168
|
-
|
|
1169
|
-
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
1170
|
-
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
1171
|
-
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
1172
|
-
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
1173
|
-
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
1174
|
-
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
1175
|
-
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
1176
|
-
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
1177
|
-
// LG_GROUP_GAP no longer needed — centralized legend handles spacing
|
|
1178
|
-
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
|
|
1179
|
-
|
|
1180
|
-
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
1181
|
-
const mainG = mainSvg.select<SVGGElement>('g');
|
|
1182
|
-
if (!mainSvg.empty() && !mainG.empty()) {
|
|
1183
|
-
// Position legend at top, below title
|
|
1184
|
-
const legendY = title ? 50 : 10;
|
|
1185
|
-
|
|
1186
|
-
// Pre-compute group widths (minified and expanded)
|
|
1187
|
-
type LegendGroup = {
|
|
1188
|
-
group: TagGroup;
|
|
1189
|
-
minifiedWidth: number;
|
|
1190
|
-
expandedWidth: number;
|
|
1191
|
-
};
|
|
1192
|
-
const legendGroups: LegendGroup[] = parsed.timelineTagGroups.map((g) => {
|
|
1193
|
-
const pillW = measureLegendText(g.name, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
1194
|
-
// Expanded: pill + icon (unless viewMode) + entries
|
|
1195
|
-
const iconSpace = viewMode ? 8 : LG_ICON_W + 4;
|
|
1196
|
-
let entryX = LG_CAPSULE_PAD + pillW + iconSpace;
|
|
1197
|
-
for (const entry of g.entries) {
|
|
1198
|
-
const textX = entryX + LG_DOT_R * 2 + LG_ENTRY_DOT_GAP;
|
|
1199
|
-
entryX =
|
|
1200
|
-
textX +
|
|
1201
|
-
measureLegendText(entry.value, LG_ENTRY_FONT_SIZE) +
|
|
1202
|
-
LG_ENTRY_TRAIL;
|
|
1203
|
-
}
|
|
1204
|
-
return {
|
|
1205
|
-
group: g,
|
|
1206
|
-
minifiedWidth: pillW,
|
|
1207
|
-
expandedWidth: entryX + LG_CAPSULE_PAD,
|
|
1208
|
-
};
|
|
1209
|
-
});
|
|
1210
|
-
|
|
1211
|
-
// Two independent state axes: swimlane source + color source
|
|
1212
|
-
let currentActiveGroup: string | null = activeTagGroup ?? null;
|
|
1213
|
-
let currentSwimlaneGroup: string | null = swimlaneTagGroup ?? null;
|
|
1214
|
-
|
|
1215
|
-
/** Render the swimlane icon (3 horizontal bars of varying width) */
|
|
1216
|
-
function drawSwimlaneIcon(
|
|
1217
|
-
parent: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
1218
|
-
x: number,
|
|
1219
|
-
y: number,
|
|
1220
|
-
isSwimActive: boolean
|
|
1221
|
-
) {
|
|
1222
|
-
const iconG = parent
|
|
1223
|
-
.append('g')
|
|
1224
|
-
.attr('class', 'tl-swimlane-icon')
|
|
1225
|
-
.attr('transform', `translate(${x}, ${y})`)
|
|
1226
|
-
.style('cursor', 'pointer');
|
|
1227
|
-
|
|
1228
|
-
// Transparent hit area so the whole icon (not just the 2px bars) is clickable
|
|
1229
|
-
iconG
|
|
1230
|
-
.append('rect')
|
|
1231
|
-
.attr('x', -5)
|
|
1232
|
-
.attr('y', -5)
|
|
1233
|
-
.attr('width', 22)
|
|
1234
|
-
.attr('height', 20)
|
|
1235
|
-
.attr('fill', 'transparent');
|
|
1236
|
-
|
|
1237
|
-
const barColor = isSwimActive ? palette.primary : palette.textMuted;
|
|
1238
|
-
const barOpacity = isSwimActive ? 1 : 0.35;
|
|
1239
|
-
const bars = [
|
|
1240
|
-
{ y: 0, w: 8 },
|
|
1241
|
-
{ y: 4, w: 12 },
|
|
1242
|
-
{ y: 8, w: 6 },
|
|
1243
|
-
];
|
|
1244
|
-
for (const bar of bars) {
|
|
1245
|
-
iconG
|
|
1246
|
-
.append('rect')
|
|
1247
|
-
.attr('x', 0)
|
|
1248
|
-
.attr('y', bar.y)
|
|
1249
|
-
.attr('width', bar.w)
|
|
1250
|
-
.attr('height', 2)
|
|
1251
|
-
.attr('rx', 1)
|
|
1252
|
-
.attr('fill', barColor)
|
|
1253
|
-
.attr('opacity', barOpacity);
|
|
1254
|
-
}
|
|
1255
|
-
return iconG;
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
/** Full re-render with updated swimlane state */
|
|
1259
|
-
function relayout() {
|
|
1260
|
-
renderTimeline(
|
|
1261
|
-
container,
|
|
1262
|
-
parsed,
|
|
1263
|
-
palette,
|
|
1264
|
-
isDark,
|
|
1265
|
-
onClickItem,
|
|
1266
|
-
exportDims,
|
|
1267
|
-
currentActiveGroup,
|
|
1268
|
-
currentSwimlaneGroup,
|
|
1269
|
-
onTagStateChange,
|
|
1270
|
-
viewMode
|
|
1271
|
-
);
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
function drawLegend() {
|
|
1275
|
-
// Remove previous legend
|
|
1276
|
-
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
1277
|
-
mainSvg.selectAll('.tl-tag-legend-container').remove();
|
|
1278
|
-
|
|
1279
|
-
// Effective color source: explicit color group > swimlane group
|
|
1280
|
-
const effectiveColorKey =
|
|
1281
|
-
(currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
1282
|
-
|
|
1283
|
-
// In view mode, only show the color-driving tag group (expanded, non-interactive).
|
|
1284
|
-
// Skip the swimlane group if it's separate from the color group (lane headers already label it).
|
|
1285
|
-
const visibleGroups = viewMode
|
|
1286
|
-
? legendGroups.filter(
|
|
1287
|
-
(lg) =>
|
|
1288
|
-
effectiveColorKey != null &&
|
|
1289
|
-
lg.group.name.toLowerCase() === effectiveColorKey
|
|
1290
|
-
)
|
|
1291
|
-
: legendGroups;
|
|
1292
|
-
|
|
1293
|
-
if (visibleGroups.length === 0) return;
|
|
1294
|
-
|
|
1295
|
-
// Legend container for data-legend-active attribute
|
|
1296
|
-
const legendContainer = mainSvg
|
|
1297
|
-
.append('g')
|
|
1298
|
-
.attr('class', 'tl-tag-legend-container');
|
|
1299
|
-
if (currentActiveGroup) {
|
|
1300
|
-
legendContainer.attr(
|
|
1301
|
-
'data-legend-active',
|
|
1302
|
-
tagAttrKey(currentActiveGroup)
|
|
1303
|
-
);
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
// Render tag groups via centralized legend system
|
|
1307
|
-
const iconAddon = viewMode ? 0 : LG_ICON_W;
|
|
1308
|
-
const centralGroups = visibleGroups.map((lg) => ({
|
|
1309
|
-
name: lg.group.name,
|
|
1310
|
-
entries: lg.group.entries.map((e) => ({
|
|
1311
|
-
value: e.value,
|
|
1312
|
-
color: e.color,
|
|
1313
|
-
})),
|
|
1314
|
-
}));
|
|
1315
|
-
|
|
1316
|
-
// Determine effective active group for centralized renderer
|
|
1317
|
-
const centralActive = viewMode ? effectiveColorKey : currentActiveGroup;
|
|
1318
|
-
|
|
1319
|
-
const centralConfig: LegendConfig = {
|
|
1320
|
-
groups: centralGroups,
|
|
1321
|
-
position: { placement: 'top-center', titleRelation: 'below-title' },
|
|
1322
|
-
mode: exportMode ? 'export' : 'preview',
|
|
1323
|
-
capsulePillAddonWidth: iconAddon,
|
|
1324
|
-
};
|
|
1325
|
-
const centralCallbacks: LegendCallbacks = viewMode
|
|
1326
|
-
? {}
|
|
1327
|
-
: {
|
|
1328
|
-
onGroupToggle: (groupName) => {
|
|
1329
|
-
currentActiveGroup =
|
|
1330
|
-
currentActiveGroup === groupName.toLowerCase()
|
|
1331
|
-
? null
|
|
1332
|
-
: groupName.toLowerCase();
|
|
1333
|
-
drawLegend();
|
|
1334
|
-
recolorEvents();
|
|
1335
|
-
onTagStateChange?.(currentActiveGroup, currentSwimlaneGroup);
|
|
1336
|
-
},
|
|
1337
|
-
onEntryHover: (groupName, entryValue) => {
|
|
1338
|
-
const tagKey = tagAttrKey(groupName);
|
|
1339
|
-
if (entryValue) {
|
|
1340
|
-
const tagVal = entryValue.toLowerCase();
|
|
1341
|
-
fadeToTagValue(mainG, tagKey, tagVal);
|
|
1342
|
-
mainSvg
|
|
1343
|
-
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
1344
|
-
.each(function () {
|
|
1345
|
-
const el = d3Selection.select(this);
|
|
1346
|
-
const ev = el.attr('data-legend-entry');
|
|
1347
|
-
const eg =
|
|
1348
|
-
el.attr('data-tag-group') ??
|
|
1349
|
-
(el.node() as Element)
|
|
1350
|
-
?.closest?.('[data-tag-group]')
|
|
1351
|
-
?.getAttribute('data-tag-group');
|
|
1352
|
-
el.attr(
|
|
1353
|
-
'opacity',
|
|
1354
|
-
eg === tagKey && ev === tagVal ? 1 : FADE_OPACITY
|
|
1355
|
-
);
|
|
1356
|
-
});
|
|
1357
|
-
} else {
|
|
1358
|
-
fadeReset(mainG);
|
|
1359
|
-
mainSvg
|
|
1360
|
-
.selectAll<SVGGElement, unknown>('[data-legend-entry]')
|
|
1361
|
-
.attr('opacity', 1);
|
|
1362
|
-
}
|
|
1363
|
-
},
|
|
1364
|
-
onGroupRendered: (groupName, groupEl, isActive) => {
|
|
1365
|
-
const groupKey = tagAttrKey(groupName);
|
|
1366
|
-
groupEl.attr('data-tag-group', groupKey);
|
|
1367
|
-
if (isActive && !viewMode) {
|
|
1368
|
-
const isSwimActive =
|
|
1369
|
-
currentSwimlaneGroup?.toLowerCase() === groupKey;
|
|
1370
|
-
const pillWidth =
|
|
1371
|
-
measureLegendText(groupName, LG_PILL_FONT_SIZE) + LG_PILL_PAD;
|
|
1372
|
-
const pillXOff = LG_CAPSULE_PAD;
|
|
1373
|
-
const iconX = pillXOff + pillWidth + 5;
|
|
1374
|
-
const iconY = (LG_HEIGHT - 10) / 2;
|
|
1375
|
-
const iconEl = drawSwimlaneIcon(
|
|
1376
|
-
groupEl,
|
|
1377
|
-
iconX,
|
|
1378
|
-
iconY,
|
|
1379
|
-
isSwimActive
|
|
1380
|
-
);
|
|
1381
|
-
iconEl
|
|
1382
|
-
.attr('data-swimlane-toggle', groupKey)
|
|
1383
|
-
.on('click', (event: MouseEvent) => {
|
|
1384
|
-
event.stopPropagation();
|
|
1385
|
-
currentSwimlaneGroup =
|
|
1386
|
-
currentSwimlaneGroup === groupKey ? null : groupKey;
|
|
1387
|
-
onTagStateChange?.(
|
|
1388
|
-
currentActiveGroup,
|
|
1389
|
-
currentSwimlaneGroup
|
|
1390
|
-
);
|
|
1391
|
-
relayout();
|
|
1392
|
-
});
|
|
1393
|
-
}
|
|
1394
|
-
},
|
|
1395
|
-
};
|
|
1396
|
-
|
|
1397
|
-
const legendInnerG = legendContainer
|
|
1398
|
-
.append('g')
|
|
1399
|
-
.attr('transform', `translate(0, ${legendY})`);
|
|
1400
|
-
renderIntegratedLegend(legendInnerG, {
|
|
1401
|
-
...centralConfig,
|
|
1402
|
-
palette,
|
|
1403
|
-
isDark,
|
|
1404
|
-
width,
|
|
1405
|
-
activeGroup: centralActive,
|
|
1406
|
-
callbacks: centralCallbacks,
|
|
1407
|
-
});
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// Build a quick lineNumber→event lookup
|
|
1411
|
-
const eventByLine = new Map<string, TimelineEvent>();
|
|
1412
|
-
for (const ev of timelineEvents) {
|
|
1413
|
-
eventByLine.set(String(ev.lineNumber), ev);
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
function recolorEvents() {
|
|
1417
|
-
const colorTG = currentActiveGroup ?? swimlaneTagGroup ?? null;
|
|
1418
|
-
mainG.selectAll<SVGGElement, unknown>('.tl-event').each(function () {
|
|
1419
|
-
const el = d3Selection.select(this);
|
|
1420
|
-
const lineNum = el.attr('data-line-number');
|
|
1421
|
-
const ev = lineNum ? eventByLine.get(lineNum) : undefined;
|
|
1422
|
-
if (!ev) return;
|
|
1423
|
-
|
|
1424
|
-
let color: string;
|
|
1425
|
-
if (colorTG) {
|
|
1426
|
-
const tagColor = resolveTagColor(
|
|
1427
|
-
ev.metadata,
|
|
1428
|
-
parsed.timelineTagGroups,
|
|
1429
|
-
colorTG
|
|
1430
|
-
);
|
|
1431
|
-
color =
|
|
1432
|
-
tagColor ??
|
|
1433
|
-
(ev.group && groupColorMap.has(ev.group)
|
|
1434
|
-
? groupColorMap.get(ev.group)!
|
|
1435
|
-
: textColor);
|
|
1436
|
-
} else {
|
|
1437
|
-
color =
|
|
1438
|
-
ev.group && groupColorMap.has(ev.group)
|
|
1439
|
-
? groupColorMap.get(ev.group)!
|
|
1440
|
-
: textColor;
|
|
1441
|
-
}
|
|
1442
|
-
el.selectAll('rect')
|
|
1443
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
1444
|
-
.attr('stroke', color);
|
|
1445
|
-
el.selectAll('circle:not(.tl-event-point-outline)')
|
|
1446
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
1447
|
-
.attr('stroke', color);
|
|
1448
|
-
});
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
drawLegend();
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
// ============================================================
|
|
1456
|
-
// Timeline — horizontal-time-sort renderer (extracted from renderTimeline)
|
|
1457
|
-
// ============================================================
|
|
1458
|
-
|
|
1459
|
-
function renderTimelineHorizontalTimeSort(
|
|
1460
|
-
container: HTMLDivElement,
|
|
1461
|
-
parsed: ParsedTimeline,
|
|
1462
|
-
palette: PaletteColors,
|
|
1463
|
-
isDark: boolean,
|
|
1464
|
-
setup: TimelineSetup,
|
|
1465
|
-
hovers: TimelineHoverHelpers,
|
|
1466
|
-
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
1467
|
-
exportDims: D3ExportDimensions | undefined,
|
|
1468
|
-
_swimlaneTagGroup: string | null | undefined,
|
|
1469
|
-
_activeTagGroup: string | null | undefined,
|
|
1470
|
-
_onTagStateChange:
|
|
1471
|
-
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
1472
|
-
| undefined,
|
|
1473
|
-
_viewMode: boolean | undefined
|
|
1474
|
-
): void {
|
|
1475
|
-
const {
|
|
1476
|
-
width,
|
|
1477
|
-
height,
|
|
1478
|
-
tooltip,
|
|
1479
|
-
solid,
|
|
1480
|
-
textColor,
|
|
1481
|
-
bgColor,
|
|
1482
|
-
bg,
|
|
1483
|
-
groupColorMap,
|
|
1484
|
-
eventColor,
|
|
1485
|
-
minDate,
|
|
1486
|
-
maxDate,
|
|
1487
|
-
datePadding,
|
|
1488
|
-
earliestStartDateStr,
|
|
1489
|
-
latestEndDateStr,
|
|
1490
|
-
tagLegendReserve,
|
|
1491
|
-
ctx,
|
|
1492
|
-
} = setup;
|
|
1493
|
-
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
1494
|
-
hovers;
|
|
1495
|
-
const {
|
|
1496
|
-
timelineEvents,
|
|
1497
|
-
timelineGroups,
|
|
1498
|
-
timelineEras,
|
|
1499
|
-
timelineMarkers,
|
|
1500
|
-
timelineScale,
|
|
1501
|
-
} = parsed;
|
|
1502
|
-
const title = parsed.noTitle ? null : parsed.title;
|
|
1503
|
-
|
|
1504
|
-
const sBarH = ctx.structural(22);
|
|
1505
|
-
const sPointR = ctx.structural(5);
|
|
1506
|
-
const sPointStroke = ctx.structural(2);
|
|
1507
|
-
const sBarRx = ctx.structural(4);
|
|
1508
|
-
const sBarStroke = ctx.structural(2);
|
|
1509
|
-
const sEventFont = ctx.text(13);
|
|
1510
|
-
const sEventFontSm = ctx.text(12);
|
|
1511
|
-
const sCharW = ctx.structural(7);
|
|
1512
|
-
|
|
1513
|
-
// === TIME SORT, horizontal: each event on its own row ===
|
|
1514
|
-
const sorted = timelineEvents
|
|
1515
|
-
.slice()
|
|
1516
|
-
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
1517
|
-
|
|
1518
|
-
const scaleMargin = timelineScale ? ctx.aesthetic(24) : 0;
|
|
1519
|
-
const ERA_ROW_H = ctx.structural(22);
|
|
1520
|
-
const MARKER_ROW_H = ctx.structural(22);
|
|
1521
|
-
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
1522
|
-
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
1523
|
-
const topScaleH = timelineScale ? ctx.structural(40) : 0;
|
|
1524
|
-
const margin = {
|
|
1525
|
-
top:
|
|
1526
|
-
ctx.aesthetic(104) +
|
|
1527
|
-
topScaleH +
|
|
1528
|
-
eraReserve +
|
|
1529
|
-
markerReserve +
|
|
1530
|
-
tagLegendReserve,
|
|
1531
|
-
right: ctx.aesthetic(40),
|
|
1532
|
-
bottom: ctx.aesthetic(40) + scaleMargin,
|
|
1533
|
-
left: ctx.aesthetic(60),
|
|
1534
|
-
};
|
|
1535
|
-
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
1536
|
-
const eraLabelY = eraReserve
|
|
1537
|
-
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
1538
|
-
: 0;
|
|
1539
|
-
const innerWidth = width - margin.left - margin.right;
|
|
1540
|
-
// Each event gets a fixed comfortable row. The old behaviour compressed rowH
|
|
1541
|
-
// to fit the container height (`min(28, avail / n)`), but that only ever
|
|
1542
|
-
// shrank rows BELOW the 22px bar height — cramming events into overlap when
|
|
1543
|
-
// the host surface was shorter than the content required (e.g. the app's
|
|
1544
|
-
// fixed-height embedded-diagram surface). A constant rowH never overlaps:
|
|
1545
|
-
// when the container is taller than needed the SVG shrinks to the content
|
|
1546
|
-
// (top-aligned via preserveAspectRatio); when shorter, the SVG grows past it
|
|
1547
|
-
// and the host collapses/expands to the rendered height. This also makes the
|
|
1548
|
-
// interactive preview match the exported image, which already used rowH=28.
|
|
1549
|
-
const rowH = ctx.structural(28);
|
|
1550
|
-
// Draw the era bands and time axis to the content height (not the full
|
|
1551
|
-
// container) so the axis sits just below the last event.
|
|
1552
|
-
const innerHeight = rowH * sorted.length;
|
|
1553
|
-
const usedHeight = margin.top + innerHeight + margin.bottom;
|
|
1554
|
-
|
|
1555
|
-
// On-screen (non-export) the content can be taller than the host pane. Rather
|
|
1556
|
-
// than overflow it (forcing the user to scroll), scale the whole SVG down to
|
|
1557
|
-
// fit the available height: the viewBox keeps the natural content geometry
|
|
1558
|
-
// while the rendered height is clamped to the container, and
|
|
1559
|
-
// preserveAspectRatio uniformly shrinks + centers it. Export keeps the full
|
|
1560
|
-
// natural height so the image is never compressed.
|
|
1561
|
-
const fitToContainer = !exportDims && height > 0 && usedHeight > height;
|
|
1562
|
-
const svgHeight = fitToContainer ? height : usedHeight;
|
|
1563
|
-
|
|
1564
|
-
const xScale = d3Scale
|
|
1565
|
-
.scaleLinear()
|
|
1566
|
-
.domain([minDate - datePadding, maxDate + datePadding])
|
|
1567
|
-
.range([0, innerWidth]);
|
|
1568
|
-
|
|
1569
|
-
const svg = d3Selection
|
|
1570
|
-
.select(container)
|
|
1571
|
-
.append('svg')
|
|
1572
|
-
.attr('width', width)
|
|
1573
|
-
.attr('height', svgHeight)
|
|
1574
|
-
.attr('viewBox', `0 0 ${width} ${usedHeight}`)
|
|
1575
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
1576
|
-
.style('background', bgColor);
|
|
1577
|
-
|
|
1578
|
-
if (ctx.isBelowFloor && !fitToContainer) {
|
|
1579
|
-
svg.attr('width', '100%');
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
const g = svg
|
|
1583
|
-
.append('g')
|
|
1584
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
1585
|
-
|
|
1586
|
-
renderChartTitle(
|
|
1587
|
-
svg,
|
|
1588
|
-
title,
|
|
1589
|
-
parsed.titleLineNumber,
|
|
1590
|
-
width,
|
|
1591
|
-
textColor,
|
|
1592
|
-
onClickItem
|
|
1593
|
-
);
|
|
1594
|
-
|
|
1595
|
-
renderEras(
|
|
1596
|
-
g,
|
|
1597
|
-
timelineEras,
|
|
1598
|
-
xScale,
|
|
1599
|
-
false,
|
|
1600
|
-
innerWidth,
|
|
1601
|
-
innerHeight,
|
|
1602
|
-
(s, e) => fadeToEra(g, s, e),
|
|
1603
|
-
() => fadeReset(g),
|
|
1604
|
-
timelineScale,
|
|
1605
|
-
tooltip,
|
|
1606
|
-
palette,
|
|
1607
|
-
eraReserve ? eraLabelY : undefined
|
|
1608
|
-
);
|
|
1609
|
-
|
|
1610
|
-
renderMarkers(
|
|
1611
|
-
g,
|
|
1612
|
-
timelineMarkers,
|
|
1613
|
-
xScale,
|
|
1614
|
-
false,
|
|
1615
|
-
innerWidth,
|
|
1616
|
-
innerHeight,
|
|
1617
|
-
(d) => fadeToMarker(g, d),
|
|
1618
|
-
() => fadeReset(g),
|
|
1619
|
-
timelineScale,
|
|
1620
|
-
tooltip,
|
|
1621
|
-
palette,
|
|
1622
|
-
markerReserve ? markerLabelY : undefined
|
|
1623
|
-
);
|
|
1624
|
-
|
|
1625
|
-
if (timelineScale) {
|
|
1626
|
-
renderTimeScale(
|
|
1627
|
-
g,
|
|
1628
|
-
xScale,
|
|
1629
|
-
false,
|
|
1630
|
-
innerWidth,
|
|
1631
|
-
innerHeight,
|
|
1632
|
-
textColor,
|
|
1633
|
-
minDate,
|
|
1634
|
-
maxDate,
|
|
1635
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
1636
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
1637
|
-
);
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
// Group legend at top-left (pill style)
|
|
1641
|
-
if (timelineGroups.length > 0) {
|
|
1642
|
-
const legendY = timelineScale ? -ctx.aesthetic(75) : -ctx.aesthetic(55);
|
|
1643
|
-
renderTimelineGroupLegend(
|
|
1644
|
-
g,
|
|
1645
|
-
timelineGroups,
|
|
1646
|
-
groupColorMap,
|
|
1647
|
-
textColor,
|
|
1648
|
-
palette,
|
|
1649
|
-
isDark,
|
|
1650
|
-
legendY,
|
|
1651
|
-
(name) => fadeToGroup(g, name),
|
|
1652
|
-
() => fadeReset(g)
|
|
1653
|
-
);
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
sorted.forEach((ev, i) => {
|
|
1657
|
-
// Marker labels live in their reserved row above the chart, so the
|
|
1658
|
-
// first event sits at the chart top edge.
|
|
1659
|
-
const y = i * rowH + rowH / 2;
|
|
1660
|
-
const x = xScale(parseTimelineDate(ev.date));
|
|
1661
|
-
const color = eventColor(ev);
|
|
1662
|
-
|
|
1663
|
-
const evG = g
|
|
1664
|
-
.append('g')
|
|
1665
|
-
.attr('class', 'tl-event')
|
|
1666
|
-
.attr('data-group', ev.group || '')
|
|
1667
|
-
.attr('data-line-number', String(ev.lineNumber))
|
|
1668
|
-
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
1669
|
-
.attr(
|
|
1670
|
-
'data-end-date',
|
|
1671
|
-
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
1672
|
-
)
|
|
1673
|
-
.style('cursor', 'pointer')
|
|
1674
|
-
.on('mouseenter', function (event: MouseEvent) {
|
|
1675
|
-
if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
|
|
1676
|
-
if (timelineScale) {
|
|
1677
|
-
showEventDatesOnScale(
|
|
1678
|
-
g,
|
|
1679
|
-
xScale,
|
|
1680
|
-
ev.date,
|
|
1681
|
-
ev.endDate,
|
|
1682
|
-
innerHeight,
|
|
1683
|
-
color
|
|
1684
|
-
);
|
|
1685
|
-
} else {
|
|
1686
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
1687
|
-
}
|
|
1688
|
-
})
|
|
1689
|
-
.on('mouseleave', function () {
|
|
1690
|
-
fadeReset(g);
|
|
1691
|
-
if (timelineScale) {
|
|
1692
|
-
hideEventDatesOnScale(g);
|
|
1693
|
-
} else {
|
|
1694
|
-
hideTooltip(tooltip);
|
|
1695
|
-
}
|
|
1696
|
-
})
|
|
1697
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
1698
|
-
if (!timelineScale) {
|
|
1699
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
1700
|
-
}
|
|
1701
|
-
})
|
|
1702
|
-
.on('click', () => {
|
|
1703
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
1704
|
-
});
|
|
1705
|
-
setTagAttrs(evG, ev);
|
|
1706
|
-
|
|
1707
|
-
if (ev.endDate) {
|
|
1708
|
-
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
1709
|
-
const rectW = Math.max(x2 - x, 4);
|
|
1710
|
-
const estLabelWidth = ev.label.length * sCharW + ctx.aesthetic(16);
|
|
1711
|
-
const labelFitsInside = rectW >= estLabelWidth;
|
|
1712
|
-
|
|
1713
|
-
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
1714
|
-
let stroke: string = color;
|
|
1715
|
-
if (ev.uncertain) {
|
|
1716
|
-
const gradientId = `uncertain-ts-${ev.lineNumber}`;
|
|
1717
|
-
const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
|
|
1718
|
-
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
1719
|
-
const defsEl = d3Selection.select(defs as Element);
|
|
1720
|
-
defsEl
|
|
1721
|
-
.append('linearGradient')
|
|
1722
|
-
.attr('id', gradientId)
|
|
1723
|
-
.attr('x1', '0%')
|
|
1724
|
-
.attr('y1', '0%')
|
|
1725
|
-
.attr('x2', '100%')
|
|
1726
|
-
.attr('y2', '0%')
|
|
1727
|
-
.selectAll('stop')
|
|
1728
|
-
.data([
|
|
1729
|
-
{ offset: '0%', opacity: 1 },
|
|
1730
|
-
{ offset: '80%', opacity: 1 },
|
|
1731
|
-
{ offset: '100%', opacity: 0 },
|
|
1732
|
-
])
|
|
1733
|
-
.enter()
|
|
1734
|
-
.append('stop')
|
|
1735
|
-
.attr('offset', (d) => d.offset)
|
|
1736
|
-
.attr('stop-color', mix(color, bg, 30))
|
|
1737
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
1738
|
-
defsEl
|
|
1739
|
-
.append('linearGradient')
|
|
1740
|
-
.attr('id', strokeGradientId)
|
|
1741
|
-
.attr('x1', '0%')
|
|
1742
|
-
.attr('y1', '0%')
|
|
1743
|
-
.attr('x2', '100%')
|
|
1744
|
-
.attr('y2', '0%')
|
|
1745
|
-
.selectAll('stop')
|
|
1746
|
-
.data([
|
|
1747
|
-
{ offset: '0%', opacity: 1 },
|
|
1748
|
-
{ offset: '80%', opacity: 1 },
|
|
1749
|
-
{ offset: '100%', opacity: 0 },
|
|
1750
|
-
])
|
|
1751
|
-
.enter()
|
|
1752
|
-
.append('stop')
|
|
1753
|
-
.attr('offset', (d) => d.offset)
|
|
1754
|
-
.attr('stop-color', color)
|
|
1755
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
1756
|
-
fill = `url(#${gradientId})`;
|
|
1757
|
-
stroke = `url(#${strokeGradientId})`;
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
evG
|
|
1761
|
-
.append('rect')
|
|
1762
|
-
.attr('x', x)
|
|
1763
|
-
.attr('y', y - sBarH / 2)
|
|
1764
|
-
.attr('width', rectW)
|
|
1765
|
-
.attr('height', sBarH)
|
|
1766
|
-
.attr('rx', sBarRx)
|
|
1767
|
-
.attr('fill', fill)
|
|
1768
|
-
.attr('stroke', stroke)
|
|
1769
|
-
.attr('stroke-width', sBarStroke);
|
|
1770
|
-
|
|
1771
|
-
if (labelFitsInside) {
|
|
1772
|
-
evG
|
|
1773
|
-
.append('text')
|
|
1774
|
-
.attr('x', x + ctx.aesthetic(8))
|
|
1775
|
-
.attr('y', y)
|
|
1776
|
-
.attr('dy', '0.35em')
|
|
1777
|
-
.attr('text-anchor', 'start')
|
|
1778
|
-
.attr('fill', textColor)
|
|
1779
|
-
.attr('font-size', `${sEventFont}px`)
|
|
1780
|
-
.text(ev.label);
|
|
1781
|
-
} else {
|
|
1782
|
-
const sLabelGap = ctx.aesthetic(6);
|
|
1783
|
-
const wouldFlipLeft = x + rectW > innerWidth * 0.6;
|
|
1784
|
-
const labelFitsLeft = x - sLabelGap - estLabelWidth > 0;
|
|
1785
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
1786
|
-
evG
|
|
1787
|
-
.append('text')
|
|
1788
|
-
.attr('x', flipLeft ? x - sLabelGap : x + rectW + sLabelGap)
|
|
1789
|
-
.attr('y', y)
|
|
1790
|
-
.attr('dy', '0.35em')
|
|
1791
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
1792
|
-
.attr('fill', textColor)
|
|
1793
|
-
.attr('font-size', `${sEventFont}px`)
|
|
1794
|
-
.text(ev.label);
|
|
1795
|
-
}
|
|
1796
|
-
} else {
|
|
1797
|
-
const estLabelWidth = ev.label.length * sCharW;
|
|
1798
|
-
const sPointGap = ctx.aesthetic(10);
|
|
1799
|
-
const wouldFlipLeft = x > innerWidth * 0.6;
|
|
1800
|
-
const labelFitsLeft = x - sPointGap - estLabelWidth > 0;
|
|
1801
|
-
const flipLeft = wouldFlipLeft && labelFitsLeft;
|
|
1802
|
-
evG
|
|
1803
|
-
.append('circle')
|
|
1804
|
-
.attr('cx', x)
|
|
1805
|
-
.attr('cy', y)
|
|
1806
|
-
.attr('r', sPointR)
|
|
1807
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
1808
|
-
.attr('stroke', color)
|
|
1809
|
-
.attr('stroke-width', sPointStroke);
|
|
1810
|
-
evG
|
|
1811
|
-
.append('text')
|
|
1812
|
-
.attr('x', flipLeft ? x - sPointGap : x + sPointGap)
|
|
1813
|
-
.attr('y', y)
|
|
1814
|
-
.attr('dy', '0.35em')
|
|
1815
|
-
.attr('text-anchor', flipLeft ? 'end' : 'start')
|
|
1816
|
-
.attr('fill', textColor)
|
|
1817
|
-
.attr('font-size', `${sEventFontSm}px`)
|
|
1818
|
-
.text(ev.label);
|
|
1819
|
-
}
|
|
1820
|
-
});
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
// ============================================================
|
|
1824
|
-
// Timeline — horizontal-grouped renderer (extracted from renderTimeline)
|
|
1825
|
-
// ============================================================
|
|
1826
|
-
|
|
1827
|
-
function renderTimelineHorizontalGrouped(
|
|
1828
|
-
container: HTMLDivElement,
|
|
1829
|
-
parsed: ParsedTimeline,
|
|
1830
|
-
palette: PaletteColors,
|
|
1831
|
-
isDark: boolean,
|
|
1832
|
-
setup: TimelineSetup,
|
|
1833
|
-
hovers: TimelineHoverHelpers,
|
|
1834
|
-
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
1835
|
-
exportDims: D3ExportDimensions | undefined,
|
|
1836
|
-
_swimlaneTagGroup: string | null | undefined,
|
|
1837
|
-
_activeTagGroup: string | null | undefined,
|
|
1838
|
-
_onTagStateChange:
|
|
1839
|
-
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
1840
|
-
| undefined,
|
|
1841
|
-
_viewMode: boolean | undefined,
|
|
1842
|
-
collapsedGroups: Set<string>,
|
|
1843
|
-
toggleGroup: (name: string) => void
|
|
1844
|
-
): void {
|
|
1845
|
-
const {
|
|
1846
|
-
width,
|
|
1847
|
-
height,
|
|
1848
|
-
tooltip,
|
|
1849
|
-
solid,
|
|
1850
|
-
textColor,
|
|
1851
|
-
bgColor,
|
|
1852
|
-
bg,
|
|
1853
|
-
groupColorMap,
|
|
1854
|
-
tagLanes,
|
|
1855
|
-
eventColor,
|
|
1856
|
-
minDate,
|
|
1857
|
-
maxDate,
|
|
1858
|
-
datePadding,
|
|
1859
|
-
earliestStartDateStr,
|
|
1860
|
-
latestEndDateStr,
|
|
1861
|
-
tagLegendReserve,
|
|
1862
|
-
ctx,
|
|
1863
|
-
} = setup;
|
|
1864
|
-
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
1865
|
-
hovers;
|
|
1866
|
-
const {
|
|
1867
|
-
timelineEvents,
|
|
1868
|
-
timelineGroups,
|
|
1869
|
-
timelineEras,
|
|
1870
|
-
timelineMarkers,
|
|
1871
|
-
timelineScale,
|
|
1872
|
-
} = parsed;
|
|
1873
|
-
const title = parsed.noTitle ? null : parsed.title;
|
|
1874
|
-
|
|
1875
|
-
const sBarH = ctx.structural(22);
|
|
1876
|
-
const sGroupGap = ctx.aesthetic(12);
|
|
1877
|
-
const sPointR = ctx.structural(5);
|
|
1878
|
-
const sPointStroke = ctx.structural(2);
|
|
1879
|
-
const sBarRx = ctx.structural(4);
|
|
1880
|
-
const sBarStroke = ctx.structural(2);
|
|
1881
|
-
const sEventFont = ctx.text(13);
|
|
1882
|
-
const sCharW = ctx.structural(7);
|
|
1883
|
-
const sLaneHeaderFont = ctx.text(12);
|
|
1884
|
-
|
|
1885
|
-
// === GROUPED: swim-lanes stacked vertically, events on own rows ===
|
|
1886
|
-
let lanes: Lane[];
|
|
1887
|
-
|
|
1888
|
-
if (tagLanes) {
|
|
1889
|
-
lanes = tagLanes;
|
|
1890
|
-
} else {
|
|
1891
|
-
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
1892
|
-
const ungroupedEvents = timelineEvents.filter(
|
|
1893
|
-
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
1894
|
-
);
|
|
1895
|
-
const laneNames =
|
|
1896
|
-
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
1897
|
-
lanes = laneNames.map((name) => ({
|
|
1898
|
-
name,
|
|
1899
|
-
events: timelineEvents.filter((ev) =>
|
|
1900
|
-
name === '(Other)'
|
|
1901
|
-
? ev.group === null || !groupNames.includes(ev.group)
|
|
1902
|
-
: ev.group === name
|
|
1903
|
-
),
|
|
1904
|
-
}));
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
const totalRows = lanes.reduce((s, l) => {
|
|
1908
|
-
if (collapsedGroups.has(l.name)) return s + 1;
|
|
1909
|
-
return s + l.events.length + 1;
|
|
1910
|
-
}, 0);
|
|
1911
|
-
const scaleMargin = timelineScale ? ctx.aesthetic(24) : 0;
|
|
1912
|
-
// Per-feature header rows: era + marker each get their own row, reserved
|
|
1913
|
-
// only when present (mirrors the gantt header stack).
|
|
1914
|
-
const ERA_ROW_H = ctx.structural(22);
|
|
1915
|
-
const MARKER_ROW_H = ctx.structural(22);
|
|
1916
|
-
const eraReserve = timelineEras.length > 0 ? ERA_ROW_H : 0;
|
|
1917
|
-
const markerReserve = timelineMarkers.length > 0 ? MARKER_ROW_H : 0;
|
|
1918
|
-
const topScaleH = timelineScale ? ctx.structural(40) : 0;
|
|
1919
|
-
const maxGroupNameLen = Math.max(...lanes.map((l) => l.name.length)) + 2;
|
|
1920
|
-
const maxEventLabelLen = Math.max(
|
|
1921
|
-
0,
|
|
1922
|
-
...lanes.flatMap((l) => l.events.map((ev) => ev.label.length + 2))
|
|
1923
|
-
);
|
|
1924
|
-
const maxLeftLabelLen = Math.max(maxGroupNameLen, maxEventLabelLen);
|
|
1925
|
-
const dynamicLeftMargin = Math.max(
|
|
1926
|
-
ctx.aesthetic(140),
|
|
1927
|
-
maxLeftLabelLen * sCharW + ctx.aesthetic(30)
|
|
1928
|
-
);
|
|
1929
|
-
const baseTopMargin = title ? ctx.aesthetic(50) : ctx.aesthetic(20);
|
|
1930
|
-
const margin = {
|
|
1931
|
-
top:
|
|
1932
|
-
baseTopMargin + topScaleH + eraReserve + markerReserve + tagLegendReserve,
|
|
1933
|
-
right: ctx.aesthetic(40),
|
|
1934
|
-
bottom: ctx.aesthetic(40) + scaleMargin,
|
|
1935
|
-
left: dynamicLeftMargin,
|
|
1936
|
-
};
|
|
1937
|
-
const markerLabelY = markerReserve ? -(topScaleH + MARKER_ROW_H / 2) : 0;
|
|
1938
|
-
const eraLabelY = eraReserve
|
|
1939
|
-
? -(topScaleH + markerReserve + ERA_ROW_H / 2)
|
|
1940
|
-
: 0;
|
|
1941
|
-
const innerWidth = width - margin.left - margin.right;
|
|
1942
|
-
const totalGaps = (lanes.length - 1) * sGroupGap;
|
|
1943
|
-
// Fixed comfortable row height — never compress below the 22px bar height.
|
|
1944
|
-
// The old `min(28, avail/totalRows)` shrank rows to fit the container, which
|
|
1945
|
-
// crammed lanes into vertical overlap on short panes (the app's fixed-height
|
|
1946
|
-
// embedded surface). Instead the SVG scales to fit via viewBox +
|
|
1947
|
-
// preserveAspectRatio below, uniformly shrinking bars AND gaps together so
|
|
1948
|
-
// they never collide — mirroring the time-sort path.
|
|
1949
|
-
const rowH = ctx.structural(28);
|
|
1950
|
-
const innerHeight = rowH * totalRows + totalGaps;
|
|
1951
|
-
const usedHeight = margin.top + innerHeight + margin.bottom;
|
|
1952
|
-
|
|
1953
|
-
// On-screen (non-export) the content can be taller than the host pane. Rather
|
|
1954
|
-
// than overflow it, scale the whole SVG down to fit: the viewBox keeps the
|
|
1955
|
-
// natural content geometry while the rendered height is clamped to the
|
|
1956
|
-
// container, and preserveAspectRatio uniformly shrinks + centers it. Export
|
|
1957
|
-
// keeps the full natural height so the image is never compressed.
|
|
1958
|
-
const fitToContainer = !exportDims && height > 0 && usedHeight > height;
|
|
1959
|
-
const svgHeight = fitToContainer ? height : usedHeight;
|
|
1960
|
-
|
|
1961
|
-
const xScale = d3Scale
|
|
1962
|
-
.scaleLinear()
|
|
1963
|
-
.domain([minDate - datePadding, maxDate + datePadding])
|
|
1964
|
-
.range([0, innerWidth]);
|
|
1965
|
-
|
|
1966
|
-
const svg = d3Selection
|
|
1967
|
-
.select(container)
|
|
1968
|
-
.append('svg')
|
|
1969
|
-
.attr('width', width)
|
|
1970
|
-
.attr('height', svgHeight)
|
|
1971
|
-
.attr('viewBox', `0 0 ${width} ${usedHeight}`)
|
|
1972
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
1973
|
-
.style('background', bgColor);
|
|
1974
|
-
|
|
1975
|
-
if (ctx.isBelowFloor && !fitToContainer) {
|
|
1976
|
-
svg.attr('width', '100%');
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
const g = svg
|
|
1980
|
-
.append('g')
|
|
1981
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
1982
|
-
|
|
1983
|
-
renderChartTitle(
|
|
1984
|
-
svg,
|
|
1985
|
-
title,
|
|
1986
|
-
parsed.titleLineNumber,
|
|
1987
|
-
width,
|
|
1988
|
-
textColor,
|
|
1989
|
-
onClickItem
|
|
1990
|
-
);
|
|
1991
|
-
|
|
1992
|
-
renderEras(
|
|
1993
|
-
g,
|
|
1994
|
-
timelineEras,
|
|
1995
|
-
xScale,
|
|
1996
|
-
false,
|
|
1997
|
-
innerWidth,
|
|
1998
|
-
innerHeight,
|
|
1999
|
-
(s, e) => fadeToEra(g, s, e),
|
|
2000
|
-
() => fadeReset(g),
|
|
2001
|
-
timelineScale,
|
|
2002
|
-
tooltip,
|
|
2003
|
-
palette,
|
|
2004
|
-
eraReserve ? eraLabelY : undefined
|
|
2005
|
-
);
|
|
2006
|
-
|
|
2007
|
-
renderMarkers(
|
|
2008
|
-
g,
|
|
2009
|
-
timelineMarkers,
|
|
2010
|
-
xScale,
|
|
2011
|
-
false,
|
|
2012
|
-
innerWidth,
|
|
2013
|
-
innerHeight,
|
|
2014
|
-
(d) => fadeToMarker(g, d),
|
|
2015
|
-
() => fadeReset(g),
|
|
2016
|
-
timelineScale,
|
|
2017
|
-
tooltip,
|
|
2018
|
-
palette,
|
|
2019
|
-
markerReserve ? markerLabelY : undefined
|
|
2020
|
-
);
|
|
2021
|
-
|
|
2022
|
-
if (timelineScale) {
|
|
2023
|
-
renderTimeScale(
|
|
2024
|
-
g,
|
|
2025
|
-
xScale,
|
|
2026
|
-
false,
|
|
2027
|
-
innerWidth,
|
|
2028
|
-
innerHeight,
|
|
2029
|
-
textColor,
|
|
2030
|
-
minDate,
|
|
2031
|
-
maxDate,
|
|
2032
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
2033
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
2034
|
-
);
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
// Marker labels now live in their reserved row above the chart, so
|
|
2038
|
-
// events can start at y=0 (chart top edge).
|
|
2039
|
-
let curY = 0;
|
|
2040
|
-
|
|
2041
|
-
for (const lane of lanes) {
|
|
2042
|
-
const laneColor = groupColorMap.get(lane.name) ?? textColor;
|
|
2043
|
-
const isCollapsed = collapsedGroups.has(lane.name);
|
|
2044
|
-
const toggleIcon = isCollapsed ? '▶' : '▼';
|
|
2045
|
-
|
|
2046
|
-
// Header label band — gantt-style: spans only the left label column.
|
|
2047
|
-
// Band height matches the event bar height (sBarH) instead of the full
|
|
2048
|
-
// row stride (rowH), leaving a small gap below — same look as gantt.
|
|
2049
|
-
const bandX = -margin.left + 5;
|
|
2050
|
-
const bandW = margin.left - 7;
|
|
2051
|
-
const bandY = curY;
|
|
2052
|
-
const bandH = sBarH;
|
|
2053
|
-
const sBandRx = ctx.structural(4);
|
|
2054
|
-
const sBandAccentW = ctx.structural(4);
|
|
2055
|
-
|
|
2056
|
-
const clipId = `tl-band-clip-${tlBandClipCounter++}`;
|
|
2057
|
-
g.append('clipPath')
|
|
2058
|
-
.attr('id', clipId)
|
|
2059
|
-
.append('rect')
|
|
2060
|
-
.attr('x', bandX)
|
|
2061
|
-
.attr('y', bandY)
|
|
2062
|
-
.attr('width', bandW)
|
|
2063
|
-
.attr('height', bandH)
|
|
2064
|
-
.attr('rx', sBandRx);
|
|
2065
|
-
|
|
2066
|
-
g.append('rect')
|
|
2067
|
-
.attr('class', 'tl-group-header-bg')
|
|
2068
|
-
.attr('data-group', lane.name)
|
|
2069
|
-
.attr('x', bandX)
|
|
2070
|
-
.attr('y', bandY)
|
|
2071
|
-
.attr('width', bandW)
|
|
2072
|
-
.attr('height', bandH)
|
|
2073
|
-
.attr('rx', sBandRx)
|
|
2074
|
-
.attr('fill', shapeFill(palette, laneColor, isDark, { solid }))
|
|
2075
|
-
.style('pointer-events', 'none');
|
|
2076
|
-
|
|
2077
|
-
g.append('rect')
|
|
2078
|
-
.attr('class', 'tl-group-header-accent')
|
|
2079
|
-
.attr('data-group', lane.name)
|
|
2080
|
-
.attr('x', bandX)
|
|
2081
|
-
.attr('y', bandY)
|
|
2082
|
-
.attr('width', sBandAccentW)
|
|
2083
|
-
.attr('height', bandH)
|
|
2084
|
-
.attr('fill', laneColor)
|
|
2085
|
-
.attr('clip-path', `url(#${clipId})`)
|
|
2086
|
-
.style('pointer-events', 'none');
|
|
2087
|
-
|
|
2088
|
-
const headerG = g
|
|
2089
|
-
.append('g')
|
|
2090
|
-
.attr('class', 'tl-group-header')
|
|
2091
|
-
.attr('data-group', lane.name)
|
|
2092
|
-
.style('cursor', 'pointer')
|
|
2093
|
-
.on('mouseenter', () => fadeToGroup(g, lane.name))
|
|
2094
|
-
.on('mouseleave', () => fadeReset(g))
|
|
2095
|
-
.on('click', () => toggleGroup(lane.name));
|
|
2096
|
-
|
|
2097
|
-
headerG
|
|
2098
|
-
.append('text')
|
|
2099
|
-
.attr('x', -margin.left + ctx.aesthetic(10))
|
|
2100
|
-
.attr('y', curY + sBarH / 2)
|
|
2101
|
-
.attr('dy', '0.35em')
|
|
2102
|
-
.attr('text-anchor', 'start')
|
|
2103
|
-
.attr('fill', textColor)
|
|
2104
|
-
.attr('font-size', `${sLaneHeaderFont}px`)
|
|
2105
|
-
.attr('font-weight', '600')
|
|
2106
|
-
.text(`${toggleIcon} ${lane.name}`);
|
|
2107
|
-
|
|
2108
|
-
// Group bar in time area — spans min→max event dates, always rendered
|
|
2109
|
-
// (expanded and collapsed). Visually identical to gantt's group bar.
|
|
2110
|
-
if (lane.events.length > 0) {
|
|
2111
|
-
const evDates = lane.events.map((ev) => parseTimelineDate(ev.date));
|
|
2112
|
-
const evEndDates = lane.events
|
|
2113
|
-
.filter((ev) => ev.endDate)
|
|
2114
|
-
.map((ev) => parseTimelineDate(ev.endDate!));
|
|
2115
|
-
const laneMinD = Math.min(...evDates);
|
|
2116
|
-
const laneMaxD = Math.max(...evDates, ...evEndDates);
|
|
2117
|
-
const sx1 = xScale(laneMinD);
|
|
2118
|
-
const sx2 = xScale(laneMaxD);
|
|
2119
|
-
const groupBarW = Math.max(sx2 - sx1, ctx.structural(20));
|
|
2120
|
-
|
|
2121
|
-
const groupBarG = g
|
|
2122
|
-
.append('g')
|
|
2123
|
-
.attr('class', isCollapsed ? 'tl-group-summary' : 'tl-group-bar')
|
|
2124
|
-
.attr('data-group', lane.name)
|
|
2125
|
-
.style('cursor', 'pointer')
|
|
2126
|
-
.on('mouseenter', () => fadeToGroup(g, lane.name))
|
|
2127
|
-
.on('mouseleave', () => fadeReset(g))
|
|
2128
|
-
.on('click', () => toggleGroup(lane.name));
|
|
2129
|
-
|
|
2130
|
-
groupBarG
|
|
2131
|
-
.append('rect')
|
|
2132
|
-
.attr('x', sx1)
|
|
2133
|
-
.attr('y', curY)
|
|
2134
|
-
.attr('width', groupBarW)
|
|
2135
|
-
.attr('height', sBarH)
|
|
2136
|
-
.attr('rx', sBarRx)
|
|
2137
|
-
.attr('fill', shapeFill(palette, laneColor, isDark, { solid }))
|
|
2138
|
-
.attr('stroke', laneColor)
|
|
2139
|
-
.attr('stroke-width', sBarStroke);
|
|
2140
|
-
}
|
|
2141
|
-
|
|
2142
|
-
if (isCollapsed) {
|
|
2143
|
-
curY += rowH + sGroupGap;
|
|
2144
|
-
continue;
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
lane.events.forEach((ev, i) => {
|
|
2148
|
-
const y = curY + (i + 1) * rowH + rowH / 2;
|
|
2149
|
-
const x = xScale(parseTimelineDate(ev.date));
|
|
2150
|
-
|
|
2151
|
-
const evG = g
|
|
2152
|
-
.append('g')
|
|
2153
|
-
.attr('class', 'tl-event')
|
|
2154
|
-
.attr('data-group', lane.name)
|
|
2155
|
-
.attr('data-line-number', String(ev.lineNumber))
|
|
2156
|
-
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
2157
|
-
.attr(
|
|
2158
|
-
'data-end-date',
|
|
2159
|
-
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
2160
|
-
)
|
|
2161
|
-
.style('cursor', 'pointer')
|
|
2162
|
-
.on('mouseenter', function (event: MouseEvent) {
|
|
2163
|
-
fadeToGroup(g, lane.name);
|
|
2164
|
-
if (timelineScale) {
|
|
2165
|
-
showEventDatesOnScale(
|
|
2166
|
-
g,
|
|
2167
|
-
xScale,
|
|
2168
|
-
ev.date,
|
|
2169
|
-
ev.endDate,
|
|
2170
|
-
innerHeight,
|
|
2171
|
-
laneColor
|
|
2172
|
-
);
|
|
2173
|
-
} else {
|
|
2174
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2175
|
-
}
|
|
2176
|
-
})
|
|
2177
|
-
.on('mouseleave', function () {
|
|
2178
|
-
fadeReset(g);
|
|
2179
|
-
if (timelineScale) {
|
|
2180
|
-
hideEventDatesOnScale(g);
|
|
2181
|
-
} else {
|
|
2182
|
-
hideTooltip(tooltip);
|
|
2183
|
-
}
|
|
2184
|
-
})
|
|
2185
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
2186
|
-
if (!timelineScale) {
|
|
2187
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2188
|
-
}
|
|
2189
|
-
})
|
|
2190
|
-
.on('click', () => {
|
|
2191
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
2192
|
-
});
|
|
2193
|
-
setTagAttrs(evG, ev);
|
|
2194
|
-
|
|
2195
|
-
const evColor = eventColor(ev);
|
|
2196
|
-
|
|
2197
|
-
// Left column: event label with colored bullet/diamond icon
|
|
2198
|
-
const isPoint = !ev.endDate;
|
|
2199
|
-
const icon = isPoint ? '◆' : '●';
|
|
2200
|
-
const labelEl = evG
|
|
2201
|
-
.append('text')
|
|
2202
|
-
.attr('class', 'tl-event-label')
|
|
2203
|
-
.attr('x', -margin.left + ctx.aesthetic(20))
|
|
2204
|
-
.attr('y', y)
|
|
2205
|
-
.attr('dy', '0.35em')
|
|
2206
|
-
.attr('text-anchor', 'start')
|
|
2207
|
-
.attr('font-size', `${sEventFont}px`)
|
|
2208
|
-
.attr('fill', textColor);
|
|
2209
|
-
labelEl.append('tspan').attr('fill', evColor).text(icon);
|
|
2210
|
-
labelEl
|
|
2211
|
-
.append('tspan')
|
|
2212
|
-
.attr('fill', textColor)
|
|
2213
|
-
.text(' ' + ev.label);
|
|
2214
|
-
|
|
2215
|
-
// Time area: shape only (no floating label)
|
|
2216
|
-
if (ev.endDate) {
|
|
2217
|
-
const x2 = xScale(parseTimelineDate(ev.endDate));
|
|
2218
|
-
const rectW = Math.max(x2 - x, 4);
|
|
2219
|
-
|
|
2220
|
-
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
2221
|
-
let stroke: string = evColor;
|
|
2222
|
-
if (ev.uncertain) {
|
|
2223
|
-
const gradientId = `uncertain-${ev.lineNumber}`;
|
|
2224
|
-
const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
|
|
2225
|
-
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
2226
|
-
const defsEl = d3Selection.select(defs as Element);
|
|
2227
|
-
defsEl
|
|
2228
|
-
.append('linearGradient')
|
|
2229
|
-
.attr('id', gradientId)
|
|
2230
|
-
.attr('x1', '0%')
|
|
2231
|
-
.attr('y1', '0%')
|
|
2232
|
-
.attr('x2', '100%')
|
|
2233
|
-
.attr('y2', '0%')
|
|
2234
|
-
.selectAll('stop')
|
|
2235
|
-
.data([
|
|
2236
|
-
{ offset: '0%', opacity: 1 },
|
|
2237
|
-
{ offset: '80%', opacity: 1 },
|
|
2238
|
-
{ offset: '100%', opacity: 0 },
|
|
2239
|
-
])
|
|
2240
|
-
.enter()
|
|
2241
|
-
.append('stop')
|
|
2242
|
-
.attr('offset', (d) => d.offset)
|
|
2243
|
-
.attr('stop-color', mix(evColor, bg, 30))
|
|
2244
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2245
|
-
defsEl
|
|
2246
|
-
.append('linearGradient')
|
|
2247
|
-
.attr('id', strokeGradientId)
|
|
2248
|
-
.attr('x1', '0%')
|
|
2249
|
-
.attr('y1', '0%')
|
|
2250
|
-
.attr('x2', '100%')
|
|
2251
|
-
.attr('y2', '0%')
|
|
2252
|
-
.selectAll('stop')
|
|
2253
|
-
.data([
|
|
2254
|
-
{ offset: '0%', opacity: 1 },
|
|
2255
|
-
{ offset: '80%', opacity: 1 },
|
|
2256
|
-
{ offset: '100%', opacity: 0 },
|
|
2257
|
-
])
|
|
2258
|
-
.enter()
|
|
2259
|
-
.append('stop')
|
|
2260
|
-
.attr('offset', (d) => d.offset)
|
|
2261
|
-
.attr('stop-color', evColor)
|
|
2262
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2263
|
-
fill = `url(#${gradientId})`;
|
|
2264
|
-
stroke = `url(#${strokeGradientId})`;
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
evG
|
|
2268
|
-
.append('rect')
|
|
2269
|
-
.attr('x', x)
|
|
2270
|
-
.attr('y', y - sBarH / 2)
|
|
2271
|
-
.attr('width', rectW)
|
|
2272
|
-
.attr('height', sBarH)
|
|
2273
|
-
.attr('rx', sBarRx)
|
|
2274
|
-
.attr('fill', fill)
|
|
2275
|
-
.attr('stroke', stroke)
|
|
2276
|
-
.attr('stroke-width', sBarStroke);
|
|
2277
|
-
} else {
|
|
2278
|
-
evG
|
|
2279
|
-
.append('circle')
|
|
2280
|
-
.attr('cx', x)
|
|
2281
|
-
.attr('cy', y)
|
|
2282
|
-
.attr('r', sPointR)
|
|
2283
|
-
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
2284
|
-
.attr('stroke', evColor)
|
|
2285
|
-
.attr('stroke-width', sPointStroke);
|
|
2286
|
-
}
|
|
2287
|
-
});
|
|
2288
|
-
|
|
2289
|
-
curY += (lane.events.length + 1) * rowH + sGroupGap;
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
// ============================================================
|
|
2294
|
-
// Timeline — vertical-orientation renderer (extracted from renderTimeline)
|
|
2295
|
-
// ============================================================
|
|
2296
|
-
|
|
2297
|
-
function renderTimelineVertical(
|
|
2298
|
-
container: HTMLDivElement,
|
|
2299
|
-
parsed: ParsedTimeline,
|
|
2300
|
-
palette: PaletteColors,
|
|
2301
|
-
isDark: boolean,
|
|
2302
|
-
setup: TimelineSetup,
|
|
2303
|
-
hovers: TimelineHoverHelpers,
|
|
2304
|
-
onClickItem: ((lineNumber: number) => void) | undefined,
|
|
2305
|
-
exportDims: D3ExportDimensions | undefined,
|
|
2306
|
-
_swimlaneTagGroup: string | null | undefined,
|
|
2307
|
-
_activeTagGroup: string | null | undefined,
|
|
2308
|
-
_onTagStateChange:
|
|
2309
|
-
| ((activeTagGroup: string | null, swimlaneTagGroup: string | null) => void)
|
|
2310
|
-
| undefined,
|
|
2311
|
-
_viewMode: boolean | undefined
|
|
2312
|
-
): void {
|
|
2313
|
-
const {
|
|
2314
|
-
width,
|
|
2315
|
-
height,
|
|
2316
|
-
tooltip,
|
|
2317
|
-
solid,
|
|
2318
|
-
textColor,
|
|
2319
|
-
mutedColor,
|
|
2320
|
-
bgColor,
|
|
2321
|
-
bg,
|
|
2322
|
-
groupColorMap,
|
|
2323
|
-
tagLanes,
|
|
2324
|
-
eventColor,
|
|
2325
|
-
minDate,
|
|
2326
|
-
maxDate,
|
|
2327
|
-
datePadding,
|
|
2328
|
-
earliestStartDateStr,
|
|
2329
|
-
latestEndDateStr,
|
|
2330
|
-
tagLegendReserve,
|
|
2331
|
-
ctx,
|
|
2332
|
-
} = setup;
|
|
2333
|
-
const { fadeToGroup, fadeToEra, fadeToMarker, fadeReset, setTagAttrs } =
|
|
2334
|
-
hovers;
|
|
2335
|
-
const {
|
|
2336
|
-
timelineEvents,
|
|
2337
|
-
timelineGroups,
|
|
2338
|
-
timelineEras,
|
|
2339
|
-
timelineMarkers,
|
|
2340
|
-
timelineSort,
|
|
2341
|
-
timelineScale,
|
|
2342
|
-
timelineSwimlanes,
|
|
2343
|
-
} = parsed;
|
|
2344
|
-
const title = parsed.noTitle ? null : parsed.title;
|
|
2345
|
-
|
|
2346
|
-
const sPointR = ctx.structural(4);
|
|
2347
|
-
const sPointStroke = ctx.structural(2);
|
|
2348
|
-
const sBarRx = ctx.structural(4);
|
|
2349
|
-
const sBarStroke = ctx.structural(2);
|
|
2350
|
-
const sBarW = ctx.structural(12);
|
|
2351
|
-
const sEventFont = ctx.text(10);
|
|
2352
|
-
const sEventFontSm = ctx.text(11);
|
|
2353
|
-
const sDateFont = ctx.text(10);
|
|
2354
|
-
const sLaneHeaderFont = ctx.text(12);
|
|
2355
|
-
const sDash = `${ctx.structural(4)},${ctx.structural(4)}`;
|
|
2356
|
-
|
|
2357
|
-
const useGroupedVertical =
|
|
2358
|
-
tagLanes != null || (timelineSort === 'group' && timelineGroups.length > 0);
|
|
2359
|
-
if (useGroupedVertical) {
|
|
2360
|
-
// === GROUPED: one column/lane per group, vertical ===
|
|
2361
|
-
let laneNames: string[];
|
|
2362
|
-
let laneEventsByName: Map<string, TimelineEvent[]>;
|
|
2363
|
-
|
|
2364
|
-
if (tagLanes) {
|
|
2365
|
-
laneNames = tagLanes.map((l) => l.name);
|
|
2366
|
-
laneEventsByName = new Map(tagLanes.map((l) => [l.name, l.events]));
|
|
2367
|
-
} else {
|
|
2368
|
-
const groupNames = timelineGroups.map((gr) => gr.name);
|
|
2369
|
-
const ungroupedEvents = timelineEvents.filter(
|
|
2370
|
-
(ev) => ev.group === null || !groupNames.includes(ev.group)
|
|
2371
|
-
);
|
|
2372
|
-
laneNames =
|
|
2373
|
-
ungroupedEvents.length > 0 ? [...groupNames, '(Other)'] : groupNames;
|
|
2374
|
-
laneEventsByName = new Map(
|
|
2375
|
-
laneNames.map((name) => [
|
|
2376
|
-
name,
|
|
2377
|
-
timelineEvents.filter((ev) =>
|
|
2378
|
-
name === '(Other)'
|
|
2379
|
-
? ev.group === null || !groupNames.includes(ev.group)
|
|
2380
|
-
: ev.group === name
|
|
2381
|
-
),
|
|
2382
|
-
])
|
|
2383
|
-
);
|
|
2384
|
-
}
|
|
2385
|
-
|
|
2386
|
-
const laneCount = laneNames.length;
|
|
2387
|
-
const scaleMargin = timelineScale ? ctx.aesthetic(40) : 0;
|
|
2388
|
-
const markerMargin = timelineMarkers.length > 0 ? ctx.aesthetic(30) : 0;
|
|
2389
|
-
const margin = {
|
|
2390
|
-
top: ctx.aesthetic(104) + markerMargin + tagLegendReserve,
|
|
2391
|
-
right: ctx.aesthetic(40) + scaleMargin,
|
|
2392
|
-
bottom: ctx.aesthetic(40),
|
|
2393
|
-
left: ctx.aesthetic(60) + scaleMargin,
|
|
2394
|
-
};
|
|
2395
|
-
const innerWidth = width - margin.left - margin.right;
|
|
2396
|
-
const innerHeight = height - margin.top - margin.bottom;
|
|
2397
|
-
const laneWidth = innerWidth / laneCount;
|
|
2398
|
-
|
|
2399
|
-
const yScale = d3Scale
|
|
2400
|
-
.scaleLinear()
|
|
2401
|
-
.domain([minDate - datePadding, maxDate + datePadding])
|
|
2402
|
-
.range([0, innerHeight]);
|
|
2403
|
-
|
|
2404
|
-
const svg = d3Selection
|
|
2405
|
-
.select(container)
|
|
2406
|
-
.append('svg')
|
|
2407
|
-
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
2408
|
-
.attr('width', exportDims ? width : '100%')
|
|
2409
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
2410
|
-
.style('background', bgColor);
|
|
2411
|
-
|
|
2412
|
-
const g = svg
|
|
2413
|
-
.append('g')
|
|
2414
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
2415
|
-
|
|
2416
|
-
renderChartTitle(
|
|
2417
|
-
svg,
|
|
2418
|
-
title,
|
|
2419
|
-
parsed.titleLineNumber,
|
|
2420
|
-
width,
|
|
2421
|
-
textColor,
|
|
2422
|
-
onClickItem
|
|
2423
|
-
);
|
|
2424
|
-
|
|
2425
|
-
renderEras(
|
|
2426
|
-
g,
|
|
2427
|
-
timelineEras,
|
|
2428
|
-
yScale,
|
|
2429
|
-
true,
|
|
2430
|
-
innerWidth,
|
|
2431
|
-
innerHeight,
|
|
2432
|
-
(s, e) => fadeToEra(g, s, e),
|
|
2433
|
-
() => fadeReset(g),
|
|
2434
|
-
timelineScale,
|
|
2435
|
-
tooltip,
|
|
2436
|
-
palette
|
|
2437
|
-
);
|
|
2438
|
-
|
|
2439
|
-
renderMarkers(
|
|
2440
|
-
g,
|
|
2441
|
-
timelineMarkers,
|
|
2442
|
-
yScale,
|
|
2443
|
-
true,
|
|
2444
|
-
innerWidth,
|
|
2445
|
-
innerHeight,
|
|
2446
|
-
(d) => fadeToMarker(g, d),
|
|
2447
|
-
() => fadeReset(g),
|
|
2448
|
-
timelineScale,
|
|
2449
|
-
tooltip,
|
|
2450
|
-
palette
|
|
2451
|
-
);
|
|
2452
|
-
|
|
2453
|
-
if (timelineScale) {
|
|
2454
|
-
renderTimeScale(
|
|
2455
|
-
g,
|
|
2456
|
-
yScale,
|
|
2457
|
-
true,
|
|
2458
|
-
innerWidth,
|
|
2459
|
-
innerHeight,
|
|
2460
|
-
textColor,
|
|
2461
|
-
minDate,
|
|
2462
|
-
maxDate,
|
|
2463
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
2464
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
2465
|
-
);
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
// Render swimlane backgrounds for vertical lanes
|
|
2469
|
-
if (timelineSwimlanes || tagLanes) {
|
|
2470
|
-
laneNames.forEach((laneName, laneIdx) => {
|
|
2471
|
-
const laneX = laneIdx * laneWidth;
|
|
2472
|
-
const fillColor = laneIdx % 2 === 0 ? textColor : 'transparent';
|
|
2473
|
-
g.append('rect')
|
|
2474
|
-
.attr('class', 'tl-swimlane')
|
|
2475
|
-
.attr('data-group', laneName)
|
|
2476
|
-
.attr('x', laneX)
|
|
2477
|
-
.attr('y', 0)
|
|
2478
|
-
.attr('width', laneWidth)
|
|
2479
|
-
.attr('height', innerHeight)
|
|
2480
|
-
.attr('fill', fillColor)
|
|
2481
|
-
.attr('opacity', 0.06);
|
|
2482
|
-
});
|
|
2483
|
-
}
|
|
2484
|
-
|
|
2485
|
-
laneNames.forEach((laneName, laneIdx) => {
|
|
2486
|
-
const laneX = laneIdx * laneWidth;
|
|
2487
|
-
const laneColor = groupColorMap.get(laneName) ?? textColor;
|
|
2488
|
-
const laneCenter = laneX + laneWidth / 2;
|
|
2489
|
-
|
|
2490
|
-
const headerG = g
|
|
2491
|
-
.append('g')
|
|
2492
|
-
.attr('class', 'tl-lane-header')
|
|
2493
|
-
.attr('data-group', laneName)
|
|
2494
|
-
.style('cursor', 'pointer')
|
|
2495
|
-
.on('mouseenter', () => fadeToGroup(g, laneName))
|
|
2496
|
-
.on('mouseleave', () => fadeReset(g));
|
|
2497
|
-
|
|
2498
|
-
headerG
|
|
2499
|
-
.append('text')
|
|
2500
|
-
.attr('x', laneCenter)
|
|
2501
|
-
.attr('y', -ctx.structural(15))
|
|
2502
|
-
.attr('text-anchor', 'middle')
|
|
2503
|
-
.attr('fill', laneColor)
|
|
2504
|
-
.attr('font-size', `${sLaneHeaderFont}px`)
|
|
2505
|
-
.attr('font-weight', '600')
|
|
2506
|
-
.text(laneName);
|
|
2507
|
-
|
|
2508
|
-
g.append('line')
|
|
2509
|
-
.attr('x1', laneCenter)
|
|
2510
|
-
.attr('y1', 0)
|
|
2511
|
-
.attr('x2', laneCenter)
|
|
2512
|
-
.attr('y2', innerHeight)
|
|
2513
|
-
.attr('stroke', mutedColor)
|
|
2514
|
-
.attr('stroke-width', 1)
|
|
2515
|
-
.attr('stroke-dasharray', sDash);
|
|
2516
|
-
|
|
2517
|
-
const laneEvents = laneEventsByName.get(laneName) ?? [];
|
|
2518
|
-
|
|
2519
|
-
for (const ev of laneEvents) {
|
|
2520
|
-
const y = yScale(parseTimelineDate(ev.date));
|
|
2521
|
-
const evG = g
|
|
2522
|
-
.append('g')
|
|
2523
|
-
.attr('class', 'tl-event')
|
|
2524
|
-
.attr('data-group', laneName)
|
|
2525
|
-
.attr('data-line-number', String(ev.lineNumber))
|
|
2526
|
-
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
2527
|
-
.attr(
|
|
2528
|
-
'data-end-date',
|
|
2529
|
-
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
2530
|
-
)
|
|
2531
|
-
.style('cursor', 'pointer')
|
|
2532
|
-
.on('mouseenter', function (event: MouseEvent) {
|
|
2533
|
-
fadeToGroup(g, laneName);
|
|
2534
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2535
|
-
})
|
|
2536
|
-
.on('mouseleave', function () {
|
|
2537
|
-
fadeReset(g);
|
|
2538
|
-
hideTooltip(tooltip);
|
|
2539
|
-
})
|
|
2540
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
2541
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2542
|
-
})
|
|
2543
|
-
.on('click', () => {
|
|
2544
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
2545
|
-
});
|
|
2546
|
-
setTagAttrs(evG, ev);
|
|
2547
|
-
|
|
2548
|
-
const evColor = eventColor(ev);
|
|
2549
|
-
|
|
2550
|
-
if (ev.endDate) {
|
|
2551
|
-
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
2552
|
-
const rectH = Math.max(y2 - y, 4);
|
|
2553
|
-
|
|
2554
|
-
let fill: string = shapeFill(palette, evColor, isDark, { solid });
|
|
2555
|
-
let stroke: string = evColor;
|
|
2556
|
-
if (ev.uncertain) {
|
|
2557
|
-
const gradientId = `uncertain-vg-${ev.lineNumber}`;
|
|
2558
|
-
const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
|
|
2559
|
-
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
2560
|
-
const defsEl = d3Selection.select(defs as Element);
|
|
2561
|
-
defsEl
|
|
2562
|
-
.append('linearGradient')
|
|
2563
|
-
.attr('id', gradientId)
|
|
2564
|
-
.attr('x1', '0%')
|
|
2565
|
-
.attr('y1', '0%')
|
|
2566
|
-
.attr('x2', '0%')
|
|
2567
|
-
.attr('y2', '100%')
|
|
2568
|
-
.selectAll('stop')
|
|
2569
|
-
.data([
|
|
2570
|
-
{ offset: '0%', opacity: 1 },
|
|
2571
|
-
{ offset: '80%', opacity: 1 },
|
|
2572
|
-
{ offset: '100%', opacity: 0 },
|
|
2573
|
-
])
|
|
2574
|
-
.enter()
|
|
2575
|
-
.append('stop')
|
|
2576
|
-
.attr('offset', (d) => d.offset)
|
|
2577
|
-
.attr('stop-color', mix(laneColor, bg, 30))
|
|
2578
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2579
|
-
defsEl
|
|
2580
|
-
.append('linearGradient')
|
|
2581
|
-
.attr('id', strokeGradientId)
|
|
2582
|
-
.attr('x1', '0%')
|
|
2583
|
-
.attr('y1', '0%')
|
|
2584
|
-
.attr('x2', '0%')
|
|
2585
|
-
.attr('y2', '100%')
|
|
2586
|
-
.selectAll('stop')
|
|
2587
|
-
.data([
|
|
2588
|
-
{ offset: '0%', opacity: 1 },
|
|
2589
|
-
{ offset: '80%', opacity: 1 },
|
|
2590
|
-
{ offset: '100%', opacity: 0 },
|
|
2591
|
-
])
|
|
2592
|
-
.enter()
|
|
2593
|
-
.append('stop')
|
|
2594
|
-
.attr('offset', (d) => d.offset)
|
|
2595
|
-
.attr('stop-color', evColor)
|
|
2596
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2597
|
-
fill = `url(#${gradientId})`;
|
|
2598
|
-
stroke = `url(#${strokeGradientId})`;
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
evG
|
|
2602
|
-
.append('rect')
|
|
2603
|
-
.attr('x', laneCenter - sBarW / 2)
|
|
2604
|
-
.attr('y', y)
|
|
2605
|
-
.attr('width', sBarW)
|
|
2606
|
-
.attr('height', rectH)
|
|
2607
|
-
.attr('rx', sBarRx)
|
|
2608
|
-
.attr('fill', fill)
|
|
2609
|
-
.attr('stroke', stroke)
|
|
2610
|
-
.attr('stroke-width', sBarStroke);
|
|
2611
|
-
evG
|
|
2612
|
-
.append('text')
|
|
2613
|
-
.attr('x', laneCenter + sBarW + ctx.aesthetic(2))
|
|
2614
|
-
.attr('y', y + rectH / 2)
|
|
2615
|
-
.attr('dy', '0.35em')
|
|
2616
|
-
.attr('fill', textColor)
|
|
2617
|
-
.attr('font-size', `${sEventFont}px`)
|
|
2618
|
-
.text(ev.label);
|
|
2619
|
-
} else {
|
|
2620
|
-
evG
|
|
2621
|
-
.append('circle')
|
|
2622
|
-
.attr('cx', laneCenter)
|
|
2623
|
-
.attr('cy', y)
|
|
2624
|
-
.attr('r', sPointR)
|
|
2625
|
-
.attr('fill', shapeFill(palette, evColor, isDark, { solid }))
|
|
2626
|
-
.attr('stroke', evColor)
|
|
2627
|
-
.attr('stroke-width', sPointStroke);
|
|
2628
|
-
evG
|
|
2629
|
-
.append('text')
|
|
2630
|
-
.attr('x', laneCenter + ctx.aesthetic(10))
|
|
2631
|
-
.attr('y', y)
|
|
2632
|
-
.attr('dy', '0.35em')
|
|
2633
|
-
.attr('fill', textColor)
|
|
2634
|
-
.attr('font-size', `${sEventFont}px`)
|
|
2635
|
-
.text(ev.label);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
});
|
|
2639
|
-
} else {
|
|
2640
|
-
// === TIME SORT, vertical: single vertical axis ===
|
|
2641
|
-
const scaleMargin = timelineScale ? ctx.aesthetic(40) : 0;
|
|
2642
|
-
const markerMargin = timelineMarkers.length > 0 ? ctx.aesthetic(30) : 0;
|
|
2643
|
-
const margin = {
|
|
2644
|
-
top: ctx.aesthetic(104) + markerMargin + tagLegendReserve,
|
|
2645
|
-
right: ctx.aesthetic(200),
|
|
2646
|
-
bottom: ctx.aesthetic(40),
|
|
2647
|
-
left: ctx.aesthetic(60) + scaleMargin,
|
|
2648
|
-
};
|
|
2649
|
-
const innerWidth = width - margin.left - margin.right;
|
|
2650
|
-
const innerHeight = height - margin.top - margin.bottom;
|
|
2651
|
-
const axisX = ctx.structural(20);
|
|
2652
|
-
|
|
2653
|
-
const yScale = d3Scale
|
|
2654
|
-
.scaleLinear()
|
|
2655
|
-
.domain([minDate - datePadding, maxDate + datePadding])
|
|
2656
|
-
.range([0, innerHeight]);
|
|
2657
|
-
|
|
2658
|
-
const sorted = timelineEvents
|
|
2659
|
-
.slice()
|
|
2660
|
-
.sort((a, b) => parseTimelineDate(a.date) - parseTimelineDate(b.date));
|
|
2661
|
-
|
|
2662
|
-
const svg = d3Selection
|
|
2663
|
-
.select(container)
|
|
2664
|
-
.append('svg')
|
|
2665
|
-
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
2666
|
-
.attr('width', exportDims ? width : '100%')
|
|
2667
|
-
.attr('preserveAspectRatio', 'xMidYMin meet')
|
|
2668
|
-
.style('background', bgColor);
|
|
2669
|
-
|
|
2670
|
-
const g = svg
|
|
2671
|
-
.append('g')
|
|
2672
|
-
.attr('transform', `translate(${margin.left},${margin.top})`);
|
|
2673
|
-
|
|
2674
|
-
renderChartTitle(
|
|
2675
|
-
svg,
|
|
2676
|
-
title,
|
|
2677
|
-
parsed.titleLineNumber,
|
|
2678
|
-
width,
|
|
2679
|
-
textColor,
|
|
2680
|
-
onClickItem
|
|
2681
|
-
);
|
|
2682
|
-
|
|
2683
|
-
renderEras(
|
|
2684
|
-
g,
|
|
2685
|
-
timelineEras,
|
|
2686
|
-
yScale,
|
|
2687
|
-
true,
|
|
2688
|
-
innerWidth,
|
|
2689
|
-
innerHeight,
|
|
2690
|
-
(s, e) => fadeToEra(g, s, e),
|
|
2691
|
-
() => fadeReset(g),
|
|
2692
|
-
timelineScale,
|
|
2693
|
-
tooltip,
|
|
2694
|
-
palette
|
|
2695
|
-
);
|
|
2696
|
-
|
|
2697
|
-
renderMarkers(
|
|
2698
|
-
g,
|
|
2699
|
-
timelineMarkers,
|
|
2700
|
-
yScale,
|
|
2701
|
-
true,
|
|
2702
|
-
innerWidth,
|
|
2703
|
-
innerHeight,
|
|
2704
|
-
(d) => fadeToMarker(g, d),
|
|
2705
|
-
() => fadeReset(g),
|
|
2706
|
-
timelineScale,
|
|
2707
|
-
tooltip,
|
|
2708
|
-
palette
|
|
2709
|
-
);
|
|
2710
|
-
|
|
2711
|
-
if (timelineScale) {
|
|
2712
|
-
renderTimeScale(
|
|
2713
|
-
g,
|
|
2714
|
-
yScale,
|
|
2715
|
-
true,
|
|
2716
|
-
innerWidth,
|
|
2717
|
-
innerHeight,
|
|
2718
|
-
textColor,
|
|
2719
|
-
minDate,
|
|
2720
|
-
maxDate,
|
|
2721
|
-
formatBoundaryLabel(earliestStartDateStr, latestEndDateStr),
|
|
2722
|
-
formatBoundaryLabel(latestEndDateStr, earliestStartDateStr)
|
|
2723
|
-
);
|
|
2724
|
-
}
|
|
2725
|
-
|
|
2726
|
-
if (timelineGroups.length > 0) {
|
|
2727
|
-
renderTimelineGroupLegend(
|
|
2728
|
-
g,
|
|
2729
|
-
timelineGroups,
|
|
2730
|
-
groupColorMap,
|
|
2731
|
-
textColor,
|
|
2732
|
-
palette,
|
|
2733
|
-
isDark,
|
|
2734
|
-
-ctx.aesthetic(55),
|
|
2735
|
-
(name) => fadeToGroup(g, name),
|
|
2736
|
-
() => fadeReset(g)
|
|
2737
|
-
);
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
g.append('line')
|
|
2741
|
-
.attr('x1', axisX)
|
|
2742
|
-
.attr('y1', 0)
|
|
2743
|
-
.attr('x2', axisX)
|
|
2744
|
-
.attr('y2', innerHeight)
|
|
2745
|
-
.attr('stroke', mutedColor)
|
|
2746
|
-
.attr('stroke-width', 1)
|
|
2747
|
-
.attr('stroke-dasharray', sDash);
|
|
2748
|
-
|
|
2749
|
-
for (const ev of sorted) {
|
|
2750
|
-
const y = yScale(parseTimelineDate(ev.date));
|
|
2751
|
-
const color = eventColor(ev);
|
|
2752
|
-
|
|
2753
|
-
const evG = g
|
|
2754
|
-
.append('g')
|
|
2755
|
-
.attr('class', 'tl-event')
|
|
2756
|
-
.attr('data-group', ev.group || '')
|
|
2757
|
-
.attr('data-line-number', String(ev.lineNumber))
|
|
2758
|
-
.attr('data-date', String(parseTimelineDate(ev.date)))
|
|
2759
|
-
.attr(
|
|
2760
|
-
'data-end-date',
|
|
2761
|
-
ev.endDate ? String(parseTimelineDate(ev.endDate)) : null
|
|
2762
|
-
)
|
|
2763
|
-
.style('cursor', 'pointer')
|
|
2764
|
-
.on('mouseenter', function (event: MouseEvent) {
|
|
2765
|
-
if (ev.group && timelineGroups.length > 0) fadeToGroup(g, ev.group);
|
|
2766
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2767
|
-
})
|
|
2768
|
-
.on('mouseleave', function () {
|
|
2769
|
-
fadeReset(g);
|
|
2770
|
-
hideTooltip(tooltip);
|
|
2771
|
-
})
|
|
2772
|
-
.on('mousemove', function (event: MouseEvent) {
|
|
2773
|
-
showTooltip(tooltip, buildEventTooltipHtml(ev), event);
|
|
2774
|
-
})
|
|
2775
|
-
.on('click', () => {
|
|
2776
|
-
if (onClickItem && ev.lineNumber) onClickItem(ev.lineNumber);
|
|
2777
|
-
});
|
|
2778
|
-
setTagAttrs(evG, ev);
|
|
2779
|
-
|
|
2780
|
-
if (ev.endDate) {
|
|
2781
|
-
const y2 = yScale(parseTimelineDate(ev.endDate));
|
|
2782
|
-
const rectH = Math.max(y2 - y, 4);
|
|
2783
|
-
|
|
2784
|
-
let fill: string = shapeFill(palette, color, isDark, { solid });
|
|
2785
|
-
let stroke: string = color;
|
|
2786
|
-
if (ev.uncertain) {
|
|
2787
|
-
const gradientId = `uncertain-v-${ev.lineNumber}`;
|
|
2788
|
-
const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
|
|
2789
|
-
const defs = svg.select('defs').node() || svg.append('defs').node();
|
|
2790
|
-
const defsEl = d3Selection.select(defs as Element);
|
|
2791
|
-
defsEl
|
|
2792
|
-
.append('linearGradient')
|
|
2793
|
-
.attr('id', gradientId)
|
|
2794
|
-
.attr('x1', '0%')
|
|
2795
|
-
.attr('y1', '0%')
|
|
2796
|
-
.attr('x2', '0%')
|
|
2797
|
-
.attr('y2', '100%')
|
|
2798
|
-
.selectAll('stop')
|
|
2799
|
-
.data([
|
|
2800
|
-
{ offset: '0%', opacity: 1 },
|
|
2801
|
-
{ offset: '80%', opacity: 1 },
|
|
2802
|
-
{ offset: '100%', opacity: 0 },
|
|
2803
|
-
])
|
|
2804
|
-
.enter()
|
|
2805
|
-
.append('stop')
|
|
2806
|
-
.attr('offset', (d) => d.offset)
|
|
2807
|
-
.attr('stop-color', mix(color, bg, 30))
|
|
2808
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2809
|
-
defsEl
|
|
2810
|
-
.append('linearGradient')
|
|
2811
|
-
.attr('id', strokeGradientId)
|
|
2812
|
-
.attr('x1', '0%')
|
|
2813
|
-
.attr('y1', '0%')
|
|
2814
|
-
.attr('x2', '0%')
|
|
2815
|
-
.attr('y2', '100%')
|
|
2816
|
-
.selectAll('stop')
|
|
2817
|
-
.data([
|
|
2818
|
-
{ offset: '0%', opacity: 1 },
|
|
2819
|
-
{ offset: '80%', opacity: 1 },
|
|
2820
|
-
{ offset: '100%', opacity: 0 },
|
|
2821
|
-
])
|
|
2822
|
-
.enter()
|
|
2823
|
-
.append('stop')
|
|
2824
|
-
.attr('offset', (d) => d.offset)
|
|
2825
|
-
.attr('stop-color', color)
|
|
2826
|
-
.attr('stop-opacity', (d) => d.opacity);
|
|
2827
|
-
fill = `url(#${gradientId})`;
|
|
2828
|
-
stroke = `url(#${strokeGradientId})`;
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
evG
|
|
2832
|
-
.append('rect')
|
|
2833
|
-
.attr('x', axisX - sBarW / 2)
|
|
2834
|
-
.attr('y', y)
|
|
2835
|
-
.attr('width', sBarW)
|
|
2836
|
-
.attr('height', rectH)
|
|
2837
|
-
.attr('rx', sBarRx)
|
|
2838
|
-
.attr('fill', fill)
|
|
2839
|
-
.attr('stroke', stroke)
|
|
2840
|
-
.attr('stroke-width', sBarStroke);
|
|
2841
|
-
evG
|
|
2842
|
-
.append('text')
|
|
2843
|
-
.attr('x', axisX + sBarW + ctx.aesthetic(4))
|
|
2844
|
-
.attr('y', y + rectH / 2)
|
|
2845
|
-
.attr('dy', '0.35em')
|
|
2846
|
-
.attr('fill', textColor)
|
|
2847
|
-
.attr('font-size', `${sEventFontSm}px`)
|
|
2848
|
-
.text(ev.label);
|
|
2849
|
-
} else {
|
|
2850
|
-
evG
|
|
2851
|
-
.append('circle')
|
|
2852
|
-
.attr('cx', axisX)
|
|
2853
|
-
.attr('cy', y)
|
|
2854
|
-
.attr('r', sPointR)
|
|
2855
|
-
.attr('fill', shapeFill(palette, color, isDark, { solid }))
|
|
2856
|
-
.attr('stroke', color)
|
|
2857
|
-
.attr('stroke-width', sPointStroke);
|
|
2858
|
-
evG
|
|
2859
|
-
.append('text')
|
|
2860
|
-
.attr('x', axisX + sBarW + ctx.aesthetic(4))
|
|
2861
|
-
.attr('y', y)
|
|
2862
|
-
.attr('dy', '0.35em')
|
|
2863
|
-
.attr('fill', textColor)
|
|
2864
|
-
.attr('font-size', `${sEventFontSm}px`)
|
|
2865
|
-
.text(ev.label);
|
|
2866
|
-
}
|
|
2867
|
-
|
|
2868
|
-
evG
|
|
2869
|
-
.append('text')
|
|
2870
|
-
.attr('x', axisX - ctx.aesthetic(14))
|
|
2871
|
-
.attr(
|
|
2872
|
-
'y',
|
|
2873
|
-
ev.endDate
|
|
2874
|
-
? yScale(parseTimelineDate(ev.date)) +
|
|
2875
|
-
Math.max(
|
|
2876
|
-
yScale(parseTimelineDate(ev.endDate)) -
|
|
2877
|
-
yScale(parseTimelineDate(ev.date)),
|
|
2878
|
-
4
|
|
2879
|
-
) /
|
|
2880
|
-
2
|
|
2881
|
-
: y
|
|
2882
|
-
)
|
|
2883
|
-
.attr('dy', '0.35em')
|
|
2884
|
-
.attr('text-anchor', 'end')
|
|
2885
|
-
.attr('fill', mutedColor)
|
|
2886
|
-
.attr('font-size', `${sDateFont}px`)
|
|
2887
|
-
.text(ev.date + (ev.endDate ? `→${ev.endDate}` : ''));
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
|
-
const timelineCollapseState = new WeakMap<HTMLDivElement, Set<string>>();
|
|
2893
|
-
let tlBandClipCounter = 0;
|
|
2894
|
-
|
|
2895
|
-
export function renderTimeline(
|
|
2896
|
-
container: HTMLDivElement,
|
|
2897
|
-
parsed: ParsedTimeline,
|
|
2898
|
-
palette: PaletteColors,
|
|
2899
|
-
isDark: boolean,
|
|
2900
|
-
onClickItem?: (lineNumber: number) => void,
|
|
2901
|
-
exportDims?: D3ExportDimensions,
|
|
2902
|
-
activeTagGroup?: string | null,
|
|
2903
|
-
swimlaneTagGroup?: string | null,
|
|
2904
|
-
onTagStateChange?: (
|
|
2905
|
-
activeTagGroup: string | null,
|
|
2906
|
-
swimlaneTagGroup: string | null
|
|
2907
|
-
) => void,
|
|
2908
|
-
viewMode?: boolean,
|
|
2909
|
-
exportMode?: boolean
|
|
2910
|
-
): void {
|
|
2911
|
-
const setup = setupTimeline(
|
|
2912
|
-
container,
|
|
2913
|
-
parsed,
|
|
2914
|
-
palette,
|
|
2915
|
-
isDark,
|
|
2916
|
-
exportDims,
|
|
2917
|
-
activeTagGroup,
|
|
2918
|
-
swimlaneTagGroup
|
|
2919
|
-
);
|
|
2920
|
-
if (!setup) return;
|
|
2921
|
-
swimlaneTagGroup = setup.swimlaneTagGroup;
|
|
2922
|
-
|
|
2923
|
-
const { isVertical, tagLanes } = setup;
|
|
2924
|
-
const hovers = makeTimelineHoverHelpers();
|
|
2925
|
-
|
|
2926
|
-
let collapsedGroups = timelineCollapseState.get(container);
|
|
2927
|
-
if (!collapsedGroups) {
|
|
2928
|
-
collapsedGroups = new Set<string>();
|
|
2929
|
-
timelineCollapseState.set(container, collapsedGroups);
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
function toggleGroup(name: string) {
|
|
2933
|
-
if (collapsedGroups!.has(name)) collapsedGroups!.delete(name);
|
|
2934
|
-
else collapsedGroups!.add(name);
|
|
2935
|
-
renderTimeline(
|
|
2936
|
-
container,
|
|
2937
|
-
parsed,
|
|
2938
|
-
palette,
|
|
2939
|
-
isDark,
|
|
2940
|
-
onClickItem,
|
|
2941
|
-
exportDims,
|
|
2942
|
-
activeTagGroup,
|
|
2943
|
-
swimlaneTagGroup,
|
|
2944
|
-
onTagStateChange,
|
|
2945
|
-
viewMode,
|
|
2946
|
-
exportMode
|
|
2947
|
-
);
|
|
2948
|
-
}
|
|
2949
|
-
|
|
2950
|
-
if (isVertical) {
|
|
2951
|
-
renderTimelineVertical(
|
|
2952
|
-
container,
|
|
2953
|
-
parsed,
|
|
2954
|
-
palette,
|
|
2955
|
-
isDark,
|
|
2956
|
-
setup,
|
|
2957
|
-
hovers,
|
|
2958
|
-
onClickItem,
|
|
2959
|
-
exportDims,
|
|
2960
|
-
swimlaneTagGroup,
|
|
2961
|
-
activeTagGroup,
|
|
2962
|
-
onTagStateChange,
|
|
2963
|
-
viewMode
|
|
2964
|
-
);
|
|
2965
|
-
return;
|
|
2966
|
-
}
|
|
2967
|
-
|
|
2968
|
-
const useGroupedHorizontal =
|
|
2969
|
-
tagLanes != null ||
|
|
2970
|
-
(parsed.timelineSort !== 'time' && parsed.timelineGroups.length > 0);
|
|
2971
|
-
if (useGroupedHorizontal) {
|
|
2972
|
-
renderTimelineHorizontalGrouped(
|
|
2973
|
-
container,
|
|
2974
|
-
parsed,
|
|
2975
|
-
palette,
|
|
2976
|
-
isDark,
|
|
2977
|
-
setup,
|
|
2978
|
-
hovers,
|
|
2979
|
-
onClickItem,
|
|
2980
|
-
exportDims,
|
|
2981
|
-
swimlaneTagGroup,
|
|
2982
|
-
activeTagGroup,
|
|
2983
|
-
onTagStateChange,
|
|
2984
|
-
viewMode,
|
|
2985
|
-
collapsedGroups,
|
|
2986
|
-
toggleGroup
|
|
2987
|
-
);
|
|
2988
|
-
} else {
|
|
2989
|
-
renderTimelineHorizontalTimeSort(
|
|
2990
|
-
container,
|
|
2991
|
-
parsed,
|
|
2992
|
-
palette,
|
|
2993
|
-
isDark,
|
|
2994
|
-
setup,
|
|
2995
|
-
hovers,
|
|
2996
|
-
onClickItem,
|
|
2997
|
-
exportDims,
|
|
2998
|
-
swimlaneTagGroup,
|
|
2999
|
-
activeTagGroup,
|
|
3000
|
-
onTagStateChange,
|
|
3001
|
-
viewMode
|
|
3002
|
-
);
|
|
3003
|
-
}
|
|
3004
|
-
|
|
3005
|
-
renderTimelineTagLegendOverlay(
|
|
3006
|
-
container,
|
|
3007
|
-
parsed,
|
|
3008
|
-
palette,
|
|
3009
|
-
isDark,
|
|
3010
|
-
setup,
|
|
3011
|
-
hovers,
|
|
3012
|
-
onClickItem,
|
|
3013
|
-
exportDims,
|
|
3014
|
-
swimlaneTagGroup,
|
|
3015
|
-
activeTagGroup,
|
|
3016
|
-
onTagStateChange,
|
|
3017
|
-
viewMode,
|
|
3018
|
-
exportMode
|
|
3019
|
-
);
|
|
3020
|
-
}
|
|
3021
|
-
|
|
3022
|
-
// ============================================================
|
|
3023
|
-
// Word Cloud Helpers
|
|
3024
|
-
// ============================================================
|