@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/pert/layout.ts
DELETED
|
@@ -1,971 +0,0 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// PERT Layout — dagre wrapper
|
|
3
|
-
// ============================================================
|
|
4
|
-
//
|
|
5
|
-
// Pure functions; no UI state. The diagrammo-app holds expansion state
|
|
6
|
-
// in its own store and feeds an override map into `relayoutPert`.
|
|
7
|
-
|
|
8
|
-
import dagre from '@dagrejs/dagre';
|
|
9
|
-
import type {
|
|
10
|
-
ResolvedPert,
|
|
11
|
-
LayoutResult,
|
|
12
|
-
PertLayoutNode,
|
|
13
|
-
PertLayoutGroup,
|
|
14
|
-
PertDirection,
|
|
15
|
-
} from './types';
|
|
16
|
-
import type { LayoutOverrides } from './internal';
|
|
17
|
-
import {
|
|
18
|
-
formatDuration,
|
|
19
|
-
formatScheduleValue,
|
|
20
|
-
formatSprintCell,
|
|
21
|
-
formatSlackValue,
|
|
22
|
-
} from './internal';
|
|
23
|
-
import { measureText } from '../utils/text-measure';
|
|
24
|
-
|
|
25
|
-
// Textbook 3×3 PERT/CPM box: top row [ES | dur | EF], middle row
|
|
26
|
-
// [name spanning all three columns], bottom row [LS | slack | LF].
|
|
27
|
-
// Heights are fixed; widths are computed from cell content via
|
|
28
|
-
// `computeNodeSizing` so a non-date diagram (where ES/EF are short
|
|
29
|
-
// duration labels) renders narrower than a date-anchored diagram
|
|
30
|
-
// (where those cells hold a 10-char `YYYY-MM-DD`).
|
|
31
|
-
const DEFAULT_NODE_HEIGHT = 90;
|
|
32
|
-
const MILESTONE_NODE_HEIGHT = DEFAULT_NODE_HEIGHT;
|
|
33
|
-
const COLLAPSED_GROUP_HEIGHT = 90;
|
|
34
|
-
const DIAGRAM_PADDING = 20;
|
|
35
|
-
const GROUP_PADDING = 18;
|
|
36
|
-
// Top padding reserves space for the centered group label band — matches
|
|
37
|
-
// org's `CONTAINER_HEADER_HEIGHT`. See `docs/architecture/diagram-visual-
|
|
38
|
-
// conventions.md` §2 ("Reserved header band").
|
|
39
|
-
const GROUP_TOP_PADDING = 28;
|
|
40
|
-
|
|
41
|
-
// Swim-lane layout constants. When a diagram has groups, members are
|
|
42
|
-
// placed in horizontal (LR) or vertical (TB) bands so group rectangles
|
|
43
|
-
// never overlap, even when cross-group edges scatter members across ranks.
|
|
44
|
-
const SWIMLANE_SLOT_SEP = 40;
|
|
45
|
-
const SWIMLANE_GAP = 24;
|
|
46
|
-
|
|
47
|
-
// Cell-sizing constants — see `computeNodeSizing` below.
|
|
48
|
-
const NODE_CELL_FONT_SIZE = 11;
|
|
49
|
-
const NODE_NAME_FONT_SIZE = 13;
|
|
50
|
-
// Milestone name renders at 12pt with a leading `◆ ` glyph (see renderer).
|
|
51
|
-
const MILESTONE_NAME_FONT_SIZE = 12;
|
|
52
|
-
const CELL_PAD_X = 8;
|
|
53
|
-
const NAME_PAD_X = 6;
|
|
54
|
-
// Anchor icon + gap (renderer reserves space to the left of the name
|
|
55
|
-
// when the activity is the forward source / backward sink).
|
|
56
|
-
const NAME_PIN_WIDTH = 13 + 4;
|
|
57
|
-
const MIN_CELL_WIDTH = 30;
|
|
58
|
-
const MIN_NODE_WIDTH = 120;
|
|
59
|
-
const MAX_NODE_WIDTH = 280;
|
|
60
|
-
const MIN_MILESTONE_WIDTH = 80;
|
|
61
|
-
const MAX_MILESTONE_WIDTH = 160;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Per-diagram node geometry. All non-milestone activity nodes (and any
|
|
65
|
-
* collapsed-group cards) share `activityWidth` so the lane reads as a
|
|
66
|
-
* uniform grid; `outerColW`/`midColW` distribute that width so the
|
|
67
|
-
* outer cells (ES/EF/LS/LF) fit their longest displayed value and the
|
|
68
|
-
* middle cells (dur/slack) fit theirs. Milestones get their own width.
|
|
69
|
-
*/
|
|
70
|
-
export interface NodeSizing {
|
|
71
|
-
activityWidth: number;
|
|
72
|
-
milestoneWidth: number;
|
|
73
|
-
outerColW: number;
|
|
74
|
-
midColW: number;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Walk every resolved activity, format each cell with the same fn the
|
|
79
|
-
* renderer uses, and return the column/node widths needed to hold the
|
|
80
|
-
* longest cell text (plus the longest name, capped at MAX_NODE_WIDTH —
|
|
81
|
-
* names beyond that truncate with ellipsis at draw time). Sprint /
|
|
82
|
-
* date / duration modes each have a characteristic cell-width profile,
|
|
83
|
-
* so the diagram tightens automatically when ES/EF only hold "5w" vs.
|
|
84
|
-
* "2026-05-15".
|
|
85
|
-
*/
|
|
86
|
-
export function computeNodeSizing(resolved: ResolvedPert): NodeSizing {
|
|
87
|
-
const unit = resolved.options.timeUnit;
|
|
88
|
-
const sprintMode = resolved.options.sprintMode;
|
|
89
|
-
const sprintNumber = resolved.options.sprintNumber ?? 1;
|
|
90
|
-
const projectStart = resolved.projectStart;
|
|
91
|
-
|
|
92
|
-
const fmtSchedule = (v: number | null, isTbd: boolean): string =>
|
|
93
|
-
sprintMode
|
|
94
|
-
? formatSprintCell(v, sprintNumber, isTbd ? '?' : null)
|
|
95
|
-
: formatScheduleValue(v, projectStart, unit, isTbd ? '?' : null);
|
|
96
|
-
const fmtSlack = (v: number | null, isTbd: boolean): string =>
|
|
97
|
-
formatSlackValue(v, projectStart, unit, isTbd ? '?' : null);
|
|
98
|
-
const fmtDur = (v: number | null, isTbd: boolean): string =>
|
|
99
|
-
formatDuration(v, unit, isTbd ? '?' : null);
|
|
100
|
-
|
|
101
|
-
// Track the widest rendered pixel width per cell category — measured at
|
|
102
|
-
// the exact font sizes the renderer draws with so reserved width
|
|
103
|
-
// matches drawn text.
|
|
104
|
-
let maxOuterW = 0;
|
|
105
|
-
let maxMidW = 0;
|
|
106
|
-
let maxNameW = 0;
|
|
107
|
-
let maxMilestoneTopW = 0;
|
|
108
|
-
let maxMilestoneSlackW = 0;
|
|
109
|
-
let maxMilestoneNameW = 0;
|
|
110
|
-
|
|
111
|
-
for (const r of resolved.activities) {
|
|
112
|
-
const isTbd = r.es === null;
|
|
113
|
-
if (r.activity.isMilestone) {
|
|
114
|
-
const dateStr = fmtSchedule(r.es, isTbd);
|
|
115
|
-
const slackStr = fmtSlack(r.slack, isTbd);
|
|
116
|
-
const slackHidden = !isTbd && /^0[a-z]?$/.test(slackStr);
|
|
117
|
-
maxMilestoneTopW = Math.max(
|
|
118
|
-
maxMilestoneTopW,
|
|
119
|
-
measureText(dateStr, NODE_CELL_FONT_SIZE)
|
|
120
|
-
);
|
|
121
|
-
if (!slackHidden) {
|
|
122
|
-
maxMilestoneSlackW = Math.max(
|
|
123
|
-
maxMilestoneSlackW,
|
|
124
|
-
measureText(slackStr, NODE_CELL_FONT_SIZE)
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
// The milestone glyph prefix `◆ ` precedes the name.
|
|
128
|
-
maxMilestoneNameW = Math.max(
|
|
129
|
-
maxMilestoneNameW,
|
|
130
|
-
measureText(`◆ ${r.activity.name}`, MILESTONE_NAME_FONT_SIZE)
|
|
131
|
-
);
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
const esStr = fmtSchedule(r.es, isTbd);
|
|
135
|
-
const efStr = fmtSchedule(r.ef, isTbd);
|
|
136
|
-
const lsStr = fmtSchedule(r.ls, isTbd);
|
|
137
|
-
const lfStr = fmtSchedule(r.lf, isTbd);
|
|
138
|
-
const durStr = fmtDur(r.mu, isTbd);
|
|
139
|
-
const slackStr = fmtSlack(r.slack, isTbd);
|
|
140
|
-
maxOuterW = Math.max(
|
|
141
|
-
maxOuterW,
|
|
142
|
-
measureText(esStr, NODE_CELL_FONT_SIZE),
|
|
143
|
-
measureText(efStr, NODE_CELL_FONT_SIZE),
|
|
144
|
-
measureText(lsStr, NODE_CELL_FONT_SIZE),
|
|
145
|
-
measureText(lfStr, NODE_CELL_FONT_SIZE)
|
|
146
|
-
);
|
|
147
|
-
maxMidW = Math.max(
|
|
148
|
-
maxMidW,
|
|
149
|
-
measureText(durStr, NODE_CELL_FONT_SIZE),
|
|
150
|
-
measureText(slackStr, NODE_CELL_FONT_SIZE)
|
|
151
|
-
);
|
|
152
|
-
maxNameW = Math.max(
|
|
153
|
-
maxNameW,
|
|
154
|
-
measureText(r.activity.name, NODE_NAME_FONT_SIZE)
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Also account for any collapsed-group rolled-up label width — the
|
|
159
|
-
// renderer draws those with the same textbook-card chrome, so their
|
|
160
|
-
// name needs to fit in the shared `activityWidth`.
|
|
161
|
-
for (const rg of resolved.groups) {
|
|
162
|
-
maxNameW = Math.max(
|
|
163
|
-
maxNameW,
|
|
164
|
-
measureText(rg.group.name, NODE_NAME_FONT_SIZE)
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const outerCell = Math.ceil(maxOuterW) + 2 * CELL_PAD_X;
|
|
169
|
-
const midCell = Math.ceil(maxMidW) + 2 * CELL_PAD_X;
|
|
170
|
-
const outerNeeded = Math.max(MIN_CELL_WIDTH, outerCell);
|
|
171
|
-
const midNeeded = Math.max(MIN_CELL_WIDTH, midCell);
|
|
172
|
-
const cellsTotalW = 2 * outerNeeded + midNeeded;
|
|
173
|
-
|
|
174
|
-
// The name dictates a lower bound too — anchor-pinned cards reserve
|
|
175
|
-
// a NAME_PIN_WIDTH on the left, so size for the worst case.
|
|
176
|
-
const nameTextW = Math.ceil(maxNameW);
|
|
177
|
-
const nameTotalW = nameTextW + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
|
|
178
|
-
|
|
179
|
-
const activityWidth = Math.max(
|
|
180
|
-
MIN_NODE_WIDTH,
|
|
181
|
-
Math.min(MAX_NODE_WIDTH, Math.max(cellsTotalW, nameTotalW))
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
// Distribute the chosen activityWidth across columns. The ES/EF/LS/LF
|
|
185
|
-
// pillars must always be the same dimension, so any slack between
|
|
186
|
-
// `cellsTotalW` and `activityWidth` (from MIN_NODE_WIDTH inflation or
|
|
187
|
-
// a long name) goes to the outer columns. The middle stays at its
|
|
188
|
-
// natural need so the dur/slack cells don't grow needlessly wide. If
|
|
189
|
-
// mid's natural need ever exceeds the outer share, fall back to three
|
|
190
|
-
// equal columns so the middle never reads as the widest cell.
|
|
191
|
-
let midColW = midNeeded;
|
|
192
|
-
let outerColW = (activityWidth - midColW) / 2;
|
|
193
|
-
if (midColW > outerColW) {
|
|
194
|
-
outerColW = activityWidth / 3;
|
|
195
|
-
midColW = activityWidth - 2 * outerColW;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Milestones are independent: a one-cell-tall date row, a name row,
|
|
199
|
-
// and (optionally) a slack row. Width should fit whichever is widest.
|
|
200
|
-
const mTop = Math.ceil(maxMilestoneTopW) + 2 * CELL_PAD_X;
|
|
201
|
-
const mSlack =
|
|
202
|
-
maxMilestoneSlackW > 0 ? Math.ceil(maxMilestoneSlackW) + 2 * CELL_PAD_X : 0;
|
|
203
|
-
// Milestone name renders at 12pt with anchor-pin reserve.
|
|
204
|
-
const mName = Math.ceil(maxMilestoneNameW) + NAME_PIN_WIDTH + 2 * NAME_PAD_X;
|
|
205
|
-
const milestoneWidth = Math.max(
|
|
206
|
-
MIN_MILESTONE_WIDTH,
|
|
207
|
-
Math.min(MAX_MILESTONE_WIDTH, Math.max(mTop, mSlack, mName))
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
return { activityWidth, milestoneWidth, outerColW, midColW };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function nodeDimensions(
|
|
214
|
-
resolved: ResolvedPert,
|
|
215
|
-
id: string,
|
|
216
|
-
sizing: NodeSizing,
|
|
217
|
-
overrides?: LayoutOverrides
|
|
218
|
-
): { width: number; height: number } {
|
|
219
|
-
if (overrides?.[id]) {
|
|
220
|
-
return { width: overrides[id].width, height: overrides[id].height };
|
|
221
|
-
}
|
|
222
|
-
const r = resolved.activities.find((a) => a.activity.id === id);
|
|
223
|
-
if (r?.activity.isMilestone) {
|
|
224
|
-
return { width: sizing.milestoneWidth, height: MILESTONE_NODE_HEIGHT };
|
|
225
|
-
}
|
|
226
|
-
return { width: sizing.activityWidth, height: DEFAULT_NODE_HEIGHT };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function layoutPert(resolved: ResolvedPert): LayoutResult {
|
|
230
|
-
return relayoutPert(resolved, {});
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function relayoutPert(
|
|
234
|
-
resolved: ResolvedPert,
|
|
235
|
-
overrides: LayoutOverrides,
|
|
236
|
-
collapsedGroupIds: ReadonlySet<string> = new Set()
|
|
237
|
-
): LayoutResult {
|
|
238
|
-
if (resolved.activities.length === 0) {
|
|
239
|
-
return { nodes: [], edges: [], groups: [], width: 0, height: 0 };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Build member-activity → collapsed-group lookup so we can replace
|
|
243
|
-
// member activities with a single virtual dagre node per group.
|
|
244
|
-
const memberToGroup = new Map<string, string>();
|
|
245
|
-
for (const rg of resolved.groups) {
|
|
246
|
-
if (collapsedGroupIds.has(rg.group.id)) {
|
|
247
|
-
for (const aid of rg.group.activityIds) {
|
|
248
|
-
memberToGroup.set(aid, rg.group.id);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
const dagreId = (id: string): string => memberToGroup.get(id) ?? id;
|
|
253
|
-
|
|
254
|
-
const g = new dagre.graphlib.Graph();
|
|
255
|
-
g.setGraph({
|
|
256
|
-
rankdir: resolved.options.direction,
|
|
257
|
-
nodesep: 50,
|
|
258
|
-
ranksep: 60,
|
|
259
|
-
edgesep: 18,
|
|
260
|
-
marginx: DIAGRAM_PADDING,
|
|
261
|
-
marginy: DIAGRAM_PADDING,
|
|
262
|
-
});
|
|
263
|
-
g.setDefaultEdgeLabel(() => ({}));
|
|
264
|
-
|
|
265
|
-
const sizing = computeNodeSizing(resolved);
|
|
266
|
-
|
|
267
|
-
// Add real activity nodes (skipping members of collapsed groups).
|
|
268
|
-
for (const r of resolved.activities) {
|
|
269
|
-
if (memberToGroup.has(r.activity.id)) continue;
|
|
270
|
-
const { width, height } = nodeDimensions(
|
|
271
|
-
resolved,
|
|
272
|
-
r.activity.id,
|
|
273
|
-
sizing,
|
|
274
|
-
overrides
|
|
275
|
-
);
|
|
276
|
-
g.setNode(r.activity.id, { width, height });
|
|
277
|
-
}
|
|
278
|
-
// Add one virtual node per collapsed group — uses the same shared
|
|
279
|
-
// activity width so the rolled-up envelope lines up with siblings.
|
|
280
|
-
for (const rg of resolved.groups) {
|
|
281
|
-
if (collapsedGroupIds.has(rg.group.id)) {
|
|
282
|
-
g.setNode(rg.group.id, {
|
|
283
|
-
width: sizing.activityWidth,
|
|
284
|
-
height: COLLAPSED_GROUP_HEIGHT,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Add edges; rewrite endpoints that land on collapsed members,
|
|
290
|
-
// dedupe, and drop edges that fold to a self-loop (internal-only
|
|
291
|
-
// edges of a collapsed group).
|
|
292
|
-
const seenEdges = new Set<string>();
|
|
293
|
-
for (const e of resolved.edges) {
|
|
294
|
-
const src = dagreId(e.source);
|
|
295
|
-
const tgt = dagreId(e.target);
|
|
296
|
-
if (src === tgt) continue;
|
|
297
|
-
const k = `${src}|${tgt}`;
|
|
298
|
-
if (seenEdges.has(k)) continue;
|
|
299
|
-
seenEdges.add(k);
|
|
300
|
-
g.setEdge(src, tgt, {});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
dagre.layout(g);
|
|
304
|
-
|
|
305
|
-
// When the diagram has any expanded group, switch to swim-lane layout:
|
|
306
|
-
// ungrouped activities merge into the largest group's lane (the
|
|
307
|
-
// "spine") so they share the same row as the spine's members, and any
|
|
308
|
-
// additional groups get their own horizontal band below — guarantees
|
|
309
|
-
// group rectangles never overlap and cross-group edges read as
|
|
310
|
-
// explicit lane transitions. Falls back to per-rank criticality
|
|
311
|
-
// centering when there are no groups (or all collapsed).
|
|
312
|
-
const swimApplied = applySwimLanes(
|
|
313
|
-
g,
|
|
314
|
-
resolved,
|
|
315
|
-
memberToGroup,
|
|
316
|
-
collapsedGroupIds
|
|
317
|
-
);
|
|
318
|
-
if (!swimApplied) {
|
|
319
|
-
// Pull critical-path activities toward the vertical center of each
|
|
320
|
-
// rank. Dagre orders nodes within a rank to minimize edge crossings,
|
|
321
|
-
// which is criticality-blind — so the dominant path drifts wherever
|
|
322
|
-
// dagre chose. After layout, swap y-positions per rank so the
|
|
323
|
-
// highest-criticality node sits in the middle and lower-criticality
|
|
324
|
-
// ones fan out to the edges.
|
|
325
|
-
centerByCriticality(g, resolved, memberToGroup, collapsedGroupIds);
|
|
326
|
-
// Criticality centering is crossing-blind — it can undo dagre's
|
|
327
|
-
// barycenter ordering and reintroduce edge crossings. Run a greedy
|
|
328
|
-
// adjacent-swap sweep that keeps any swap that reduces total edge
|
|
329
|
-
// crossings, preserving criticality-at-center where it doesn't
|
|
330
|
-
// conflict.
|
|
331
|
-
reduceCrossings(g, resolved.options.direction);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Output nodes — only un-collapsed activities (virtual group nodes
|
|
335
|
-
// are rendered through the `groups` channel, not here).
|
|
336
|
-
const nodes: PertLayoutNode[] = resolved.activities
|
|
337
|
-
.filter((r) => !memberToGroup.has(r.activity.id))
|
|
338
|
-
.map((r) => {
|
|
339
|
-
const pos = g.node(r.activity.id);
|
|
340
|
-
return {
|
|
341
|
-
id: r.activity.id,
|
|
342
|
-
x: pos.x,
|
|
343
|
-
y: pos.y,
|
|
344
|
-
width: pos.width,
|
|
345
|
-
height: pos.height,
|
|
346
|
-
};
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
// Output edges — one per dedup'd dagre edge. Endpoints already point
|
|
350
|
-
// at virtual group ids when a side is collapsed, which the renderer
|
|
351
|
-
// uses verbatim for `data-source` / `data-target`.
|
|
352
|
-
const edges = Array.from(seenEdges).map((k) => {
|
|
353
|
-
// In-bounds: `seenEdges` keys are always `source|target` (built via join).
|
|
354
|
-
const [source, target] = k.split('|') as [string, string];
|
|
355
|
-
const data = g.edge(source, target);
|
|
356
|
-
return { source, target, points: data?.points ?? [] };
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// Pool of every laid-out rect (real activities + virtual group nodes)
|
|
360
|
-
// for both diagram-bounds and group-rect math.
|
|
361
|
-
const nodeMap = new Map<
|
|
362
|
-
string,
|
|
363
|
-
{ x: number; y: number; width: number; height: number }
|
|
364
|
-
>();
|
|
365
|
-
for (const n of nodes) nodeMap.set(n.id, n);
|
|
366
|
-
for (const rg of resolved.groups) {
|
|
367
|
-
if (collapsedGroupIds.has(rg.group.id)) {
|
|
368
|
-
const vn = g.node(rg.group.id);
|
|
369
|
-
nodeMap.set(rg.group.id, {
|
|
370
|
-
x: vn.x,
|
|
371
|
-
y: vn.y,
|
|
372
|
-
width: vn.width,
|
|
373
|
-
height: vn.height,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const groups: PertLayoutGroup[] = resolved.groups.map((rg) => {
|
|
379
|
-
const classification = rg.group.classification ?? 'cluster';
|
|
380
|
-
if (collapsedGroupIds.has(rg.group.id)) {
|
|
381
|
-
const vn = nodeMap.get(rg.group.id);
|
|
382
|
-
if (!vn) {
|
|
383
|
-
return {
|
|
384
|
-
id: rg.group.id,
|
|
385
|
-
x: 0,
|
|
386
|
-
y: 0,
|
|
387
|
-
width: 0,
|
|
388
|
-
height: 0,
|
|
389
|
-
classification,
|
|
390
|
-
collapsed: true,
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
return {
|
|
394
|
-
id: rg.group.id,
|
|
395
|
-
x: vn.x - vn.width / 2,
|
|
396
|
-
y: vn.y - vn.height / 2,
|
|
397
|
-
width: vn.width,
|
|
398
|
-
height: vn.height,
|
|
399
|
-
classification,
|
|
400
|
-
collapsed: true,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
const members = rg.group.activityIds
|
|
405
|
-
.map((id) => nodeMap.get(id))
|
|
406
|
-
.filter(
|
|
407
|
-
(n): n is { x: number; y: number; width: number; height: number } =>
|
|
408
|
-
n !== undefined
|
|
409
|
-
);
|
|
410
|
-
|
|
411
|
-
if (members.length === 0) {
|
|
412
|
-
return {
|
|
413
|
-
id: rg.group.id,
|
|
414
|
-
x: 0,
|
|
415
|
-
y: 0,
|
|
416
|
-
width: 0,
|
|
417
|
-
height: 0,
|
|
418
|
-
classification,
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
let minX = Infinity;
|
|
423
|
-
let minY = Infinity;
|
|
424
|
-
let maxX = -Infinity;
|
|
425
|
-
let maxY = -Infinity;
|
|
426
|
-
for (const m of members) {
|
|
427
|
-
const left = m.x - m.width / 2;
|
|
428
|
-
const right = m.x + m.width / 2;
|
|
429
|
-
const top = m.y - m.height / 2;
|
|
430
|
-
const bottom = m.y + m.height / 2;
|
|
431
|
-
if (left < minX) minX = left;
|
|
432
|
-
if (right > maxX) maxX = right;
|
|
433
|
-
if (top < minY) minY = top;
|
|
434
|
-
if (bottom > maxY) maxY = bottom;
|
|
435
|
-
}
|
|
436
|
-
// Group rectangle is a tight bounding box around its members plus
|
|
437
|
-
// label-band + bottom padding. Swim-lane placement already guarantees
|
|
438
|
-
// non-overlap by giving each group a distinct slot band, so we don't
|
|
439
|
-
// need to extend the rect to the full lane height — that would just
|
|
440
|
-
// bake in empty space when the spine lane absorbs ungrouped members
|
|
441
|
-
// at ranks the spine doesn't span.
|
|
442
|
-
return {
|
|
443
|
-
id: rg.group.id,
|
|
444
|
-
x: minX - GROUP_PADDING,
|
|
445
|
-
y: minY - GROUP_TOP_PADDING,
|
|
446
|
-
width: maxX - minX + GROUP_PADDING * 2,
|
|
447
|
-
height: maxY - minY + GROUP_TOP_PADDING + GROUP_PADDING,
|
|
448
|
-
classification,
|
|
449
|
-
};
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
// Diagram bounds — include both nodes and group rects (collapsed
|
|
453
|
-
// groups contribute their virtual-node bounds via `groups`).
|
|
454
|
-
let totalW = 0;
|
|
455
|
-
let totalH = 0;
|
|
456
|
-
for (const n of nodes) {
|
|
457
|
-
const right = n.x + n.width / 2;
|
|
458
|
-
const bottom = n.y + n.height / 2;
|
|
459
|
-
if (right > totalW) totalW = right;
|
|
460
|
-
if (bottom > totalH) totalH = bottom;
|
|
461
|
-
}
|
|
462
|
-
for (const grp of groups) {
|
|
463
|
-
if (grp.x + grp.width > totalW) totalW = grp.x + grp.width;
|
|
464
|
-
if (grp.y + grp.height > totalH) totalH = grp.y + grp.height;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
return {
|
|
468
|
-
nodes,
|
|
469
|
-
edges,
|
|
470
|
-
groups,
|
|
471
|
-
width: totalW + DIAGRAM_PADDING,
|
|
472
|
-
height: totalH + DIAGRAM_PADDING,
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// ============================================================
|
|
477
|
-
// Swim-lane layout (post-process pass)
|
|
478
|
-
// ============================================================
|
|
479
|
-
//
|
|
480
|
-
// When the diagram declares any group, dagre's rank-only layout will
|
|
481
|
-
// scatter group members across ranks that the OTHER group also occupies,
|
|
482
|
-
// causing post-hoc bounding rectangles to overlap (the classic "two
|
|
483
|
-
// groups crammed into the same column band" failure). To fix this, we
|
|
484
|
-
// reassign positions along the slot axis so each group gets its own
|
|
485
|
-
// contiguous band along the slot axis. Dagre's rank assignments (rank
|
|
486
|
-
// axis) are preserved so the diagram still reads as a topological flow.
|
|
487
|
-
//
|
|
488
|
-
// Lane order: the SPINE lane (the largest expanded group by member
|
|
489
|
-
// count) goes first, with all ungrouped activities + collapsed-group
|
|
490
|
-
// cards merged into it so they share the spine's primary row — wreck-
|
|
491
|
-
// located, dive-ops member, log-inventory all sit at the same y in LR.
|
|
492
|
-
// Other expanded groups stack below in source-declaration order. The
|
|
493
|
-
// spine group's rectangle remains tight around its own members (not the
|
|
494
|
-
// merged-in ungrouped ones), so ungrouped nodes sit at the spine row
|
|
495
|
-
// but outside the rect, in the columns the spine doesn't span.
|
|
496
|
-
|
|
497
|
-
function applySwimLanes(
|
|
498
|
-
g: any,
|
|
499
|
-
resolved: ResolvedPert,
|
|
500
|
-
_memberToGroup: Map<string, string>,
|
|
501
|
-
collapsedGroupIds: ReadonlySet<string>
|
|
502
|
-
): boolean {
|
|
503
|
-
const expanded = resolved.groups.filter(
|
|
504
|
-
(rg) => !collapsedGroupIds.has(rg.group.id)
|
|
505
|
-
);
|
|
506
|
-
if (expanded.length === 0) return false;
|
|
507
|
-
|
|
508
|
-
const isLR = resolved.options.direction !== 'TB';
|
|
509
|
-
const rankAxis = isLR ? 'x' : 'y';
|
|
510
|
-
const slotAxis = isLR ? 'y' : 'x';
|
|
511
|
-
|
|
512
|
-
// Spine = largest expanded group; ungrouped activities (and collapsed-
|
|
513
|
-
// group cards) all merge into this lane so they ride the spine row.
|
|
514
|
-
// Tiebreak by source-declaration order (first largest wins).
|
|
515
|
-
let spineIdx = 1;
|
|
516
|
-
let spineMembers = 0;
|
|
517
|
-
expanded.forEach((rg, i) => {
|
|
518
|
-
if (rg.group.activityIds.length > spineMembers) {
|
|
519
|
-
spineMembers = rg.group.activityIds.length;
|
|
520
|
-
spineIdx = i + 1;
|
|
521
|
-
}
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// Lane index per node. 1..N = expanded groups in declaration order;
|
|
525
|
-
// ungrouped activities + collapsed-group cards get spineIdx so they
|
|
526
|
-
// share the band with the spine's members.
|
|
527
|
-
const laneOf = new Map<string, number>();
|
|
528
|
-
expanded.forEach((rg, i) => {
|
|
529
|
-
for (const aid of rg.group.activityIds) laneOf.set(aid, i + 1);
|
|
530
|
-
});
|
|
531
|
-
for (const id of g.nodes()) {
|
|
532
|
-
if (!laneOf.has(id)) laneOf.set(id, spineIdx);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// Track which nodes are "spine members" — group members of the spine
|
|
536
|
-
// (NOT the merged-in ungrouped nodes). This drives in-cell ordering:
|
|
537
|
-
// spine members sit at the top slots so they form a continuous row.
|
|
538
|
-
const spineMemberSet = new Set<string>(
|
|
539
|
-
expanded[spineIdx - 1]?.group.activityIds ?? []
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
// Bucket every dagre node by rank, then by lane within each rank.
|
|
543
|
-
const numLanes = expanded.length + 1; // index 0 unused (kept for clarity)
|
|
544
|
-
const rankBuckets = new Map<number, Map<number, string[]>>();
|
|
545
|
-
for (const id of g.nodes()) {
|
|
546
|
-
const node = g.node(id);
|
|
547
|
-
if (!node) continue;
|
|
548
|
-
const rankKey = Math.round(node[rankAxis] as number);
|
|
549
|
-
const laneIdx = laneOf.get(id) ?? spineIdx;
|
|
550
|
-
let byLane = rankBuckets.get(rankKey);
|
|
551
|
-
if (!byLane) {
|
|
552
|
-
byLane = new Map();
|
|
553
|
-
rankBuckets.set(rankKey, byLane);
|
|
554
|
-
}
|
|
555
|
-
if (!byLane.has(laneIdx)) byLane.set(laneIdx, []);
|
|
556
|
-
byLane.get(laneIdx)!.push(id);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Per-lane slot capacity = max members of that lane in any single rank.
|
|
560
|
-
const laneSlots = new Array<number>(numLanes).fill(0);
|
|
561
|
-
for (const byLane of rankBuckets.values()) {
|
|
562
|
-
for (const [li, members] of byLane) {
|
|
563
|
-
// In-bounds: laneSlots indexed by li from byLane which was keyed under numLanes.
|
|
564
|
-
if (members.length > laneSlots[li]!) laneSlots[li] = members.length;
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Slot size along the slot axis. All non-milestone activity / collapsed-
|
|
569
|
-
// group nodes share DEFAULT_NODE_HEIGHT in LR; in TB we use the uniform
|
|
570
|
-
// activityWidth (first node's width is representative).
|
|
571
|
-
const slotSize = isLR
|
|
572
|
-
? DEFAULT_NODE_HEIGHT
|
|
573
|
-
: (g.node(g.nodes()[0])?.width ?? DEFAULT_NODE_HEIGHT);
|
|
574
|
-
|
|
575
|
-
// Lane sizes (along the slot axis) = LABEL_PAD + slot strip + BOTTOM_PAD
|
|
576
|
-
// for any lane with at least one member; empty lanes contribute 0.
|
|
577
|
-
const laneSize: number[] = new Array<number>(numLanes).fill(0);
|
|
578
|
-
for (let i = 1; i < numLanes; i++) {
|
|
579
|
-
// In-bounds by loop guard.
|
|
580
|
-
const slots = laneSlots[i]!;
|
|
581
|
-
if (slots === 0) {
|
|
582
|
-
laneSize[i] = 0;
|
|
583
|
-
continue;
|
|
584
|
-
}
|
|
585
|
-
const slotsExtent =
|
|
586
|
-
slots * slotSize + Math.max(0, slots - 1) * SWIMLANE_SLOT_SEP;
|
|
587
|
-
laneSize[i] = GROUP_TOP_PADDING + slotsExtent + GROUP_PADDING;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// Lane start offsets along the slot axis (cumulative). Spine goes first
|
|
591
|
-
// so the spine row sits at the top of the diagram; other lanes stack
|
|
592
|
-
// below in declaration order.
|
|
593
|
-
const laneOrder: number[] = [
|
|
594
|
-
spineIdx,
|
|
595
|
-
...Array.from({ length: expanded.length }, (_, i) => i + 1).filter(
|
|
596
|
-
(i) => i !== spineIdx
|
|
597
|
-
),
|
|
598
|
-
];
|
|
599
|
-
const laneStart: number[] = new Array<number>(numLanes).fill(0);
|
|
600
|
-
let cursor = DIAGRAM_PADDING;
|
|
601
|
-
for (const li of laneOrder) {
|
|
602
|
-
laneStart[li] = cursor;
|
|
603
|
-
// In-bounds: laneSize is sized numLanes; li drawn from laneOrder which contains valid indices.
|
|
604
|
-
if (laneSize[li]! > 0) cursor += laneSize[li]! + SWIMLANE_GAP;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Criticality lookup — used as a tie-breaker when ordering peers in
|
|
608
|
-
// the same cell.
|
|
609
|
-
const critOf = (id: string): number => {
|
|
610
|
-
const r = resolved.activities.find((a) => a.activity.id === id);
|
|
611
|
-
if (r) {
|
|
612
|
-
return r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
|
|
613
|
-
}
|
|
614
|
-
const rg = resolved.groups.find((x) => x.group.id === id);
|
|
615
|
-
if (!rg) return 0;
|
|
616
|
-
let m = 0;
|
|
617
|
-
for (const aid of rg.group.activityIds) {
|
|
618
|
-
const ar = resolved.activities.find((a) => a.activity.id === aid);
|
|
619
|
-
if (!ar) continue;
|
|
620
|
-
const c =
|
|
621
|
-
ar.criticality !== null ? ar.criticality : ar.isCriticalPath ? 1 : 0;
|
|
622
|
-
if (c > m) m = c;
|
|
623
|
-
}
|
|
624
|
-
return m;
|
|
625
|
-
};
|
|
626
|
-
|
|
627
|
-
// Pre-shuffle slot positions for stable tie-breaking.
|
|
628
|
-
const oldSlot = new Map<string, number>();
|
|
629
|
-
for (const id of g.nodes()) {
|
|
630
|
-
oldSlot.set(id, g.node(id)![slotAxis] as number);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// Reposition each node into its (lane, rank) cell — packed from the
|
|
634
|
-
// band top so slot 1 stays at the same y across ranks (the "spine
|
|
635
|
-
// row"). Spine members win the top slot; ungrouped tag along beneath.
|
|
636
|
-
for (const byLane of rankBuckets.values()) {
|
|
637
|
-
for (const [li, members] of byLane) {
|
|
638
|
-
// In-bounds: laneStart sized numLanes; li drawn from the same lane-index domain.
|
|
639
|
-
const slotStripStart = laneStart[li]! + GROUP_TOP_PADDING;
|
|
640
|
-
|
|
641
|
-
const sorted = [...members].sort((a, b) => {
|
|
642
|
-
// Spine members first (so they own slot 1 — the spine row).
|
|
643
|
-
const aSpine = spineMemberSet.has(a) ? 0 : 1;
|
|
644
|
-
const bSpine = spineMemberSet.has(b) ? 0 : 1;
|
|
645
|
-
if (aSpine !== bSpine) return aSpine - bSpine;
|
|
646
|
-
const ca = critOf(a);
|
|
647
|
-
const cb = critOf(b);
|
|
648
|
-
if (cb !== ca) return cb - ca;
|
|
649
|
-
return (oldSlot.get(a) ?? 0) - (oldSlot.get(b) ?? 0);
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
sorted.forEach((id, idx) => {
|
|
653
|
-
const node = g.node(id);
|
|
654
|
-
node[slotAxis] =
|
|
655
|
-
slotStripStart + slotSize / 2 + idx * (slotSize + SWIMLANE_SLOT_SEP);
|
|
656
|
-
});
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Re-route every edge with a clean S-curve — almost every node moved
|
|
661
|
-
// along the slot axis, so dagre's original control points are now
|
|
662
|
-
// routed through empty space at best, through other nodes at worst.
|
|
663
|
-
const dir = resolved.options.direction;
|
|
664
|
-
for (const e of g.edges()) {
|
|
665
|
-
const src = g.node(e.v);
|
|
666
|
-
const tgt = g.node(e.w);
|
|
667
|
-
if (!src || !tgt) continue;
|
|
668
|
-
const data = g.edge(e);
|
|
669
|
-
if (!data) continue;
|
|
670
|
-
data.points = smoothEdge(src, tgt, dir);
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
return true;
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// ============================================================
|
|
677
|
-
// Criticality-aware vertical centering (post-process pass)
|
|
678
|
-
// ============================================================
|
|
679
|
-
//
|
|
680
|
-
// For each dagre rank (column in LR / row in TB), reorder nodes so the
|
|
681
|
-
// highest-criticality node occupies the middle slot and lower ones
|
|
682
|
-
// fan out symmetrically. Edges are kept in shape but their first/last
|
|
683
|
-
// y-coordinates shift to match the new endpoints, with a linear
|
|
684
|
-
// interpolation across the polyline so the curve still terminates on
|
|
685
|
-
// the new node centers.
|
|
686
|
-
|
|
687
|
-
// `@dagrejs/dagre` ships no type definitions, so `g` is typed as `any`
|
|
688
|
-
// here — every prop access (`nodes()`, `node(id)`, `edges()`, etc.) is
|
|
689
|
-
// untyped within this function.
|
|
690
|
-
|
|
691
|
-
function centerByCriticality(
|
|
692
|
-
g: any,
|
|
693
|
-
resolved: ResolvedPert,
|
|
694
|
-
memberToGroup: Map<string, string>,
|
|
695
|
-
collapsedGroupIds: ReadonlySet<string>
|
|
696
|
-
): void {
|
|
697
|
-
// Look up criticality per laid-out node. For activities we use the
|
|
698
|
-
// MC criticality index when present, falling back to binary critical-
|
|
699
|
-
// path membership in analytical mode. Collapsed groups inherit the
|
|
700
|
-
// max-of-members so they sort with their internal hottest path.
|
|
701
|
-
const critById = new Map<string, number>();
|
|
702
|
-
for (const r of resolved.activities) {
|
|
703
|
-
if (memberToGroup.has(r.activity.id)) continue;
|
|
704
|
-
const c = r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
|
|
705
|
-
critById.set(r.activity.id, c);
|
|
706
|
-
}
|
|
707
|
-
for (const rg of resolved.groups) {
|
|
708
|
-
if (!collapsedGroupIds.has(rg.group.id)) continue;
|
|
709
|
-
let maxC = 0;
|
|
710
|
-
for (const aid of rg.group.activityIds) {
|
|
711
|
-
const r = resolved.activities.find((x) => x.activity.id === aid);
|
|
712
|
-
if (!r) continue;
|
|
713
|
-
const c =
|
|
714
|
-
r.criticality !== null ? r.criticality : r.isCriticalPath ? 1 : 0;
|
|
715
|
-
if (c > maxC) maxC = c;
|
|
716
|
-
}
|
|
717
|
-
critById.set(rg.group.id, maxC);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// Bucket nodes by rank. dagre gives every node in a rank an identical
|
|
721
|
-
// x (LR) or y (TB) coordinate, so a single rounded key per axis is
|
|
722
|
-
// sufficient.
|
|
723
|
-
const rankAxis = resolved.options.direction === 'TB' ? 'y' : 'x';
|
|
724
|
-
const slotAxis = resolved.options.direction === 'TB' ? 'x' : 'y';
|
|
725
|
-
|
|
726
|
-
const buckets = new Map<number, string[]>();
|
|
727
|
-
for (const id of g.nodes()) {
|
|
728
|
-
const node = g.node(id);
|
|
729
|
-
if (!node) continue;
|
|
730
|
-
const key = Math.round(node[rankAxis] as number);
|
|
731
|
-
if (!buckets.has(key)) buckets.set(key, []);
|
|
732
|
-
buckets.get(key)!.push(id);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// For each rank with > 1 member, recompute slot assignments.
|
|
736
|
-
// `shifts` records the delta between the old slot value (along
|
|
737
|
-
// `slotAxis`) and the new one, so edges can be translated to match.
|
|
738
|
-
const shifts = new Map<string, number>();
|
|
739
|
-
|
|
740
|
-
for (const ids of buckets.values()) {
|
|
741
|
-
if (ids.length < 2) continue;
|
|
742
|
-
|
|
743
|
-
// Existing slot positions (sorted ascending = top→bottom in LR).
|
|
744
|
-
const slots = ids
|
|
745
|
-
.map((id) => g.node(id)![slotAxis] as number)
|
|
746
|
-
.sort((a, b) => a - b);
|
|
747
|
-
|
|
748
|
-
// Sort ids by criticality descending; tie-break by current slot
|
|
749
|
-
// so deterministic & stable when criticalities are equal.
|
|
750
|
-
const currentSlot = new Map<string, number>();
|
|
751
|
-
for (const id of ids) currentSlot.set(id, g.node(id)![slotAxis] as number);
|
|
752
|
-
const sortedDesc = [...ids].sort((a, b) => {
|
|
753
|
-
const ca = critById.get(a) ?? 0;
|
|
754
|
-
const cb = critById.get(b) ?? 0;
|
|
755
|
-
if (cb !== ca) return cb - ca;
|
|
756
|
-
return (currentSlot.get(a) ?? 0) - (currentSlot.get(b) ?? 0);
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
// Middle-out arrangement: highest goes to the middle, then alternate
|
|
760
|
-
// above/below for the next-ranked, so the lowest-criticality ends
|
|
761
|
-
// up at the outermost edges.
|
|
762
|
-
const top: string[] = [];
|
|
763
|
-
const bottom: string[] = [];
|
|
764
|
-
let middle: string | null = null;
|
|
765
|
-
sortedDesc.forEach((id, i) => {
|
|
766
|
-
if (i === 0) middle = id;
|
|
767
|
-
else if (i % 2 === 1) top.unshift(id);
|
|
768
|
-
else bottom.push(id);
|
|
769
|
-
});
|
|
770
|
-
const newOrder: string[] = middle
|
|
771
|
-
? [...top, middle, ...bottom]
|
|
772
|
-
: [...top, ...bottom];
|
|
773
|
-
|
|
774
|
-
newOrder.forEach((id, i) => {
|
|
775
|
-
const node = g.node(id);
|
|
776
|
-
const oldVal = node[slotAxis] as number;
|
|
777
|
-
// In-bounds: slots was built with same length as newOrder above.
|
|
778
|
-
const newVal = slots[i]!;
|
|
779
|
-
shifts.set(id, newVal - oldVal);
|
|
780
|
-
(node as Record<string, unknown>)[slotAxis] = newVal;
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
// Re-route every edge whose endpoints moved. Dagre routes edges
|
|
785
|
-
// through gaps between same-rank nodes; once we permute nodes within
|
|
786
|
-
// a rank those gaps no longer exist where the polyline expected, and
|
|
787
|
-
// a linear-shift hack would push control points through other nodes.
|
|
788
|
-
// Replace the polyline with a clean 4-control-point smooth curve
|
|
789
|
-
// (entry/exit are pulled along the flow axis so `curveBasis` gives a
|
|
790
|
-
// gentle horizontal-leaning S, matching dagre's typical edge shape).
|
|
791
|
-
if (shifts.size > 0) {
|
|
792
|
-
const dir = resolved.options.direction;
|
|
793
|
-
for (const e of g.edges()) {
|
|
794
|
-
// Only regenerate edges where at least one endpoint moved, OR
|
|
795
|
-
// an edge between two ranks where any node moved (so that
|
|
796
|
-
// unaffected edges keep dagre's nicer multi-rank routing).
|
|
797
|
-
if (!shifts.has(e.v) && !shifts.has(e.w)) continue;
|
|
798
|
-
const src = g.node(e.v);
|
|
799
|
-
const tgt = g.node(e.w);
|
|
800
|
-
if (!src || !tgt) continue;
|
|
801
|
-
const data = g.edge(e);
|
|
802
|
-
if (!data) continue;
|
|
803
|
-
data.points = smoothEdge(src, tgt, dir);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/**
|
|
809
|
-
* Build a 4-control-point polyline that exits the source rectangle
|
|
810
|
-
* along the flow axis, enters the target rectangle the same way, and
|
|
811
|
-
* smooths under `curveBasis` into a gentle S-curve. Falls back to a
|
|
812
|
-
* straight line when source and target are colinear along the flow.
|
|
813
|
-
*/
|
|
814
|
-
function smoothEdge(
|
|
815
|
-
src: { x: number; y: number; width: number; height: number },
|
|
816
|
-
tgt: { x: number; y: number; width: number; height: number },
|
|
817
|
-
direction: PertDirection
|
|
818
|
-
): { x: number; y: number }[] {
|
|
819
|
-
if (direction === 'TB') {
|
|
820
|
-
const sy = src.y + src.height / 2;
|
|
821
|
-
const ty = tgt.y - tgt.height / 2;
|
|
822
|
-
const span = ty - sy;
|
|
823
|
-
const pad = Math.max(20, Math.abs(span) * 0.3);
|
|
824
|
-
return [
|
|
825
|
-
{ x: src.x, y: sy },
|
|
826
|
-
{ x: src.x, y: sy + Math.sign(span || 1) * pad },
|
|
827
|
-
{ x: tgt.x, y: ty - Math.sign(span || 1) * pad },
|
|
828
|
-
{ x: tgt.x, y: ty },
|
|
829
|
-
];
|
|
830
|
-
}
|
|
831
|
-
// LR
|
|
832
|
-
const sx = src.x + src.width / 2;
|
|
833
|
-
const tx = tgt.x - tgt.width / 2;
|
|
834
|
-
const span = tx - sx;
|
|
835
|
-
const pad = Math.max(20, Math.abs(span) * 0.3);
|
|
836
|
-
return [
|
|
837
|
-
{ x: sx, y: src.y },
|
|
838
|
-
{ x: sx + Math.sign(span || 1) * pad, y: src.y },
|
|
839
|
-
{ x: tx - Math.sign(span || 1) * pad, y: tgt.y },
|
|
840
|
-
{ x: tx, y: tgt.y },
|
|
841
|
-
];
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// ============================================================
|
|
845
|
-
// Crossing reduction (post-process pass)
|
|
846
|
-
// ============================================================
|
|
847
|
-
//
|
|
848
|
-
// Greedy adjacent-swap sweep that runs after `centerByCriticality` so
|
|
849
|
-
// crossings introduced by criticality permutation can be reversed when
|
|
850
|
-
// the swap reduces total edge crossings. Edges are modeled as straight
|
|
851
|
-
// segments between node centers — geometrically exact for the polyline
|
|
852
|
-
// shape `smoothEdge` produces (the curve only deflects along the flow
|
|
853
|
-
// axis, not the slot axis, so two edges cross visually iff their slot-
|
|
854
|
-
// axis center segments cross). Caps iterations to bound cost.
|
|
855
|
-
|
|
856
|
-
function reduceCrossings(g: any, direction: PertDirection): void {
|
|
857
|
-
const isLR = direction !== 'TB';
|
|
858
|
-
const rankAxis = isLR ? 'x' : 'y';
|
|
859
|
-
const slotAxis = isLR ? 'y' : 'x';
|
|
860
|
-
|
|
861
|
-
// Bucket nodes by rank.
|
|
862
|
-
const buckets = new Map<number, string[]>();
|
|
863
|
-
for (const id of g.nodes()) {
|
|
864
|
-
const node = g.node(id);
|
|
865
|
-
if (!node) continue;
|
|
866
|
-
const key = Math.round(node[rankAxis] as number);
|
|
867
|
-
if (!buckets.has(key)) buckets.set(key, []);
|
|
868
|
-
buckets.get(key)!.push(id);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// Collect edges once — each is a pair of node ids whose current
|
|
872
|
-
// positions we read on every count.
|
|
873
|
-
const edges: Array<{ v: string; w: string }> = g
|
|
874
|
-
.edges()
|
|
875
|
-
.map((e: { v: string; w: string }) => ({ v: e.v, w: e.w }));
|
|
876
|
-
|
|
877
|
-
const countCrossings = (): number => {
|
|
878
|
-
let total = 0;
|
|
879
|
-
for (let i = 0; i < edges.length; i++) {
|
|
880
|
-
// In-bounds by loop guard.
|
|
881
|
-
const a = edges[i]!;
|
|
882
|
-
const a1 = g.node(a.v);
|
|
883
|
-
const a2 = g.node(a.w);
|
|
884
|
-
if (!a1 || !a2) continue;
|
|
885
|
-
for (let j = i + 1; j < edges.length; j++) {
|
|
886
|
-
// In-bounds by loop guard.
|
|
887
|
-
const b = edges[j]!;
|
|
888
|
-
// Shared endpoint → segments meet at a node, not a crossing.
|
|
889
|
-
if (a.v === b.v || a.v === b.w || a.w === b.v || a.w === b.w) continue;
|
|
890
|
-
const b1 = g.node(b.v);
|
|
891
|
-
const b2 = g.node(b.w);
|
|
892
|
-
if (!b1 || !b2) continue;
|
|
893
|
-
if (segmentsCross(a1, a2, b1, b2)) total++;
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
return total;
|
|
897
|
-
};
|
|
898
|
-
|
|
899
|
-
// Cap iterations — bubble-style sweep converges fast on small DAGs,
|
|
900
|
-
// but a pathological case shouldn't spend O(rank * pairs) per pass
|
|
901
|
-
// indefinitely.
|
|
902
|
-
const MAX_ITER = 8;
|
|
903
|
-
let baseline = countCrossings();
|
|
904
|
-
if (baseline === 0) return;
|
|
905
|
-
|
|
906
|
-
for (let iter = 0; iter < MAX_ITER; iter++) {
|
|
907
|
-
let improved = false;
|
|
908
|
-
for (const ids of buckets.values()) {
|
|
909
|
-
if (ids.length < 2) continue;
|
|
910
|
-
const sorted = ids
|
|
911
|
-
.slice()
|
|
912
|
-
.sort(
|
|
913
|
-
(a, b) =>
|
|
914
|
-
(g.node(a)![slotAxis] as number) - (g.node(b)![slotAxis] as number)
|
|
915
|
-
);
|
|
916
|
-
for (let i = 0; i < sorted.length - 1; i++) {
|
|
917
|
-
const a = sorted[i];
|
|
918
|
-
const b = sorted[i + 1];
|
|
919
|
-
const an = g.node(a)!;
|
|
920
|
-
const bn = g.node(b)!;
|
|
921
|
-
const av = an[slotAxis] as number;
|
|
922
|
-
const bv = bn[slotAxis] as number;
|
|
923
|
-
an[slotAxis] = bv;
|
|
924
|
-
bn[slotAxis] = av;
|
|
925
|
-
const after = countCrossings();
|
|
926
|
-
if (after < baseline) {
|
|
927
|
-
baseline = after;
|
|
928
|
-
improved = true;
|
|
929
|
-
} else {
|
|
930
|
-
an[slotAxis] = av;
|
|
931
|
-
bn[slotAxis] = bv;
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
if (!improved || baseline === 0) break;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
// Re-route every edge — node slot positions changed, so dagre's
|
|
939
|
-
// original control points no longer connect cleanly.
|
|
940
|
-
for (const e of g.edges()) {
|
|
941
|
-
const src = g.node(e.v);
|
|
942
|
-
const tgt = g.node(e.w);
|
|
943
|
-
if (!src || !tgt) continue;
|
|
944
|
-
const data = g.edge(e);
|
|
945
|
-
if (!data) continue;
|
|
946
|
-
data.points = smoothEdge(src, tgt, direction);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// Standard CCW segment-intersection test. Returns true only for proper
|
|
951
|
-
// (non-collinear, non-endpoint-shared) crossings.
|
|
952
|
-
function segmentsCross(
|
|
953
|
-
a1: { x: number; y: number },
|
|
954
|
-
a2: { x: number; y: number },
|
|
955
|
-
b1: { x: number; y: number },
|
|
956
|
-
b2: { x: number; y: number }
|
|
957
|
-
): boolean {
|
|
958
|
-
const ccw = (
|
|
959
|
-
p: { x: number; y: number },
|
|
960
|
-
q: { x: number; y: number },
|
|
961
|
-
r: { x: number; y: number }
|
|
962
|
-
) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
|
963
|
-
const d1 = ccw(b1, b2, a1);
|
|
964
|
-
const d2 = ccw(b1, b2, a2);
|
|
965
|
-
const d3 = ccw(a1, a2, b1);
|
|
966
|
-
const d4 = ccw(a1, a2, b2);
|
|
967
|
-
return (
|
|
968
|
-
((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) &&
|
|
969
|
-
((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
|
|
970
|
-
);
|
|
971
|
-
}
|