@typeonce/effect-machine-devtools 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -0
- package/README.md +14 -8
- package/dist/DevServer.d.ts +2 -1
- package/dist/DevServer.d.ts.map +1 -1
- package/dist/DevServer.js.map +1 -1
- package/dist/DevToolsProtocol.d.ts +167 -17
- package/dist/DevToolsProtocol.d.ts.map +1 -1
- package/dist/DevToolsProtocol.js +1 -1
- package/dist/MachineDocument.d.ts +98 -2
- package/dist/MachineDocument.d.ts.map +1 -1
- package/dist/MachineDocument.js +35 -1
- package/dist/MachineDocument.js.map +1 -1
- package/dist/MachineRegistry.d.ts +56 -6
- package/dist/MachineRegistry.d.ts.map +1 -1
- package/dist/MachineWalkthrough.d.ts +171 -0
- package/dist/MachineWalkthrough.d.ts.map +1 -0
- package/dist/MachineWalkthrough.js +98 -0
- package/dist/MachineWalkthrough.js.map +1 -0
- package/dist/client/assets/index-BTOAhezX.js +37 -0
- package/dist/client/assets/index-Cg7xzSkz.css +1 -0
- package/dist/client/index.html +4 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/devServer.d.ts +2 -1
- package/dist/internal/devServer.d.ts.map +1 -1
- package/dist/internal/devServer.js +4 -3
- package/dist/internal/devServer.js.map +1 -1
- package/dist/internal/evaluationWorker.d.ts.map +1 -1
- package/dist/internal/evaluationWorker.js +28 -9
- package/dist/internal/evaluationWorker.js.map +1 -1
- package/dist/internal/machineDocument.d.ts.map +1 -1
- package/dist/internal/machineDocument.js +65 -2
- package/dist/internal/machineDocument.js.map +1 -1
- package/dist/internal/machineWalkthrough.d.ts +26 -0
- package/dist/internal/machineWalkthrough.d.ts.map +1 -0
- package/dist/internal/machineWalkthrough.js +256 -0
- package/dist/internal/machineWalkthrough.js.map +1 -0
- package/dist/internal/projectInspector.d.ts.map +1 -1
- package/dist/internal/projectInspector.js +15 -9
- package/dist/internal/projectInspector.js.map +1 -1
- package/index.html +2 -2
- package/package.json +4 -3
- package/src/DevServer.ts +6 -2
- package/src/DevToolsProtocol.ts +1 -1
- package/src/MachineDocument.ts +56 -1
- package/src/MachineWalkthrough.ts +199 -0
- package/src/index.ts +2 -2
- package/src/internal/browser/chart-layout.ts +433 -0
- package/src/internal/browser/chart-model.ts +234 -0
- package/src/internal/browser/chart-renderer.ts +661 -0
- package/src/internal/browser/example-machine.ts +15 -6
- package/src/internal/browser/input-form.ts +252 -0
- package/src/internal/browser/invoke-outcomes-example.ts +231 -0
- package/src/internal/browser/parallel-completion-example.ts +199 -0
- package/src/internal/browser/planner-example.ts +145 -0
- package/src/internal/browser/protocol-events-example.ts +200 -0
- package/src/internal/browser/styles.css +959 -185
- package/src/internal/browser/transition-semantics-example.ts +236 -0
- package/src/internal/browser/visualizer-app.ts +818 -389
- package/src/internal/browser/visualizer-model.ts +104 -0
- package/src/internal/browser/visualizer.ts +1 -1
- package/src/internal/devServer.ts +9 -4
- package/src/internal/evaluationWorker.ts +28 -10
- package/src/internal/machineDocument.ts +77 -2
- package/src/internal/machineWalkthrough.ts +355 -0
- package/src/internal/projectInspector.ts +29 -14
- package/dist/MachineSimulator.d.ts +0 -169
- package/dist/MachineSimulator.d.ts.map +0 -1
- package/dist/MachineSimulator.js +0 -98
- package/dist/MachineSimulator.js.map +0 -1
- package/dist/client/assets/index-BGOhE3Ng.css +0 -1
- package/dist/client/assets/index-DiqGhsGR.js +0 -14
- package/dist/internal/machineSimulator.d.ts +0 -5
- package/dist/internal/machineSimulator.d.ts.map +0 -1
- package/dist/internal/machineSimulator.js +0 -156
- package/dist/internal/machineSimulator.js.map +0 -1
- package/src/MachineSimulator.ts +0 -154
- package/src/internal/machineSimulator.ts +0 -199
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side-effect-free topology walkthroughs over machine documents.
|
|
3
|
+
*
|
|
4
|
+
* @since 0.25.0
|
|
5
|
+
*/
|
|
6
|
+
import { dual } from "effect/Function"
|
|
7
|
+
import * as Result from "effect/Result"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import * as internal from "./internal/machineWalkthrough.js"
|
|
10
|
+
import type * as MachineDocument from "./MachineDocument.js"
|
|
11
|
+
|
|
12
|
+
const TypeId = internal.SessionTypeId
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An immutable walkthrough session. Its timeline can be inspected and its
|
|
16
|
+
* cursor can move without evaluating machine callbacks.
|
|
17
|
+
*
|
|
18
|
+
* @category models
|
|
19
|
+
* @since 0.25.0
|
|
20
|
+
*/
|
|
21
|
+
export interface Session {
|
|
22
|
+
readonly [TypeId]: typeof TypeId
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why taking a documented branch requires an explicit human decision.
|
|
27
|
+
*
|
|
28
|
+
* @category models
|
|
29
|
+
* @since 0.25.0
|
|
30
|
+
*/
|
|
31
|
+
export type Decision =
|
|
32
|
+
| "conditional-branch"
|
|
33
|
+
| "declinable-transition"
|
|
34
|
+
| "automatic-trigger"
|
|
35
|
+
| "invoke-outcome"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a documented branch cannot advance topology without runtime data.
|
|
39
|
+
*
|
|
40
|
+
* @category models
|
|
41
|
+
* @since 0.25.0
|
|
42
|
+
*/
|
|
43
|
+
export type UnavailableReason = "history-unavailable" | "runtime-target"
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One branch that can be explored from the current active configuration.
|
|
47
|
+
*
|
|
48
|
+
* @category models
|
|
49
|
+
* @since 0.25.0
|
|
50
|
+
*/
|
|
51
|
+
export interface Choice {
|
|
52
|
+
readonly id: string
|
|
53
|
+
readonly transitionId: string
|
|
54
|
+
readonly branchId: string
|
|
55
|
+
readonly branchIndex: number
|
|
56
|
+
readonly branchKey: string | null
|
|
57
|
+
readonly title: string | null
|
|
58
|
+
readonly source: string
|
|
59
|
+
readonly trigger: MachineDocument.Trigger
|
|
60
|
+
readonly target: string | null
|
|
61
|
+
readonly selection: MachineDocument.Selection
|
|
62
|
+
readonly updates: ReadonlyArray<string>
|
|
63
|
+
readonly decisions: ReadonlyArray<Decision>
|
|
64
|
+
readonly unavailableReason: UnavailableReason | null
|
|
65
|
+
readonly input: MachineDocument.InputSchema | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The active state paths at one point in a walkthrough.
|
|
70
|
+
*
|
|
71
|
+
* @category models
|
|
72
|
+
* @since 0.25.0
|
|
73
|
+
*/
|
|
74
|
+
export interface Snapshot {
|
|
75
|
+
readonly activePaths: ReadonlyArray<string>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One initial or manually selected topology step.
|
|
80
|
+
*
|
|
81
|
+
* @category models
|
|
82
|
+
* @since 0.25.0
|
|
83
|
+
*/
|
|
84
|
+
export interface Frame {
|
|
85
|
+
readonly step: number
|
|
86
|
+
readonly choice: Choice | null
|
|
87
|
+
readonly before: Snapshot
|
|
88
|
+
readonly after: Snapshot
|
|
89
|
+
readonly exitPaths: ReadonlyArray<string>
|
|
90
|
+
readonly entryPaths: ReadonlyArray<string>
|
|
91
|
+
readonly changed: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A requested choice is not available from the cursor's configuration.
|
|
96
|
+
*
|
|
97
|
+
* @category errors
|
|
98
|
+
* @since 0.25.0
|
|
99
|
+
*/
|
|
100
|
+
export class ChoiceNotFound extends Schema.Error<ChoiceNotFound>(
|
|
101
|
+
"@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceNotFound"
|
|
102
|
+
)({
|
|
103
|
+
_tag: Schema.tag("ChoiceNotFound"),
|
|
104
|
+
choiceId: Schema.String
|
|
105
|
+
}) {}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A choice depends on runtime information absent from the machine document.
|
|
109
|
+
*
|
|
110
|
+
* @category errors
|
|
111
|
+
* @since 0.25.0
|
|
112
|
+
*/
|
|
113
|
+
export class ChoiceUnavailable extends Schema.Error<ChoiceUnavailable>(
|
|
114
|
+
"@typeonce/effect-machine-devtools/MachineWalkthrough/ChoiceUnavailable"
|
|
115
|
+
)({
|
|
116
|
+
_tag: Schema.tag("ChoiceUnavailable"),
|
|
117
|
+
choiceId: Schema.String,
|
|
118
|
+
reason: Schema.Literals(["history-unavailable", "runtime-target"])
|
|
119
|
+
}) {}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A requested timeline step does not exist.
|
|
123
|
+
*
|
|
124
|
+
* @category errors
|
|
125
|
+
* @since 0.25.0
|
|
126
|
+
*/
|
|
127
|
+
export class StepNotFound extends Schema.Error<StepNotFound>(
|
|
128
|
+
"@typeonce/effect-machine-devtools/MachineWalkthrough/StepNotFound"
|
|
129
|
+
)({
|
|
130
|
+
_tag: Schema.tag("StepNotFound"),
|
|
131
|
+
step: Schema.Number
|
|
132
|
+
}) {}
|
|
133
|
+
|
|
134
|
+
const api = { ChoiceNotFound, ChoiceUnavailable, StepNotFound }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Starts at the document's captured snapshot when present, otherwise at its
|
|
138
|
+
* statically declared initial configuration.
|
|
139
|
+
*
|
|
140
|
+
* @category constructors
|
|
141
|
+
* @since 0.25.0
|
|
142
|
+
*/
|
|
143
|
+
export const start: (document: MachineDocument.MachineDocument) => Session = internal.start
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Returns the frame selected by the session cursor.
|
|
147
|
+
*
|
|
148
|
+
* @category getters
|
|
149
|
+
* @since 0.25.0
|
|
150
|
+
*/
|
|
151
|
+
export const current: (self: Session) => Frame = internal.current
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Returns every retained frame, including frames after the current cursor.
|
|
155
|
+
*
|
|
156
|
+
* @category getters
|
|
157
|
+
* @since 0.25.0
|
|
158
|
+
*/
|
|
159
|
+
export const timeline: (self: Session) => ReadonlyArray<Frame> = internal.timeline
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Returns the current zero-based timeline position.
|
|
163
|
+
*
|
|
164
|
+
* @category getters
|
|
165
|
+
* @since 0.25.0
|
|
166
|
+
*/
|
|
167
|
+
export const cursor: (self: Session) => number = internal.cursor
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Lists documented branches whose sources are active at the current cursor.
|
|
171
|
+
* Runtime-dependent choices remain visible with an unavailable reason.
|
|
172
|
+
*
|
|
173
|
+
* @category getters
|
|
174
|
+
* @since 0.25.0
|
|
175
|
+
*/
|
|
176
|
+
export const choices: (self: Session) => ReadonlyArray<Choice> = internal.choices
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Takes one documented branch. Taking a branch from the past truncates the
|
|
180
|
+
* future timeline before appending the new frame.
|
|
181
|
+
*
|
|
182
|
+
* @category combinators
|
|
183
|
+
* @since 0.25.0
|
|
184
|
+
*/
|
|
185
|
+
export const take: {
|
|
186
|
+
(choiceId: string): (self: Session) => Result.Result<Session, ChoiceNotFound | ChoiceUnavailable>
|
|
187
|
+
(self: Session, choiceId: string): Result.Result<Session, ChoiceNotFound | ChoiceUnavailable>
|
|
188
|
+
} = dual(2, internal.take(api))
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Moves the cursor to an existing frame without changing the timeline.
|
|
192
|
+
*
|
|
193
|
+
* @category combinators
|
|
194
|
+
* @since 0.25.0
|
|
195
|
+
*/
|
|
196
|
+
export const seek: {
|
|
197
|
+
(step: number): (self: Session) => Result.Result<Session, StepNotFound>
|
|
198
|
+
(self: Session, step: number): Result.Result<Session, StepNotFound>
|
|
199
|
+
} = dual(2, internal.seek(api))
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,6 @@ export * as DevToolsProtocol from "./DevToolsProtocol.js"
|
|
|
15
15
|
export * as MachineDocument from "./MachineDocument.js"
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* @since 0.
|
|
18
|
+
* @since 0.25.0
|
|
19
19
|
*/
|
|
20
|
-
export * as
|
|
20
|
+
export * as MachineWalkthrough from "./MachineWalkthrough.js"
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import * as Data from "effect/Data"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import type {
|
|
4
|
+
ELK as ElkApi,
|
|
5
|
+
ELKConstructorArguments,
|
|
6
|
+
ElkExtendedEdge,
|
|
7
|
+
ElkNode,
|
|
8
|
+
ElkPoint,
|
|
9
|
+
ElkPort
|
|
10
|
+
} from "elkjs/lib/elk-api.js"
|
|
11
|
+
import ELKBundle from "elkjs/lib/elk.bundled.js"
|
|
12
|
+
import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js"
|
|
13
|
+
|
|
14
|
+
export const maxVisibleFields = 4
|
|
15
|
+
export const maxVisibleActivities = 3
|
|
16
|
+
|
|
17
|
+
export interface ChartPoint {
|
|
18
|
+
readonly x: number
|
|
19
|
+
readonly y: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LaidOutChartNode {
|
|
23
|
+
readonly node: ChartNode
|
|
24
|
+
readonly x: number
|
|
25
|
+
readonly y: number
|
|
26
|
+
readonly width: number
|
|
27
|
+
readonly height: number
|
|
28
|
+
readonly headerHeight: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LaidOutChartInitial {
|
|
32
|
+
readonly initial: ChartInitial
|
|
33
|
+
readonly x: number
|
|
34
|
+
readonly y: number
|
|
35
|
+
readonly width: number
|
|
36
|
+
readonly height: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LaidOutChartRuntimeTarget {
|
|
40
|
+
readonly target: ChartRuntimeTarget
|
|
41
|
+
readonly x: number
|
|
42
|
+
readonly y: number
|
|
43
|
+
readonly width: number
|
|
44
|
+
readonly height: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LaidOutChartTransition {
|
|
48
|
+
readonly kind: "transition"
|
|
49
|
+
readonly edge: ChartEdge
|
|
50
|
+
readonly points: ReadonlyArray<ChartPoint>
|
|
51
|
+
readonly label: ChartPoint
|
|
52
|
+
readonly labelWidth: number
|
|
53
|
+
readonly labelHeight: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface LaidOutChartInitialEdge {
|
|
57
|
+
readonly kind: "initial"
|
|
58
|
+
readonly initial: ChartInitial
|
|
59
|
+
readonly points: ReadonlyArray<ChartPoint>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LaidOutChart {
|
|
63
|
+
readonly width: number
|
|
64
|
+
readonly height: number
|
|
65
|
+
readonly nodes: ReadonlyArray<LaidOutChartNode>
|
|
66
|
+
readonly initials: ReadonlyArray<LaidOutChartInitial>
|
|
67
|
+
readonly runtimeTargets: ReadonlyArray<LaidOutChartRuntimeTarget>
|
|
68
|
+
readonly edges: ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ChartLayoutError extends Data.TaggedError("ChartLayoutError")<{
|
|
72
|
+
readonly cause: unknown
|
|
73
|
+
}> {}
|
|
74
|
+
|
|
75
|
+
interface NodeMetric {
|
|
76
|
+
readonly width: number
|
|
77
|
+
readonly height: number
|
|
78
|
+
readonly headerHeight: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sectionHeight = (length: number, limit: number): number => {
|
|
82
|
+
if (length === 0) return 0
|
|
83
|
+
const visible = Math.min(length, limit)
|
|
84
|
+
return 24 + visible * 22 + (length > limit ? 18 : 0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nodeMetric = (node: ChartNode): NodeMetric => {
|
|
88
|
+
const headerHeight = Math.max(
|
|
89
|
+
78,
|
|
90
|
+
76 +
|
|
91
|
+
sectionHeight(node.fields.length, maxVisibleFields) +
|
|
92
|
+
sectionHeight(node.activities.length, maxVisibleActivities)
|
|
93
|
+
)
|
|
94
|
+
const width = node.type === "choice" || node.type === "history" ? 176 : node.children.length > 0 ? 340 : 276
|
|
95
|
+
return {
|
|
96
|
+
width,
|
|
97
|
+
height: node.children.length === 0 ? headerHeight : Math.max(240, headerHeight + 104),
|
|
98
|
+
headerHeight
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi
|
|
103
|
+
const elk = new ELK()
|
|
104
|
+
|
|
105
|
+
const sourcePortId = (edge: ChartEdge): string => `port:${edge.id}:source`
|
|
106
|
+
const targetPortId = (edge: ChartEdge): string => `port:${edge.id}:target`
|
|
107
|
+
const initialNodeId = (initial: ChartInitial): string => `node:${initial.id}`
|
|
108
|
+
const initialTargetPortId = (initial: ChartInitial): string => `port:${initial.id}:target`
|
|
109
|
+
const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id}`
|
|
110
|
+
const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target`
|
|
111
|
+
|
|
112
|
+
const portsByState = (model: ChartModel): ReadonlyMap<string, ReadonlyArray<ElkPort>> => {
|
|
113
|
+
const ports = new Map<string, Array<ElkPort>>()
|
|
114
|
+
const add = (path: string, id: string, side: "EAST" | "WEST"): void => {
|
|
115
|
+
const statePorts = ports.get(path) ?? []
|
|
116
|
+
statePorts.push({
|
|
117
|
+
id,
|
|
118
|
+
width: 6,
|
|
119
|
+
height: 6,
|
|
120
|
+
layoutOptions: {
|
|
121
|
+
"elk.port.side": side,
|
|
122
|
+
"elk.port.index": String(statePorts.length)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
ports.set(path, statePorts)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const edge of model.edges) {
|
|
129
|
+
add(edge.source, sourcePortId(edge), "EAST")
|
|
130
|
+
if (edge.kind === "target" && edge.target !== null) {
|
|
131
|
+
add(edge.target, targetPortId(edge), "WEST")
|
|
132
|
+
} else if (edge.kind === "targetless") {
|
|
133
|
+
add(edge.source, targetPortId(edge), "EAST")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const initial of model.initials) add(initial.target, initialTargetPortId(initial), "WEST")
|
|
137
|
+
return ports
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const labelMetric = (label: string): { readonly width: number; readonly height: number } => ({
|
|
141
|
+
width: Math.min(230, Math.max(72, label.length * 7 + 20)),
|
|
142
|
+
height: 26
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const makeGraph = (model: ChartModel): ElkNode => {
|
|
146
|
+
const nodesByParent = new Map<string | null, Array<ChartNode>>()
|
|
147
|
+
for (const node of model.nodes) {
|
|
148
|
+
const siblings = nodesByParent.get(node.parent) ?? []
|
|
149
|
+
siblings.push(node)
|
|
150
|
+
nodesByParent.set(node.parent, siblings)
|
|
151
|
+
}
|
|
152
|
+
const initialsByParent = new Map<string | null, Array<ChartInitial>>()
|
|
153
|
+
for (const initial of model.initials) {
|
|
154
|
+
const siblings = initialsByParent.get(initial.parent) ?? []
|
|
155
|
+
siblings.push(initial)
|
|
156
|
+
initialsByParent.set(initial.parent, siblings)
|
|
157
|
+
}
|
|
158
|
+
const runtimeTargetsByParent = new Map<string | null, Array<ChartRuntimeTarget>>()
|
|
159
|
+
for (const target of model.runtimeTargets) {
|
|
160
|
+
const siblings = runtimeTargetsByParent.get(target.parent) ?? []
|
|
161
|
+
siblings.push(target)
|
|
162
|
+
runtimeTargetsByParent.set(target.parent, siblings)
|
|
163
|
+
}
|
|
164
|
+
const ports = portsByState(model)
|
|
165
|
+
|
|
166
|
+
const children = (parent: string | null): Array<ElkNode> => [
|
|
167
|
+
...(initialsByParent.get(parent) ?? []).map((initial): ElkNode => ({
|
|
168
|
+
id: initialNodeId(initial),
|
|
169
|
+
width: 14,
|
|
170
|
+
height: 14
|
|
171
|
+
})),
|
|
172
|
+
...(nodesByParent.get(parent) ?? []).map((node): ElkNode => {
|
|
173
|
+
const metric = nodeMetric(node)
|
|
174
|
+
const descendants = children(node.path)
|
|
175
|
+
const common = {
|
|
176
|
+
id: node.path,
|
|
177
|
+
ports: [...ports.get(node.path) ?? []]
|
|
178
|
+
}
|
|
179
|
+
if (descendants.length === 0) {
|
|
180
|
+
return {
|
|
181
|
+
...common,
|
|
182
|
+
width: metric.width,
|
|
183
|
+
height: metric.height,
|
|
184
|
+
layoutOptions: {
|
|
185
|
+
"elk.portConstraints": "FIXED_ORDER",
|
|
186
|
+
"elk.spacing.portPort": "22"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
...common,
|
|
192
|
+
children: descendants,
|
|
193
|
+
layoutOptions: {
|
|
194
|
+
"elk.algorithm": "layered",
|
|
195
|
+
"elk.direction": "RIGHT",
|
|
196
|
+
"elk.padding": `[top=${metric.headerHeight + 28},left=28,bottom=28,right=28]`,
|
|
197
|
+
"elk.nodeSize.constraints": "MINIMUM_SIZE",
|
|
198
|
+
"elk.nodeSize.minimum": `(${metric.width}, ${metric.height})`,
|
|
199
|
+
"elk.portConstraints": "FIXED_ORDER",
|
|
200
|
+
"elk.spacing.portPort": "22",
|
|
201
|
+
"elk.spacing.nodeNode": "44",
|
|
202
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "108"
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
...(runtimeTargetsByParent.get(parent) ?? []).map((target): ElkNode => ({
|
|
207
|
+
id: runtimeNodeId(target),
|
|
208
|
+
width: 118,
|
|
209
|
+
height: 34,
|
|
210
|
+
ports: [{
|
|
211
|
+
id: runtimeTargetPortId(target),
|
|
212
|
+
width: 6,
|
|
213
|
+
height: 6,
|
|
214
|
+
layoutOptions: { "elk.port.side": "WEST" }
|
|
215
|
+
}],
|
|
216
|
+
layoutOptions: { "elk.portConstraints": "FIXED_SIDE" }
|
|
217
|
+
}))
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
id: "chart-root",
|
|
222
|
+
children: children(null),
|
|
223
|
+
edges: [
|
|
224
|
+
...model.edges.map((edge): ElkExtendedEdge => {
|
|
225
|
+
const label = labelMetric(edge.label)
|
|
226
|
+
return {
|
|
227
|
+
id: edge.id,
|
|
228
|
+
sources: [sourcePortId(edge)],
|
|
229
|
+
targets: [targetPortId(edge)],
|
|
230
|
+
labels: [{ text: edge.label, width: label.width, height: label.height }]
|
|
231
|
+
}
|
|
232
|
+
}),
|
|
233
|
+
...model.initials.map((initial): ElkExtendedEdge => ({
|
|
234
|
+
id: initial.id,
|
|
235
|
+
sources: [initialNodeId(initial)],
|
|
236
|
+
targets: [initialTargetPortId(initial)]
|
|
237
|
+
}))
|
|
238
|
+
],
|
|
239
|
+
layoutOptions: {
|
|
240
|
+
"elk.algorithm": "layered",
|
|
241
|
+
"elk.direction": "RIGHT",
|
|
242
|
+
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
|
243
|
+
"elk.edgeRouting": "ORTHOGONAL",
|
|
244
|
+
"elk.padding": "[top=44,left=44,bottom=44,right=44]",
|
|
245
|
+
"elk.spacing.nodeNode": "64",
|
|
246
|
+
"elk.layered.spacing.nodeNodeBetweenLayers": "148",
|
|
247
|
+
"elk.layered.spacing.edgeNodeBetweenLayers": "42",
|
|
248
|
+
"elk.layered.spacing.edgeEdgeBetweenLayers": "26",
|
|
249
|
+
"elk.spacing.edgeNode": "28",
|
|
250
|
+
"elk.spacing.edgeEdge": "20",
|
|
251
|
+
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
|
252
|
+
"elk.layered.crossingMinimization.greedySwitchHierarchical.type": "TWO_SIDED",
|
|
253
|
+
"elk.layered.nodePlacement.favorStraightEdges": "true",
|
|
254
|
+
"elk.layered.mergeHierarchyEdges": "false",
|
|
255
|
+
"elk.layered.thoroughness": "12",
|
|
256
|
+
"elk.randomSeed": "1"
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({
|
|
262
|
+
x: left.x + right.x,
|
|
263
|
+
y: left.y + right.y
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const compactPoints = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
|
|
267
|
+
const result: Array<ChartPoint> = []
|
|
268
|
+
for (const point of points) {
|
|
269
|
+
const previous = result.at(-1)
|
|
270
|
+
if (previous !== undefined && previous.x === point.x && previous.y === point.y) continue
|
|
271
|
+
const beforePrevious = result.at(-2)
|
|
272
|
+
if (
|
|
273
|
+
beforePrevious !== undefined && previous !== undefined &&
|
|
274
|
+
(beforePrevious.x === previous.x && previous.x === point.x ||
|
|
275
|
+
beforePrevious.y === previous.y && previous.y === point.y)
|
|
276
|
+
) {
|
|
277
|
+
result[result.length - 1] = point
|
|
278
|
+
} else {
|
|
279
|
+
result.push(point)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const edgePoints = (edge: ElkExtendedEdge, offset: ChartPoint): ReadonlyArray<ChartPoint> | undefined => {
|
|
286
|
+
const sections = edge.sections
|
|
287
|
+
if (sections === undefined || sections.length === 0) return undefined
|
|
288
|
+
const byId = new Map(sections.map((section) => [section.id, section]))
|
|
289
|
+
let section =
|
|
290
|
+
sections.find((candidate) =>
|
|
291
|
+
candidate.incomingShape !== undefined || candidate.incomingSections === undefined ||
|
|
292
|
+
candidate.incomingSections.length === 0
|
|
293
|
+
) ?? sections[0]
|
|
294
|
+
const points: Array<ElkPoint> = []
|
|
295
|
+
const visited = new Set<string>()
|
|
296
|
+
while (section !== undefined && !visited.has(section.id)) {
|
|
297
|
+
visited.add(section.id)
|
|
298
|
+
if (points.length === 0) points.push(section.startPoint)
|
|
299
|
+
points.push(...(section.bendPoints ?? []), section.endPoint)
|
|
300
|
+
const next = section.outgoingSections?.[0]
|
|
301
|
+
section = next === undefined ? undefined : byId.get(next)
|
|
302
|
+
}
|
|
303
|
+
return points.length < 2 ? undefined : compactPoints(points.map((point) => add(point, offset)))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const midpoint = (points: ReadonlyArray<ChartPoint>): ChartPoint => {
|
|
307
|
+
const lengths = points.slice(1).map((point, index) => {
|
|
308
|
+
const previous = points[index]!
|
|
309
|
+
return Math.abs(point.x - previous.x) + Math.abs(point.y - previous.y)
|
|
310
|
+
})
|
|
311
|
+
const total = lengths.reduce((sum, length) => sum + length, 0)
|
|
312
|
+
let remaining = total / 2
|
|
313
|
+
for (let index = 0; index < lengths.length; index++) {
|
|
314
|
+
const length = lengths[index]!
|
|
315
|
+
const start = points[index]!
|
|
316
|
+
const end = points[index + 1]!
|
|
317
|
+
if (remaining <= length) {
|
|
318
|
+
const ratio = length === 0 ? 0 : remaining / length
|
|
319
|
+
return {
|
|
320
|
+
x: start.x + (end.x - start.x) * ratio,
|
|
321
|
+
y: start.y + (end.y - start.y) * ratio
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
remaining -= length
|
|
325
|
+
}
|
|
326
|
+
return points.at(-1)!
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const expandTargetlessLoop = (points: ReadonlyArray<ChartPoint>): ReadonlyArray<ChartPoint> => {
|
|
330
|
+
const start = points[0]
|
|
331
|
+
const end = points.at(-1)
|
|
332
|
+
if (start === undefined || end === undefined) return points
|
|
333
|
+
const outerX = Math.max(...points.map(({ x }) => x)) + 30
|
|
334
|
+
return compactPoints([
|
|
335
|
+
start,
|
|
336
|
+
{ x: outerX, y: start.y },
|
|
337
|
+
{ x: outerX, y: end.y },
|
|
338
|
+
end
|
|
339
|
+
])
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const collectLayout = (model: ChartModel, graph: ElkNode): LaidOutChart => {
|
|
343
|
+
const chartNodes = new Map(model.nodes.map((node) => [node.path, node]))
|
|
344
|
+
const chartInitials = new Map(model.initials.map((initial) => [initialNodeId(initial), initial]))
|
|
345
|
+
const chartRuntimeTargets = new Map(model.runtimeTargets.map((target) => [runtimeNodeId(target), target]))
|
|
346
|
+
const offsets = new Map<string, ChartPoint>([[graph.id, { x: 0, y: 0 }]])
|
|
347
|
+
const nodes: Array<LaidOutChartNode> = []
|
|
348
|
+
const initials: Array<LaidOutChartInitial> = []
|
|
349
|
+
const runtimeTargets: Array<LaidOutChartRuntimeTarget> = []
|
|
350
|
+
|
|
351
|
+
const visit = (node: ElkNode, parentOffset: ChartPoint): void => {
|
|
352
|
+
const absolute = add(parentOffset, { x: node.x ?? 0, y: node.y ?? 0 })
|
|
353
|
+
offsets.set(node.id, absolute)
|
|
354
|
+
const chartNode = chartNodes.get(node.id)
|
|
355
|
+
if (chartNode !== undefined) {
|
|
356
|
+
const metric = nodeMetric(chartNode)
|
|
357
|
+
nodes.push({
|
|
358
|
+
node: chartNode,
|
|
359
|
+
x: absolute.x,
|
|
360
|
+
y: absolute.y,
|
|
361
|
+
width: node.width ?? metric.width,
|
|
362
|
+
height: node.height ?? metric.height,
|
|
363
|
+
headerHeight: metric.headerHeight
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
const initial = chartInitials.get(node.id)
|
|
367
|
+
if (initial !== undefined) {
|
|
368
|
+
initials.push({
|
|
369
|
+
initial,
|
|
370
|
+
x: absolute.x,
|
|
371
|
+
y: absolute.y,
|
|
372
|
+
width: node.width ?? 14,
|
|
373
|
+
height: node.height ?? 14
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
const runtimeTarget = chartRuntimeTargets.get(node.id)
|
|
377
|
+
if (runtimeTarget !== undefined) {
|
|
378
|
+
runtimeTargets.push({
|
|
379
|
+
target: runtimeTarget,
|
|
380
|
+
x: absolute.x,
|
|
381
|
+
y: absolute.y,
|
|
382
|
+
width: node.width ?? 118,
|
|
383
|
+
height: node.height ?? 34
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
for (const child of node.children ?? []) visit(child, absolute)
|
|
387
|
+
}
|
|
388
|
+
for (const child of graph.children ?? []) visit(child, { x: 0, y: 0 })
|
|
389
|
+
|
|
390
|
+
const chartEdges = new Map(model.edges.map((edge) => [edge.id, edge]))
|
|
391
|
+
const initialEdges = new Map(model.initials.map((initial) => [initial.id, initial]))
|
|
392
|
+
const edges = (graph.edges ?? []).flatMap((edge): ReadonlyArray<LaidOutChartTransition | LaidOutChartInitialEdge> => {
|
|
393
|
+
const offset = offsets.get(edge.container ?? graph.id) ?? { x: 0, y: 0 }
|
|
394
|
+
const points = edgePoints(edge, offset)
|
|
395
|
+
if (points === undefined) return []
|
|
396
|
+
const chartEdge = chartEdges.get(edge.id)
|
|
397
|
+
if (chartEdge !== undefined) {
|
|
398
|
+
const metric = labelMetric(chartEdge.label)
|
|
399
|
+
const label = edge.labels?.[0]
|
|
400
|
+
const transitionPoints = chartEdge.kind === "targetless" ? expandTargetlessLoop(points) : points
|
|
401
|
+
return [{
|
|
402
|
+
kind: "transition",
|
|
403
|
+
edge: chartEdge,
|
|
404
|
+
points: transitionPoints,
|
|
405
|
+
label: label?.x === undefined || label.y === undefined
|
|
406
|
+
? midpoint(transitionPoints)
|
|
407
|
+
: add(offset, {
|
|
408
|
+
x: label.x + (label.width ?? metric.width) / 2,
|
|
409
|
+
y: label.y + (label.height ?? metric.height) / 2
|
|
410
|
+
}),
|
|
411
|
+
labelWidth: label?.width ?? metric.width,
|
|
412
|
+
labelHeight: label?.height ?? metric.height
|
|
413
|
+
}]
|
|
414
|
+
}
|
|
415
|
+
const initial = initialEdges.get(edge.id)
|
|
416
|
+
return initial === undefined ? [] : [{ kind: "initial", initial, points }]
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
width: Math.max(360, graph.width ?? 0),
|
|
421
|
+
height: Math.max(280, graph.height ?? 0),
|
|
422
|
+
nodes,
|
|
423
|
+
initials,
|
|
424
|
+
runtimeTargets,
|
|
425
|
+
edges
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export const layoutChart = (model: ChartModel): Effect.Effect<LaidOutChart, ChartLayoutError> =>
|
|
430
|
+
Effect.tryPromise({
|
|
431
|
+
try: () => elk.layout(makeGraph(model)),
|
|
432
|
+
catch: (cause) => new ChartLayoutError({ cause })
|
|
433
|
+
}).pipe(Effect.map((graph) => collectLayout(model, graph)))
|