lepus-saga 0.3.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +237 -0
- data/LICENSE.txt +21 -0
- data/README.md +337 -0
- data/Rakefile +8 -0
- data/docs/brand/BRAND.md +67 -0
- data/docs/brand/apple-touch-icon.png +0 -0
- data/docs/brand/favicon-16.png +0 -0
- data/docs/brand/favicon-32.png +0 -0
- data/docs/brand/favicon.ico +0 -0
- data/docs/brand/favicon.svg +1 -0
- data/docs/brand/icon-512.png +0 -0
- data/docs/brand/lockup-lepus-mono.svg +1 -0
- data/docs/brand/lockup-lepus-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
- data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
- data/docs/brand/lockup-lepus-saga.svg +1 -0
- data/docs/brand/lockup-lepus.svg +1 -0
- data/docs/brand/mark-mono.svg +1 -0
- data/docs/brand/mark-on-dark.svg +1 -0
- data/docs/brand/mark-on-light.svg +1 -0
- data/docs/brand/mark-small-mono.svg +1 -0
- data/docs/brand/mark-small-on-dark.svg +1 -0
- data/docs/brand/mark-small-on-light.svg +1 -0
- data/docs/brand/mark-small.svg +1 -0
- data/docs/brand/mark.svg +1 -0
- data/lib/lepus/saga/configuration.rb +48 -0
- data/lib/lepus/saga/control.rb +27 -0
- data/lib/lepus/saga/definition.rb +123 -0
- data/lib/lepus/saga/execution_behaviour.rb +58 -0
- data/lib/lepus/saga/executor.rb +282 -0
- data/lib/lepus/saga/executor_consumer.rb +38 -0
- data/lib/lepus/saga/instrumentation.rb +41 -0
- data/lib/lepus/saga/log_subscriber.rb +42 -0
- data/lib/lepus/saga/queue_depth.rb +28 -0
- data/lib/lepus/saga/resume_stuck_job.rb +19 -0
- data/lib/lepus/saga/retry_policy.rb +19 -0
- data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
- data/lib/lepus/saga/rspec/helpers.rb +22 -0
- data/lib/lepus/saga/rspec/query_examples.rb +378 -0
- data/lib/lepus/saga/rspec/store_examples.rb +514 -0
- data/lib/lepus/saga/rspec.rb +6 -0
- data/lib/lepus/saga/step.rb +9 -0
- data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
- data/lib/lepus/saga/step_producer.rb +37 -0
- data/lib/lepus/saga/stores/base.rb +305 -0
- data/lib/lepus/saga/stores/memory.rb +375 -0
- data/lib/lepus/saga/stores.rb +40 -0
- data/lib/lepus/saga/testing.rb +35 -0
- data/lib/lepus/saga/version.rb +7 -0
- data/lib/lepus/saga/web/api.rb +326 -0
- data/lib/lepus/saga/web/configuration.rb +49 -0
- data/lib/lepus/saga/web/guard.rb +38 -0
- data/lib/lepus/saga/web/serializer.rb +59 -0
- data/lib/lepus/saga/web/static.rb +108 -0
- data/lib/lepus/saga/web.rb +61 -0
- data/lib/lepus/saga.rb +212 -0
- data/lib/lepus-saga.rb +3 -0
- data/mise.toml +2 -0
- data/web/assets/app.css +913 -0
- data/web/assets/apple-touch-icon.png +0 -0
- data/web/assets/favicon-32.png +0 -0
- data/web/assets/favicon.svg +1 -0
- data/web/assets/fonts/overpass/OFL.txt +93 -0
- data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
- data/web/assets/js/api.js +50 -0
- data/web/assets/js/dom.js +84 -0
- data/web/assets/js/format.js +79 -0
- data/web/assets/js/live.js +74 -0
- data/web/assets/js/main.js +143 -0
- data/web/assets/js/map.js +639 -0
- data/web/assets/js/range.js +19 -0
- data/web/assets/js/sagas.js +18 -0
- data/web/assets/js/theme.js +8 -0
- data/web/assets/js/throughput.js +262 -0
- data/web/assets/js/views/execution.js +426 -0
- data/web/assets/js/views/executions.js +165 -0
- data/web/assets/js/views/overview.js +465 -0
- data/web/index.html +63 -0
- metadata +323 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { h, s, clear, reducedMotion } from "./dom.js";
|
|
2
|
+
import { compact } from "./format.js";
|
|
3
|
+
import { isLive, now as clock, onLive } from "./live.js";
|
|
4
|
+
|
|
5
|
+
// Draws saga lines as a transit diagram and moves execution tokens along
|
|
6
|
+
// them. A position is a float over the line: -1 is the start terminal, 0..n-1
|
|
7
|
+
// are the stations, n is the end terminal. Tokens ride the main line ("fwd")
|
|
8
|
+
// or the compensation rail beneath it ("back"). Tokens heading out of the
|
|
9
|
+
// same station share up to three lanes; past SEGMENT_SHOWN the rest ride as
|
|
10
|
+
// one "+N" pill.
|
|
11
|
+
|
|
12
|
+
const RAIL_OFFSET = 12;
|
|
13
|
+
const LANES = { fwd: [0, -8, -16], back: [RAIL_OFFSET, RAIL_OFFSET + 7, RAIL_OFFSET + 14] };
|
|
14
|
+
const LANE_GAP = 26;
|
|
15
|
+
const SEGMENT_SHOWN = 8;
|
|
16
|
+
const CREEP_MIN = 0.1;
|
|
17
|
+
const CREEP_MAX = 0.9;
|
|
18
|
+
const CREEP_FRAME_MS = 66;
|
|
19
|
+
|
|
20
|
+
export class LineMap {
|
|
21
|
+
constructor(host, { mode = "lines", label = "Saga lines" } = {}) {
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.mode = mode;
|
|
24
|
+
this.label = label;
|
|
25
|
+
this.lines = [];
|
|
26
|
+
this.tokens = new Map();
|
|
27
|
+
this.width = 0;
|
|
28
|
+
this.frame = null;
|
|
29
|
+
this.svg = s("svg", { role: "group", "aria-label": label });
|
|
30
|
+
this.tip = h("div", { class: "m-tip", role: "tooltip", hidden: true });
|
|
31
|
+
host.append(this.svg, this.tip);
|
|
32
|
+
this.offLive = onLive(() => this.wake());
|
|
33
|
+
this.observer = new ResizeObserver(() => this.resize());
|
|
34
|
+
this.observer.observe(host);
|
|
35
|
+
this.tickBound = (time) => this.tick(time);
|
|
36
|
+
this.uid = `lm${Math.random().toString(36).slice(2, 8)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
destroy() {
|
|
40
|
+
this.observer.disconnect();
|
|
41
|
+
this.offLive();
|
|
42
|
+
cancelAnimationFrame(this.frame);
|
|
43
|
+
clearTimeout(this.slowTimer);
|
|
44
|
+
this.frame = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// lines: [{name, steps: [{name, compensate}], href(stepIndex), meta, decor: {stepIndex: {...}}}]
|
|
48
|
+
setLines(lines) {
|
|
49
|
+
const shape = JSON.stringify(lines.map((line) => [line.name, line.steps.map((step) => step.name)]));
|
|
50
|
+
this.lines = lines;
|
|
51
|
+
if (shape !== this.shape || !this.layout) {
|
|
52
|
+
this.shape = shape;
|
|
53
|
+
this.render();
|
|
54
|
+
} else {
|
|
55
|
+
this.decorate();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
resize() {
|
|
60
|
+
const width = Math.round(this.host.clientWidth);
|
|
61
|
+
if (width && width !== this.width) {
|
|
62
|
+
this.width = width;
|
|
63
|
+
if (this.lines.length) this.render();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// spec: {id, line, target, rail, kind, creep?: {from, ms, dir}, leave?}
|
|
68
|
+
// partial: "line|stationIndex" keys whose token list was cut short, where
|
|
69
|
+
// the station counts (decor.moving / undoing) fill in the pill.
|
|
70
|
+
setTokens(specs, partial) {
|
|
71
|
+
this.tokensKnown = true;
|
|
72
|
+
if (partial) this.partial = partial;
|
|
73
|
+
const seen = new Set();
|
|
74
|
+
for (const spec of specs) {
|
|
75
|
+
seen.add(spec.id);
|
|
76
|
+
const token = this.tokens.get(spec.id);
|
|
77
|
+
if (token) {
|
|
78
|
+
Object.assign(token, { leave: false }, spec);
|
|
79
|
+
} else {
|
|
80
|
+
this.tokens.set(spec.id, { pos: spec.from ?? spec.target, opacity: spec.from !== undefined && !reducedMotion.matches ? 0 : 1, ...spec });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const [id, token] of this.tokens) {
|
|
84
|
+
if (!seen.has(id) && !token.leave) token.leave = true;
|
|
85
|
+
}
|
|
86
|
+
this.wake();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Replay: put one token exactly where the caller says, no animation.
|
|
90
|
+
place(spec) {
|
|
91
|
+
this.tokens.clear();
|
|
92
|
+
if (spec) this.tokens.set(spec.id, { ...spec, pos: spec.target, opacity: 1, fixed: true });
|
|
93
|
+
this.drawTokens();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setProgress(progress) {
|
|
97
|
+
this.progress = progress;
|
|
98
|
+
this.drawProgress();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
wake() {
|
|
102
|
+
clearTimeout(this.slowTimer);
|
|
103
|
+
if (this.frame === null) this.frame = requestAnimationFrame(this.tickBound);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Geometry
|
|
107
|
+
|
|
108
|
+
computeLayout() {
|
|
109
|
+
const width = this.width || Math.round(this.host.clientWidth) || 1000;
|
|
110
|
+
const vertical = width < 700;
|
|
111
|
+
const single = this.mode === "single";
|
|
112
|
+
const lines = [];
|
|
113
|
+
let height = 0;
|
|
114
|
+
|
|
115
|
+
if (vertical) {
|
|
116
|
+
const lineX = 26;
|
|
117
|
+
let y = 0;
|
|
118
|
+
for (const line of this.lines) {
|
|
119
|
+
const head = single ? 0 : 52;
|
|
120
|
+
const capTop = y + head + 6;
|
|
121
|
+
const firstY = capTop + 46;
|
|
122
|
+
const gap = 50;
|
|
123
|
+
const stations = line.steps.map((step, index) => ({ x: lineX, y: firstY + index * gap }));
|
|
124
|
+
const lastY = stations.length ? stations[stations.length - 1].y : firstY;
|
|
125
|
+
const capBottom = lastY + 46;
|
|
126
|
+
lines.push({
|
|
127
|
+
line, vertical: true, head: { x: 0, y: y + 20 },
|
|
128
|
+
start: { x: lineX, y: capTop }, end: { x: lineX, y: capBottom }, stations,
|
|
129
|
+
axis: { x: 0, y: 1 }, normal: { x: 1, y: 0 }
|
|
130
|
+
});
|
|
131
|
+
y = capBottom + (single ? 22 : 40);
|
|
132
|
+
}
|
|
133
|
+
height = y;
|
|
134
|
+
return { width, height, vertical, lines, single };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const wide = !single && width >= 1000;
|
|
138
|
+
const nameCol = wide ? 236 : 0;
|
|
139
|
+
const capL = nameCol + 12;
|
|
140
|
+
const capR = width - 12;
|
|
141
|
+
const inset = single ? 90 : 76;
|
|
142
|
+
const rowH = single ? 140 : wide ? 112 : 138;
|
|
143
|
+
const top = single ? 0 : wide ? 62 : 64;
|
|
144
|
+
const lineDy = single ? 62 : wide ? 58 : 90;
|
|
145
|
+
this.lines.forEach((line, row) => {
|
|
146
|
+
const y = top + row * rowH + lineDy;
|
|
147
|
+
const n = line.steps.length;
|
|
148
|
+
const x0 = capL + inset;
|
|
149
|
+
const x1 = capR - inset;
|
|
150
|
+
const stations = line.steps.map((step, index) => ({ x: n === 1 ? (x0 + x1) / 2 : x0 + ((x1 - x0) * index) / (n - 1), y }));
|
|
151
|
+
lines.push({
|
|
152
|
+
line, vertical: false, wide,
|
|
153
|
+
head: wide ? { x: 0, y } : { x: capL + 26, y: y - 66 },
|
|
154
|
+
start: { x: capL, y }, end: { x: capR, y }, stations,
|
|
155
|
+
axis: { x: 1, y: 0 }, normal: { x: 0, y: 1 }
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
height = top + this.lines.length * rowH - (single ? 36 : 6);
|
|
159
|
+
return { width, height, vertical, lines, single, wide, capL, capR, top };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
points(geo) {
|
|
163
|
+
return [geo.start, ...geo.stations, geo.end];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
point(geo, pos, rail = "fwd") {
|
|
167
|
+
const points = this.points(geo);
|
|
168
|
+
const clamped = Math.max(-1, Math.min(geo.stations.length, pos));
|
|
169
|
+
const i = Math.min(points.length - 2, Math.floor(clamped + 1));
|
|
170
|
+
const t = clamped + 1 - i;
|
|
171
|
+
const a = points[i];
|
|
172
|
+
const b = points[i + 1];
|
|
173
|
+
const offset = rail === "back" ? RAIL_OFFSET : 0;
|
|
174
|
+
return {
|
|
175
|
+
x: a.x + (b.x - a.x) * t + geo.normal.x * offset,
|
|
176
|
+
y: a.y + (b.y - a.y) * t + geo.normal.y * offset
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Static drawing
|
|
181
|
+
|
|
182
|
+
render() {
|
|
183
|
+
this.layout = this.computeLayout();
|
|
184
|
+
const { width, height } = this.layout;
|
|
185
|
+
const svg = this.svg;
|
|
186
|
+
clear(svg);
|
|
187
|
+
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
188
|
+
svg.setAttribute("width", width);
|
|
189
|
+
svg.setAttribute("height", height);
|
|
190
|
+
|
|
191
|
+
this.layers = {
|
|
192
|
+
base: s("g"),
|
|
193
|
+
progress: s("g", { "aria-hidden": "true" }),
|
|
194
|
+
stations: s("g"),
|
|
195
|
+
tokens: s("g", { "aria-hidden": "true" })
|
|
196
|
+
};
|
|
197
|
+
svg.append(this.gradients(), this.layers.base, this.layers.progress, this.layers.stations, this.layers.tokens);
|
|
198
|
+
|
|
199
|
+
if (!this.layout.vertical && !this.layout.single) {
|
|
200
|
+
this.drawSharedTerminals();
|
|
201
|
+
if (this.terminals) this.setTerminals(this.terminals);
|
|
202
|
+
}
|
|
203
|
+
for (const geo of this.layout.lines) this.drawLine(geo);
|
|
204
|
+
this.decorate();
|
|
205
|
+
this.drawProgress();
|
|
206
|
+
this.drawTokens();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
drawSharedTerminals() {
|
|
210
|
+
const { lines, capL, capR } = this.layout;
|
|
211
|
+
if (!lines.length) return;
|
|
212
|
+
const top = lines[0].start.y - 20;
|
|
213
|
+
const textY = lines[0].wide ? lines[0].start.y - 72 : lines[0].head.y - 24;
|
|
214
|
+
const bottom = lines[lines.length - 1].start.y + 20;
|
|
215
|
+
const base = this.layers.base;
|
|
216
|
+
for (const geo of lines) {
|
|
217
|
+
base.append(s("path", { class: "m-line", d: `M${capL} ${geo.start.y}H${capR}` }));
|
|
218
|
+
}
|
|
219
|
+
this.terminalText = {};
|
|
220
|
+
for (const [key, x, anchor, label] of [["start", capL, "start", "started"], ["end", capR, "end", "completed"]]) {
|
|
221
|
+
base.append(s("rect", { class: "m-cap", x: x - 9, y: top, width: 18, height: bottom - top, rx: 9 }));
|
|
222
|
+
const num = s("tspan", { class: "m-cap-num" }, "0");
|
|
223
|
+
const words = s("tspan", { class: "m-cap-label", dx: 6 }, label);
|
|
224
|
+
const text = s("text", { x: anchor === "start" ? x - 9 : x + 9, y: textY, "text-anchor": anchor }, num, words);
|
|
225
|
+
base.append(text);
|
|
226
|
+
this.terminalText[key] = { num, words };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
setTerminals(values) {
|
|
231
|
+
this.terminals = values;
|
|
232
|
+
if (!this.terminalText) return;
|
|
233
|
+
for (const key of ["start", "end"]) {
|
|
234
|
+
const value = values[key];
|
|
235
|
+
if (!value) continue;
|
|
236
|
+
this.terminalText[key].num.textContent = value.n;
|
|
237
|
+
this.terminalText[key].words.textContent = value.label;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
drawLine(geo) {
|
|
242
|
+
const base = this.layers.base;
|
|
243
|
+
const { line } = geo;
|
|
244
|
+
const own = this.layout.vertical || this.layout.single;
|
|
245
|
+
|
|
246
|
+
if (own) {
|
|
247
|
+
base.append(s("path", { class: this.layout.single ? "m-line is-future" : "m-line", d: `M${geo.start.x} ${geo.start.y}L${geo.end.x} ${geo.end.y}` }));
|
|
248
|
+
for (const point of [geo.start, geo.end]) {
|
|
249
|
+
const w = geo.vertical ? 30 : 16;
|
|
250
|
+
const hgt = geo.vertical ? 14 : 34;
|
|
251
|
+
base.append(s("rect", { class: "m-cap", x: point.x - w / 2, y: point.y - hgt / 2, width: w, height: hgt, rx: Math.min(w, hgt) / 2 }));
|
|
252
|
+
}
|
|
253
|
+
if (this.layout.single) {
|
|
254
|
+
const start = geo.vertical ? { x: geo.start.x + 26, y: geo.start.y + 5, a: "start" } : { x: geo.start.x, y: geo.start.y + 38, a: "middle" };
|
|
255
|
+
const end = geo.vertical ? { x: geo.end.x + 26, y: geo.end.y + 5, a: "start" } : { x: geo.end.x, y: geo.end.y + 38, a: "middle" };
|
|
256
|
+
base.append(s("text", { class: "m-cap-label", x: start.x, y: start.y, "text-anchor": start.a }, "Start"));
|
|
257
|
+
base.append(s("text", { class: "m-cap-label", x: end.x, y: end.y, "text-anchor": end.a }, "End"));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const lastBack = line.steps.reduce((last, step, index) => (step.compensate ? index : last), -1);
|
|
262
|
+
if (lastBack >= 0) {
|
|
263
|
+
const a = this.point(geo, -1, "back");
|
|
264
|
+
const b = this.point(geo, lastBack, "back");
|
|
265
|
+
base.append(s("path", { class: "m-rail", d: `M${a.x} ${a.y}L${b.x} ${b.y}` }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!this.layout.single) this.drawHead(geo);
|
|
269
|
+
|
|
270
|
+
geo.nodes = line.steps.map((step, index) => {
|
|
271
|
+
const p = geo.stations[index];
|
|
272
|
+
const href = line.href ? line.href(index) : null;
|
|
273
|
+
const group = s(href ? "a" : "g", { class: "m-station", href });
|
|
274
|
+
const hit = geo.vertical
|
|
275
|
+
? s("rect", { class: "m-hit", x: p.x - 16, y: p.y - 18, width: this.layout.width - p.x + 16, height: 36, rx: 6 })
|
|
276
|
+
: s("rect", { class: "m-hit", x: p.x - 62, y: p.y - 58, width: 124, height: 100, rx: 6 });
|
|
277
|
+
const halo = s("circle", { class: "m-halo", cx: p.x, cy: p.y, r: 17 });
|
|
278
|
+
const dot = s("circle", { class: "m-dot", cx: p.x, cy: p.y, r: 7 });
|
|
279
|
+
const label = geo.vertical
|
|
280
|
+
? s("text", { class: "m-name", x: p.x + 26, y: p.y + 5 }, step.name)
|
|
281
|
+
: s("text", { class: "m-name", x: p.x, y: p.y + 36, "text-anchor": "middle" }, step.name);
|
|
282
|
+
const decor = s("g", { class: "m-decor" });
|
|
283
|
+
const title = s("title");
|
|
284
|
+
group.append(title, hit, halo, dot, label, decor);
|
|
285
|
+
const show = () => this.showTip(geo, index);
|
|
286
|
+
const hide = () => this.hideTip(geo, index);
|
|
287
|
+
group.addEventListener("pointerenter", show);
|
|
288
|
+
group.addEventListener("focus", show);
|
|
289
|
+
group.addEventListener("pointerleave", hide);
|
|
290
|
+
group.addEventListener("blur", hide);
|
|
291
|
+
group.addEventListener("keydown", (event) => { if (event.key === "Escape") hide(); });
|
|
292
|
+
this.layers.stations.append(group);
|
|
293
|
+
return { group, decor, title, dot, label };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
drawHead(geo) {
|
|
298
|
+
const { line } = geo;
|
|
299
|
+
const link = s("a", { class: "m-saga-link", href: line.sagaHref || null });
|
|
300
|
+
const name = s("text", { class: "m-saga", x: geo.head.x, y: geo.wide || geo.vertical ? geo.head.y - (geo.vertical ? 0 : 4) : geo.head.y }, line.name);
|
|
301
|
+
link.append(name);
|
|
302
|
+
const meta = s("text", { class: "m-meta", x: geo.head.x, y: geo.vertical ? geo.head.y + 20 : geo.wide ? geo.head.y + 18 : geo.head.y + 18 });
|
|
303
|
+
geo.meta = meta;
|
|
304
|
+
this.layers.base.append(link, meta);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
decorate() {
|
|
308
|
+
if (!this.layout) return;
|
|
309
|
+
for (const geo of this.layout.lines) {
|
|
310
|
+
const line = this.lines.find((candidate) => candidate.name === geo.line.name) || geo.line;
|
|
311
|
+
geo.line = line;
|
|
312
|
+
if (geo.meta) {
|
|
313
|
+
clear(geo.meta);
|
|
314
|
+
for (const part of line.meta || []) geo.meta.append(s("tspan", { class: part.stop ? "m-meta-stop" : null }, part.text));
|
|
315
|
+
}
|
|
316
|
+
(geo.nodes || []).forEach((node, index) => {
|
|
317
|
+
const decor = (line.decor && line.decor[index]) || {};
|
|
318
|
+
const name = line.steps[index].name;
|
|
319
|
+
clear(node.decor);
|
|
320
|
+
node.group.setAttribute("class", ["m-station", decor.blocks ? "is-stopped" : "", decor.state ? `is-${decor.state}` : ""].join(" ").trim());
|
|
321
|
+
node.title.textContent = decor.tip ? "" : decor.title || name;
|
|
322
|
+
if (node.group.tagName === "a") node.group.setAttribute("aria-label", decor.title || name);
|
|
323
|
+
if (decor.blocks) this.drawBlocks(geo, index, decor.blocks, node.decor);
|
|
324
|
+
if (decor.stalled) this.drawIdle(geo, index, decor.stalled, node.decor);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (this.tipAt) this.showTip(this.tipAt.geo, this.tipAt.index);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// One failure marker per station: its height grows with the log of the
|
|
331
|
+
// count and stops growing at a cap, the exact count lives in the tooltip.
|
|
332
|
+
drawBlocks(geo, index, blocks, into) {
|
|
333
|
+
const p = geo.stations[index];
|
|
334
|
+
const text = compact(blocks);
|
|
335
|
+
const grow = Math.log10(Math.max(1, blocks));
|
|
336
|
+
if (geo.vertical) {
|
|
337
|
+
const right = this.layout.width - 4;
|
|
338
|
+
const size = Math.min(30, 8 + 6 * grow);
|
|
339
|
+
const label = s("text", { class: "m-block-n", x: right, y: p.y + 5, "text-anchor": "end" }, `${text} failed`);
|
|
340
|
+
const textW = (text.length + 7) * 7.2;
|
|
341
|
+
into.append(label, s("rect", { class: "m-block", x: right - textW - 10, y: p.y - size / 2, width: 5, height: size, rx: 1.5 }));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const size = Math.min(geo.wide || this.layout.single ? 34 : 24, 8 + 7 * grow);
|
|
345
|
+
const top = p.y - 14 - size;
|
|
346
|
+
into.append(
|
|
347
|
+
s("rect", { class: "m-block", x: p.x - 3, y: top, width: 6, height: size, rx: 2 }),
|
|
348
|
+
s("text", { class: "m-block-n", x: p.x + 8, y: top + 10 }, text));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
drawIdle(geo, index, n, into) {
|
|
352
|
+
const p = geo.stations[index];
|
|
353
|
+
const label = compact(n);
|
|
354
|
+
const w = 16 + label.length * 7;
|
|
355
|
+
const hgt = 16;
|
|
356
|
+
const cx = geo.vertical ? p.x + 26 + w / 2 : p.x - 15 - w / 2;
|
|
357
|
+
const cy = geo.vertical ? p.y - 16 : p.y;
|
|
358
|
+
into.append(
|
|
359
|
+
s("rect", { class: "t-idle", x: cx - w / 2, y: cy - hgt / 2, width: w, height: hgt, rx: hgt / 2 }),
|
|
360
|
+
s("text", { class: "t-idle-n", x: cx, y: cy + 4, "text-anchor": "middle" }, label));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
showTip(geo, index) {
|
|
364
|
+
const decor = (geo.line.decor && geo.line.decor[index]) || {};
|
|
365
|
+
if (!decor.tip) return;
|
|
366
|
+
this.tipAt = { geo, index };
|
|
367
|
+
const [head, ...rest] = decor.tip;
|
|
368
|
+
clear(this.tip).append(h("b", {}, head), ...rest.map((line) => h("span", {}, line)));
|
|
369
|
+
this.tip.hidden = false;
|
|
370
|
+
const p = geo.stations[index];
|
|
371
|
+
const hostW = this.host.clientWidth;
|
|
372
|
+
const tipW = this.tip.offsetWidth;
|
|
373
|
+
const tipH = this.tip.offsetHeight;
|
|
374
|
+
const left = geo.vertical ? Math.min(p.x + 18, hostW - tipW) : Math.max(0, Math.min(hostW - tipW, p.x - tipW / 2));
|
|
375
|
+
const above = p.y - 30 - tipH;
|
|
376
|
+
const top = geo.vertical ? p.y + 20 : above >= -40 ? above : p.y + 44;
|
|
377
|
+
this.tip.style.left = `${Math.round(left)}px`;
|
|
378
|
+
this.tip.style.top = `${Math.round(top)}px`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
hideTip(geo, index) {
|
|
382
|
+
if (!this.tipAt || this.tipAt.geo.line.name !== geo.line.name || this.tipAt.index !== index) return;
|
|
383
|
+
this.tipAt = null;
|
|
384
|
+
this.tip.hidden = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
drawProgress() {
|
|
388
|
+
if (!this.layers || !this.layout) return;
|
|
389
|
+
const layer = this.layers.progress;
|
|
390
|
+
clear(layer);
|
|
391
|
+
if (!this.progress) return;
|
|
392
|
+
const geo = this.layout.lines[0];
|
|
393
|
+
if (!geo) return;
|
|
394
|
+
const { reached, backFrom, backTo } = this.progress;
|
|
395
|
+
if (reached > -1) {
|
|
396
|
+
const a = this.point(geo, -1);
|
|
397
|
+
const b = this.point(geo, reached);
|
|
398
|
+
layer.append(s("path", { class: "m-line", d: `M${a.x} ${a.y}L${b.x} ${b.y}` }));
|
|
399
|
+
}
|
|
400
|
+
if (backFrom !== null && backFrom !== undefined && backTo < backFrom) {
|
|
401
|
+
const a = this.point(geo, backFrom, "back");
|
|
402
|
+
const b = this.point(geo, backTo, "back");
|
|
403
|
+
layer.append(s("path", { class: "m-rail is-solid", d: `M${a.x} ${a.y}L${b.x} ${b.y}` }));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Motion
|
|
408
|
+
|
|
409
|
+
// A token in a step sits along the segment by how long it has been there
|
|
410
|
+
// against that step's median (creep.ms): just past the station on arrival,
|
|
411
|
+
// halfway at the median, and closing in on the next station after that.
|
|
412
|
+
effectiveTarget(token) {
|
|
413
|
+
let target = token.target;
|
|
414
|
+
const creep = token.creep;
|
|
415
|
+
if (creep && !token.leave) {
|
|
416
|
+
const elapsed = Math.max(0, clock() - creep.from);
|
|
417
|
+
const share = 1 - 2 ** (-elapsed / creep.ms);
|
|
418
|
+
target += creep.dir * (CREEP_MIN + (CREEP_MAX - CREEP_MIN) * share);
|
|
419
|
+
}
|
|
420
|
+
return target;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
tick(now) {
|
|
424
|
+
this.frame = null;
|
|
425
|
+
const dt = Math.min(0.1, (now - (this.lastTime || now)) / 1000);
|
|
426
|
+
this.lastTime = now;
|
|
427
|
+
let busy = false;
|
|
428
|
+
let creeping = false;
|
|
429
|
+
const reduce = reducedMotion.matches;
|
|
430
|
+
const running = isLive();
|
|
431
|
+
|
|
432
|
+
for (const [id, token] of this.tokens) {
|
|
433
|
+
if (token.fixed) continue;
|
|
434
|
+
const target = this.effectiveTarget(token);
|
|
435
|
+
const gap = target - token.pos;
|
|
436
|
+
if (reduce) {
|
|
437
|
+
token.pos = target;
|
|
438
|
+
} else if (Math.abs(gap) > 0.004) {
|
|
439
|
+
const speed = Math.max(0.12, Math.min(3, Math.abs(gap) * 2.2));
|
|
440
|
+
token.pos += Math.sign(gap) * Math.min(Math.abs(gap), speed * dt);
|
|
441
|
+
busy = true;
|
|
442
|
+
} else {
|
|
443
|
+
token.pos = target;
|
|
444
|
+
}
|
|
445
|
+
if (token.creep && !token.leave && !reduce && running) creeping = true;
|
|
446
|
+
|
|
447
|
+
if (token.leave) {
|
|
448
|
+
const arrived = Math.abs(target - token.pos) < 0.02;
|
|
449
|
+
if (reduce) {
|
|
450
|
+
this.tokens.delete(id);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (arrived) {
|
|
454
|
+
token.hold = (token.hold || 0) + dt;
|
|
455
|
+
if (token.hold > (token.linger || 0.2)) token.opacity -= dt * 2.2;
|
|
456
|
+
}
|
|
457
|
+
if (token.opacity <= 0) {
|
|
458
|
+
this.tokens.delete(id);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
busy = true;
|
|
462
|
+
} else if (token.opacity < 1) {
|
|
463
|
+
token.opacity = reduce ? 1 : Math.min(1, token.opacity + dt * 3);
|
|
464
|
+
busy = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
this.drawTokens();
|
|
469
|
+
if (busy) {
|
|
470
|
+
this.frame = requestAnimationFrame(this.tickBound);
|
|
471
|
+
} else if (creeping) {
|
|
472
|
+
this.lastTime = null;
|
|
473
|
+
this.slowTimer = setTimeout(() => this.wake(), CREEP_FRAME_MS);
|
|
474
|
+
} else {
|
|
475
|
+
this.lastTime = null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tokens leaving the same station on the same rail form a segment group.
|
|
480
|
+
// The group shows its first SEGMENT_SHOWN tokens (stable by id) in up to
|
|
481
|
+
// three lanes and folds the rest, plus any the poll did not list, into a pill.
|
|
482
|
+
groupTokens(byLine) {
|
|
483
|
+
const groups = new Map();
|
|
484
|
+
const loose = [];
|
|
485
|
+
const group = (geo, rail, target) => {
|
|
486
|
+
const key = `${geo.line.name}|${rail}|${target}`;
|
|
487
|
+
if (!groups.has(key)) groups.set(key, { geo, rail, target, tokens: [] });
|
|
488
|
+
return groups.get(key);
|
|
489
|
+
};
|
|
490
|
+
for (const token of this.tokens.values()) {
|
|
491
|
+
const geo = byLine.get(token.line);
|
|
492
|
+
if (!geo) continue;
|
|
493
|
+
if (token.kind === "idle" && !token.leave) continue;
|
|
494
|
+
if (token.fixed || token.leave || this.layout.single) loose.push({ token, geo });
|
|
495
|
+
else group(geo, token.rail, token.target).tokens.push(token);
|
|
496
|
+
}
|
|
497
|
+
if (!this.layout.single && this.tokensKnown) {
|
|
498
|
+
for (const geo of this.layout.lines) {
|
|
499
|
+
for (const [index, decor] of Object.entries(geo.line.decor || {})) {
|
|
500
|
+
if (!this.isPartial(geo, index)) continue;
|
|
501
|
+
if (decor.moving) group(geo, "fwd", Number(index));
|
|
502
|
+
if (decor.undoing) group(geo, "back", Number(index));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { groups: [...groups.values()], loose };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
drawTokens() {
|
|
510
|
+
if (!this.layers || !this.layout) return;
|
|
511
|
+
const layer = this.layers.tokens;
|
|
512
|
+
const byLine = new Map(this.layout.lines.map((geo) => [geo.line.name, geo]));
|
|
513
|
+
const { groups, loose } = this.groupTokens(byLine);
|
|
514
|
+
clear(layer);
|
|
515
|
+
|
|
516
|
+
for (const { token, geo } of loose) {
|
|
517
|
+
const p = this.point(geo, token.pos, token.rail);
|
|
518
|
+
this.drawToken(layer, geo, token, p, { trail: true });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
for (const { geo, rail, target, tokens } of groups) {
|
|
522
|
+
const sorted = tokens.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
523
|
+
const shown = sorted.slice(0, SEGMENT_SHOWN);
|
|
524
|
+
const hidden = sorted.slice(SEGMENT_SHOWN);
|
|
525
|
+
const decor = (geo.line.decor || {})[target] || {};
|
|
526
|
+
const known = this.isPartial(geo, target) ? (rail === "back" ? decor.undoing : decor.moving) : 0;
|
|
527
|
+
|
|
528
|
+
// Front-most token first. A token that would overlap the one ahead takes
|
|
529
|
+
// the first lane with room; when all three are taken it queues behind
|
|
530
|
+
// the roomiest lane, and one that would back up onto the station folds
|
|
531
|
+
// into the pill instead.
|
|
532
|
+
const dir = rail === "back" ? -1 : 1;
|
|
533
|
+
const ahead = (p) => (p.x * geo.axis.x + p.y * geo.axis.y) * dir;
|
|
534
|
+
const floor = ahead(this.point(geo, target)) + 10;
|
|
535
|
+
const ceiling = Math.max(floor, ahead(this.point(geo, target + dir)) - (geo.vertical ? 22 : 64));
|
|
536
|
+
const candidates = shown.map((token) => {
|
|
537
|
+
const p = this.point(geo, token.pos);
|
|
538
|
+
return { token, p, raw: ahead(p), at: Math.min(ahead(p), ceiling) };
|
|
539
|
+
}).sort((a, b) => b.at - a.at);
|
|
540
|
+
const front = [Infinity, Infinity, Infinity];
|
|
541
|
+
const placed = [];
|
|
542
|
+
let folded = 0;
|
|
543
|
+
for (const item of candidates) {
|
|
544
|
+
let lane = front.findIndex((at) => at - item.at >= LANE_GAP);
|
|
545
|
+
let at = item.at;
|
|
546
|
+
if (lane < 0) {
|
|
547
|
+
lane = front.indexOf(Math.max(...front));
|
|
548
|
+
at = front[lane] - LANE_GAP;
|
|
549
|
+
if (at < floor) {
|
|
550
|
+
folded++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
front[lane] = at;
|
|
555
|
+
const shift = (item.raw - at) * dir;
|
|
556
|
+
item.p = { x: item.p.x - geo.axis.x * shift, y: item.p.y - geo.axis.y * shift };
|
|
557
|
+
item.lane = lane;
|
|
558
|
+
placed.push(item);
|
|
559
|
+
}
|
|
560
|
+
const rest = Math.max(hidden.length, (known || 0) - shown.length) + folded;
|
|
561
|
+
const fanned = placed.some((item) => item.lane > 0);
|
|
562
|
+
for (const { token, p, lane } of placed) {
|
|
563
|
+
const offset = LANES[rail][lane];
|
|
564
|
+
const q = { x: p.x + geo.normal.x * offset, y: p.y + geo.normal.y * offset };
|
|
565
|
+
this.drawToken(layer, geo, token, q, { trail: !fanned, thin: fanned });
|
|
566
|
+
}
|
|
567
|
+
if (rest > 0) this.drawPill(layer, geo, rail, target, rest, hidden);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
isPartial(geo, index) {
|
|
572
|
+
return !this.partial || this.partial.has(`${geo.line.name}|${index}`);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
drawToken(layer, geo, token, p, { trail, thin = false }) {
|
|
576
|
+
const long = token.big ? 24 : thin ? 18 : 20;
|
|
577
|
+
const short = token.big ? 13 : thin ? 6 : 12;
|
|
578
|
+
const w = geo.vertical ? short : long;
|
|
579
|
+
const hgt = geo.vertical ? long : short;
|
|
580
|
+
const opacity = token.opacity < 1 ? Math.max(0, token.opacity).toFixed(3) : null;
|
|
581
|
+
const moving = token.kind === "move" || token.kind === "back";
|
|
582
|
+
if (moving && trail) {
|
|
583
|
+
const dir = token.kind === "back" ? -1 : 1;
|
|
584
|
+
const length = token.big ? 70 : 46;
|
|
585
|
+
const tx = p.x - geo.axis.x * dir * (length / 2 + long / 2 - 4);
|
|
586
|
+
const ty = p.y - geo.axis.y * dir * (length / 2 + long / 2 - 4);
|
|
587
|
+
const tw = geo.vertical ? 4 : length;
|
|
588
|
+
const th = geo.vertical ? length : 4;
|
|
589
|
+
const grad = `${this.uid}-${geo.vertical ? "v" : "h"}-${dir > 0 ? "f" : "b"}-${token.kind}`;
|
|
590
|
+
layer.append(s("rect", { fill: `url(#${grad})`, x: tx - tw / 2, y: ty - th / 2, width: tw, height: th, rx: 2, opacity }));
|
|
591
|
+
}
|
|
592
|
+
const cls = ["t-token", token.kind && token.kind !== "move" ? `is-${token.kind}` : "", thin ? "is-thin" : ""].join(" ").trim();
|
|
593
|
+
layer.append(s("rect", { class: cls, x: p.x - w / 2, y: p.y - hgt / 2, width: w, height: hgt, rx: short / 2, opacity }));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
drawPill(layer, geo, rail, target, rest, hidden) {
|
|
597
|
+
const dir = rail === "back" ? -1 : 1;
|
|
598
|
+
const mean = hidden.length ? hidden.reduce((sum, token) => sum + token.pos, 0) / hidden.length : target + dir * 0.45;
|
|
599
|
+
const pos = target + dir * Math.min(0.85, Math.max(0.12, (mean - target) * dir));
|
|
600
|
+
const p = this.point(geo, pos);
|
|
601
|
+
const text = `+${compact(rest)} ${rail === "back" ? "undoing" : "running"}`;
|
|
602
|
+
const w = Math.round(text.length * 6.6 + 16);
|
|
603
|
+
const hgt = 18;
|
|
604
|
+
let cx;
|
|
605
|
+
let cy;
|
|
606
|
+
if (geo.vertical) {
|
|
607
|
+
const station = geo.stations[target];
|
|
608
|
+
const name = geo.line.steps[target].name;
|
|
609
|
+
cx = station.x + 26 + name.length * 7.2 + 10 + w / 2;
|
|
610
|
+
cy = station.y + 1;
|
|
611
|
+
} else {
|
|
612
|
+
cx = p.x;
|
|
613
|
+
cy = p.y + (rail === "back" ? 48 : -36);
|
|
614
|
+
}
|
|
615
|
+
layer.append(
|
|
616
|
+
s("rect", { class: rail === "back" ? "t-more is-back" : "t-more", x: cx - w / 2, y: cy - hgt / 2, width: w, height: hgt, rx: hgt / 2 }),
|
|
617
|
+
s("text", { class: rail === "back" ? "t-more-n is-back" : "t-more-n", x: cx, y: cy + 4, "text-anchor": "middle" }, text));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
gradients() {
|
|
621
|
+
const defs = s("defs");
|
|
622
|
+
for (const vertical of [false, true]) {
|
|
623
|
+
for (const dir of [1, -1]) {
|
|
624
|
+
for (const kind of ["move", "back"]) {
|
|
625
|
+
const from = dir > 0 ? "0" : "1";
|
|
626
|
+
const to = dir > 0 ? "1" : "0";
|
|
627
|
+
const grad = s("linearGradient", {
|
|
628
|
+
id: `${this.uid}-${vertical ? "v" : "h"}-${dir > 0 ? "f" : "b"}-${kind}`,
|
|
629
|
+
x1: vertical ? 0 : from, x2: vertical ? 0 : to, y1: vertical ? from : 0, y2: vertical ? to : 0
|
|
630
|
+
},
|
|
631
|
+
s("stop", { offset: 0, class: `g-trail-${kind}`, "stop-opacity": 0 }),
|
|
632
|
+
s("stop", { offset: 1, class: `g-trail-${kind}`, "stop-opacity": 0.9 }));
|
|
633
|
+
defs.append(grad);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return defs;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const KEY = "lepus-saga:range";
|
|
2
|
+
|
|
3
|
+
export const RANGES = [
|
|
4
|
+
{ key: "1h", ms: 3_600_000, bucket: 120, short: "1 h", phrase: "the last hour", per: "2 minutes" },
|
|
5
|
+
{ key: "24h", ms: 86_400_000, bucket: 1800, short: "24 h", phrase: "the last 24 hours", per: "half hour" },
|
|
6
|
+
{ key: "7d", ms: 604_800_000, bucket: 21_600, short: "7 d", phrase: "the last 7 days", per: "6 hours" }
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
export const findRange = (key) => RANGES.find((range) => range.key === key) || null;
|
|
10
|
+
|
|
11
|
+
export const rangeFrom = (range, now = Date.now()) => new Date(now - range.ms).toISOString();
|
|
12
|
+
|
|
13
|
+
export function storedRange() {
|
|
14
|
+
try { return findRange(localStorage.getItem(KEY)); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function storeRange(range) {
|
|
18
|
+
try { localStorage.setItem(KEY, range.key); } catch {}
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { api } from "./api.js";
|
|
2
|
+
|
|
3
|
+
let cache = null;
|
|
4
|
+
let fetchedAt = 0;
|
|
5
|
+
|
|
6
|
+
// Saga definitions change only on deploy; the list keeps them for a minute.
|
|
7
|
+
export async function sagaDefinitions() {
|
|
8
|
+
if (cache && Date.now() - fetchedAt < 60_000) return cache;
|
|
9
|
+
const data = await api.sagas();
|
|
10
|
+
cache = new Map(data.sagas.map((saga) => [saga.name, saga]));
|
|
11
|
+
fetchedAt = Date.now();
|
|
12
|
+
return cache;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function rememberSagas(sagas) {
|
|
16
|
+
cache = new Map(sagas.map((saga) => [saga.name, saga]));
|
|
17
|
+
fetchedAt = Date.now();
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
try {
|
|
3
|
+
var theme = window.localStorage.getItem("lepus-saga:theme");
|
|
4
|
+
if (theme === "light" || theme === "dark") document.documentElement.setAttribute("data-theme", theme);
|
|
5
|
+
} catch (error) {
|
|
6
|
+
document.documentElement.removeAttribute("data-theme");
|
|
7
|
+
}
|
|
8
|
+
})();
|