dsh-workflow-map 0.0.0-stage → 0.3.4
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/CHANGELOG.md +184 -0
- package/LICENSE +21 -0
- package/README.en.md +458 -0
- package/README.md +436 -2
- package/client.js +9685 -0
- package/cordis.patch.yml +11 -0
- package/docs/OPEN-SOURCE-GUIDE.md +256 -0
- package/docs/host-compatibility.md +320 -0
- package/docs/screenshot-detail.png +0 -0
- package/docs/screenshot.png +0 -0
- package/icon.svg +6 -0
- package/index.js +12 -0
- package/lib/index.js +974 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
- package/src/client/Branches.js +241 -0
- package/src/client/FlowColumn.js +572 -0
- package/src/client/FlowEffects.js +677 -0
- package/src/client/HotInject.js +628 -0
- package/src/client/MainFlow.js +359 -0
- package/src/client/NodeCard.js +545 -0
- package/src/client/NodeDetail.js +569 -0
- package/src/client/ProgressUnder.js +216 -0
- package/src/client/RightPage.js +285 -0
- package/src/client/Settings.js +212 -0
- package/src/client/StepColumn.js +479 -0
- package/src/client/StepDetail.js +250 -0
- package/src/client/export.js +280 -0
- package/src/client/panel.js +1911 -0
- package/src/client/pulse.js +126 -0
- package/src/client/store.js +1090 -0
- package/src/client/theme.js +1176 -0
- package/src/fold.mjs +2763 -0
- package/src/orchestrator.mjs +444 -0
- package/src/route.js +621 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlowEffects.js - the flowing-gradient curve layer (legacy two-column layout).
|
|
3
|
+
*
|
|
4
|
+
* The most recognisable element of that design: the running step (left column, glowing frame) and
|
|
5
|
+
* the running node (right column, glowing frame) were joined by a rainbow curve.
|
|
6
|
+
*
|
|
7
|
+
* Structure:
|
|
8
|
+
* wfFxRound / wfFxBounds - geometry helpers (round to 0.01 px, normalise a DOMRect)
|
|
9
|
+
* wfFxCurve - pure geometry: two points -> a cubic Bezier `M x1 y1 C ...`
|
|
10
|
+
* wfFxGeometry - pure geometry: two cards + viewport -> {visible, d, from, to, reason}
|
|
11
|
+
* wfFxQuery / wfFxApply - DOM: find elements, compute, write `d`/endpoints back into the SVG, leaving `data-endpoints` as evidence
|
|
12
|
+
* wfFxBind - DOM: ResizeObserver + scroll(capture) + resize, recomputed on a rAF throttle
|
|
13
|
+
* wfUseFlowEffects - React wrapper: rebinds once per "visual signature" change
|
|
14
|
+
* wfRenderFlowEffects - pure markup: <svg> + two gradients + two paths + two endpoint circles
|
|
15
|
+
* wfFxSignature - model -> a short signature (rebind only when it changes, not on every 800 ms poll)
|
|
16
|
+
*
|
|
17
|
+
* Why "absolutely positioned SVG with no viewBox": the curve was drawn in the gap between the two
|
|
18
|
+
* columns, each scrolling independently. The SVG covers .wf-body (position:relative); coordinates = card rect - SVG rect,
|
|
19
|
+
* so no viewBox scaling is needed: 1 user unit is 1 px, and endpoint error comes only from one rounding step.
|
|
20
|
+
*
|
|
21
|
+
* Endpoint rule: the curve is drawn only when BOTH cards are fully inside their own scroll viewport; otherwise hidden (data-visible=false).
|
|
22
|
+
* Clamping endpoints to the viewport edge would stop them hugging the card edge, making the 2 px tolerance meaningless,
|
|
23
|
+
* so drawing nothing is the honest option.
|
|
24
|
+
*
|
|
25
|
+
* Pure, no import/export: inlined into client.js by tools/build-client.mjs (single-file browser half).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Minimum column gap needed to fit a curve (below this the columns would touch). */
|
|
29
|
+
var WF_FX_MIN_GAP = 10;
|
|
30
|
+
|
|
31
|
+
/** Endpoint margin: the control point horizontal offset is 45 % of the gap, clamped to this range. */
|
|
32
|
+
var WF_FX_BOW_MIN = 8;
|
|
33
|
+
var WF_FX_BOW_MAX = 140;
|
|
34
|
+
|
|
35
|
+
/** Placeholder endpoints written into the SVG (before measuring, both circles sit outside the canvas so nothing flashes). */
|
|
36
|
+
var WF_FX_OFFSCREEN = -99;
|
|
37
|
+
|
|
38
|
+
/** After queueing, wait at most this long before measuring from a timer (environments where rAF never fires, e.g. headless --dump-dom). */
|
|
39
|
+
var WF_FX_FALLBACK_MS = 40;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* how many node-to-node links the chain layer can draw at once.
|
|
43
|
+
*
|
|
44
|
+
* An SVG child list is built once when React renders it; the measuring code can only write into
|
|
45
|
+
* the paths that already exist. The column draws at most WF_FLOW_CAP nodes (60) or, when the user
|
|
46
|
+
* lifts the cap, the whole step — so the slot count has to cover the largest list the panel can
|
|
47
|
+
* show. 240 slots is one <path> per link with room to spare and costs nothing until written (a
|
|
48
|
+
* path with an empty `d` paints nothing).
|
|
49
|
+
*/
|
|
50
|
+
var WF_CHAIN_LINK_SLOTS = 240;
|
|
51
|
+
function wfFxRound(value) {
|
|
52
|
+
var n = wfFinite(value);
|
|
53
|
+
return n === null ? 0 : Math.round(n * 100) / 100;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** DOMRect (or any {left, top, width, height}) -> a normalised rect; null when unusable. */
|
|
57
|
+
function wfFxBounds(rect) {
|
|
58
|
+
if (!rect || typeof rect !== "object") return null;
|
|
59
|
+
var left = wfFinite(rect.left);
|
|
60
|
+
var top = wfFinite(rect.top);
|
|
61
|
+
var width = wfFinite(rect.width);
|
|
62
|
+
var height = wfFinite(rect.height);
|
|
63
|
+
var right = wfFinite(rect.right);
|
|
64
|
+
var bottom = wfFinite(rect.bottom);
|
|
65
|
+
if (width === null && left !== null && right !== null) width = right - left;
|
|
66
|
+
if (height === null && top !== null && bottom !== null) height = bottom - top;
|
|
67
|
+
if (left === null || top === null || width === null || height === null) return null;
|
|
68
|
+
if (!(width > 0) || !(height > 0)) return null;
|
|
69
|
+
return { left: left, top: top, width: width, height: height, right: left + width, bottom: top + height };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Is the rect fully inside the viewport (0.5 px rounding allowed, half a card not allowed). */
|
|
73
|
+
function wfFxInside(rect, view) {
|
|
74
|
+
if (!rect || !view) return false;
|
|
75
|
+
return (
|
|
76
|
+
rect.left >= view.left - 0.5 &&
|
|
77
|
+
rect.right <= view.right + 0.5 &&
|
|
78
|
+
rect.top >= view.top - 0.5 &&
|
|
79
|
+
rect.bottom <= view.bottom + 0.5
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A cubic Bezier between two points. Control points offset horizontally only (the classic "S bend"):
|
|
85
|
+
* M x1 y1 C x1+bow y1, x2-bow y2, x2 y2
|
|
86
|
+
* Gap too narrow (< WF_FX_MIN_GAP) or invalid coordinates -> null, and the caller hides the whole layer.
|
|
87
|
+
*
|
|
88
|
+
* @returns {{d:string, from:{x:number,y:number}, to:{x:number,y:number}, dx:number, bow:number}|null}
|
|
89
|
+
*/
|
|
90
|
+
function wfFxCurve(fromX, fromY, toX, toY) {
|
|
91
|
+
var x1 = wfFinite(fromX);
|
|
92
|
+
var y1 = wfFinite(fromY);
|
|
93
|
+
var x2 = wfFinite(toX);
|
|
94
|
+
var y2 = wfFinite(toY);
|
|
95
|
+
if (x1 === null || y1 === null || x2 === null || y2 === null) return null;
|
|
96
|
+
var dx = x2 - x1;
|
|
97
|
+
if (!(dx >= WF_FX_MIN_GAP)) return null;
|
|
98
|
+
var bow = wfClamp(dx * 0.45, WF_FX_BOW_MIN, WF_FX_BOW_MAX);
|
|
99
|
+
if (bow > dx * 0.5) bow = dx * 0.5;
|
|
100
|
+
var ax = wfFxRound(x1);
|
|
101
|
+
var ay = wfFxRound(y1);
|
|
102
|
+
var bx = wfFxRound(x2);
|
|
103
|
+
var by = wfFxRound(y2);
|
|
104
|
+
var c1 = wfFxRound(x1 + bow);
|
|
105
|
+
var c2 = wfFxRound(x2 - bow);
|
|
106
|
+
return {
|
|
107
|
+
d: "M" + ax + " " + ay + "C" + c1 + " " + ay + " " + c2 + " " + by + " " + bx + " " + by,
|
|
108
|
+
from: { x: ax, y: ay },
|
|
109
|
+
to: { x: bx, y: by },
|
|
110
|
+
dx: wfFxRound(dx),
|
|
111
|
+
bow: wfFxRound(bow),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Two cards + their own scroll viewports -> whether to draw this frame, and where.
|
|
117
|
+
*
|
|
118
|
+
* `origin` is the SVG rect (not the .wf-body border box: an absolutely positioned element's containing block is the padding box,
|
|
119
|
+
* so using the SVG's own rect keeps coordinates aligned with its internal user coordinate system).
|
|
120
|
+
*
|
|
121
|
+
* reason: ok | no-origin | no-step | no-node | step-clipped | node-clipped | no-gap
|
|
122
|
+
*/
|
|
123
|
+
function wfFxGeometry(stepRect, nodeRect, origin, stepClip, nodeClip) {
|
|
124
|
+
var originBox = wfFxBounds(origin);
|
|
125
|
+
if (!originBox) return { visible: false, reason: "no-origin", d: null };
|
|
126
|
+
var step = wfFxBounds(stepRect);
|
|
127
|
+
if (!step) return { visible: false, reason: "no-step", d: null };
|
|
128
|
+
var node = wfFxBounds(nodeRect);
|
|
129
|
+
if (!node) return { visible: false, reason: "no-node", d: null };
|
|
130
|
+
var stepView = stepClip === undefined || stepClip === null ? null : wfFxBounds(stepClip);
|
|
131
|
+
var nodeView = nodeClip === undefined || nodeClip === null ? null : wfFxBounds(nodeClip);
|
|
132
|
+
if (stepView && !wfFxInside(step, stepView)) return { visible: false, reason: "step-clipped", d: null };
|
|
133
|
+
if (nodeView && !wfFxInside(node, nodeView)) return { visible: false, reason: "node-clipped", d: null };
|
|
134
|
+
|
|
135
|
+
var fromX = step.right - originBox.left;
|
|
136
|
+
var fromY = step.top + step.height / 2 - originBox.top;
|
|
137
|
+
var toX = node.left - originBox.left;
|
|
138
|
+
var toY = node.top + node.height / 2 - originBox.top;
|
|
139
|
+
var curve = wfFxCurve(fromX, fromY, toX, toY);
|
|
140
|
+
if (!curve) return { visible: false, reason: "no-gap", d: null };
|
|
141
|
+
return {
|
|
142
|
+
visible: true,
|
|
143
|
+
reason: "ok",
|
|
144
|
+
d: curve.d,
|
|
145
|
+
from: curve.from,
|
|
146
|
+
to: curve.to,
|
|
147
|
+
dx: curve.dx,
|
|
148
|
+
bow: curve.bow,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ------------------------------------------------------------------------- */
|
|
153
|
+
/* DOM */
|
|
154
|
+
/* ------------------------------------------------------------------------- */
|
|
155
|
+
|
|
156
|
+
/** One query returns everything: container, SVG, both cards, both scroll viewports. Missing any of them means no guessing. */
|
|
157
|
+
function wfFxQuery(doc) {
|
|
158
|
+
if (!doc || typeof doc.querySelector !== "function") return null;
|
|
159
|
+
var body = doc.querySelector('[data-dsh-workflow-map="body"]');
|
|
160
|
+
if (!body) return null;
|
|
161
|
+
return {
|
|
162
|
+
body: body,
|
|
163
|
+
svg: body.querySelector('[data-dsh-workflow-map="fx"]'),
|
|
164
|
+
// Left column: the running step card (the li carries data-status; the card is its direct child)
|
|
165
|
+
stepCard: body.querySelector('.wf-step-item[data-status="running"] > .wf-step-card'),
|
|
166
|
+
// Right column: the node card being called (the same li's data-status is the only running one)
|
|
167
|
+
nodeCard: body.querySelector('.wf-flow-item[data-status="running"] > .wf-node'),
|
|
168
|
+
stepClip: body.querySelector(".wf-steps-scroll"),
|
|
169
|
+
nodeClip: body.querySelector(".wf-flow-scroll"),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function wfFxRectOf(element) {
|
|
174
|
+
if (!element || typeof element.getBoundingClientRect !== "function") return null;
|
|
175
|
+
try {
|
|
176
|
+
return element.getBoundingClientRect();
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/* ------------------------------------------------------------------------- */
|
|
182
|
+
/* the chain's curve channel */
|
|
183
|
+
/* ------------------------------------------------------------------------- */
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Two node cards + the list's own rect → ONE cubic that links them.
|
|
187
|
+
*
|
|
188
|
+
* The shape is the step's "vertical midpoint + horizontal bulge": the path leaves the upper card's bottom edge and
|
|
189
|
+
* arrives at the lower card's top edge, with both control points pulled to the VERTICAL midpoint
|
|
190
|
+
* and pushed sideways by `bow`. Sideways is what makes it an S without a corner: a straight
|
|
191
|
+
* vertical hop with a single control point in the middle would still kink at both ends.
|
|
192
|
+
*
|
|
193
|
+
* Coordinates are the list's user space (origin = the list's own rect), so the SVG needs no
|
|
194
|
+
* viewBox and 1 user unit is 1 px — the same trick the cross-column curve uses.
|
|
195
|
+
*
|
|
196
|
+
* reason: ok | no-origin | no-cards | too-close
|
|
197
|
+
*/
|
|
198
|
+
function wfChainCurve(origin, upper, lower) {
|
|
199
|
+
var box = wfFxBounds(origin);
|
|
200
|
+
if (!box) return { visible: false, reason: "no-origin", d: null };
|
|
201
|
+
var a = wfFxBounds(upper);
|
|
202
|
+
var b = wfFxBounds(lower);
|
|
203
|
+
if (!a || !b) return { visible: false, reason: "no-cards", d: null };
|
|
204
|
+
|
|
205
|
+
var fromX = a.left + a.width / 2 - box.left;
|
|
206
|
+
var fromY = a.bottom - box.top;
|
|
207
|
+
var toX = b.left + b.width / 2 - box.left;
|
|
208
|
+
var toY = b.top - box.top;
|
|
209
|
+
var dy = toY - fromY;
|
|
210
|
+
// Two cards that touch (or overlap) have no gap to draw in. Drawing a zero-length curve would
|
|
211
|
+
// put a stray mark on a card's edge, so the link is simply absent — an honest hole, not a lie.
|
|
212
|
+
if (!(dy > 0)) return { visible: false, reason: "too-close", d: null };
|
|
213
|
+
|
|
214
|
+
// The sideways bow ("horizontal bulge"): a share of the horizontal offset plus a fixed 10 px, clamped so
|
|
215
|
+
// an aligned pair still bulges into a readable S instead of collapsing into a straight line.
|
|
216
|
+
var bow = wfClamp(Math.abs(toX - fromX) * 0.6 + 10, 8, 40);
|
|
217
|
+
// Both control points sit on the VERTICAL MIDPOINT of the gap, so the path leaves and arrives
|
|
218
|
+
// pointing straight down — no kink at either end — and the S is formed by the sideways push.
|
|
219
|
+
var mid = wfFxRound(fromY + dy * 0.5);
|
|
220
|
+
var c1x = wfFxRound(fromX + bow);
|
|
221
|
+
var c1y = mid;
|
|
222
|
+
var c2x = wfFxRound(toX - bow);
|
|
223
|
+
var c2y = mid;
|
|
224
|
+
return {
|
|
225
|
+
visible: true,
|
|
226
|
+
reason: "ok",
|
|
227
|
+
d:
|
|
228
|
+
"M" +
|
|
229
|
+
wfFxRound(fromX) +
|
|
230
|
+
" " +
|
|
231
|
+
wfFxRound(fromY) +
|
|
232
|
+
"C" +
|
|
233
|
+
c1x +
|
|
234
|
+
" " +
|
|
235
|
+
c1y +
|
|
236
|
+
" " +
|
|
237
|
+
c2x +
|
|
238
|
+
" " +
|
|
239
|
+
c2y +
|
|
240
|
+
" " +
|
|
241
|
+
wfFxRound(toX) +
|
|
242
|
+
" " +
|
|
243
|
+
wfFxRound(toY),
|
|
244
|
+
from: { x: wfFxRound(fromX), y: wfFxRound(fromY) },
|
|
245
|
+
to: { x: wfFxRound(toX), y: wfFxRound(toY) },
|
|
246
|
+
dy: wfFxRound(dy),
|
|
247
|
+
bow: wfFxRound(bow),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build the chain's `d` list for the cards currently in the list. Cards are matched by their
|
|
253
|
+
* `data-status` only insofar as the LINK colour cares; the geometry needs nothing but their order
|
|
254
|
+
* and their boxes, which is why grouped runs and folded rows work without a special case.
|
|
255
|
+
*
|
|
256
|
+
* Returns {visible, reason, links:[{d, done}]} — one entry per adjacent pair that could be drawn.
|
|
257
|
+
*/
|
|
258
|
+
function wfChainGeometry(listRect, cards) {
|
|
259
|
+
var box = wfFxBounds(listRect);
|
|
260
|
+
if (!box) return { visible: false, reason: "no-origin", links: [] };
|
|
261
|
+
var rows = Array.isArray(cards) ? cards : [];
|
|
262
|
+
if (rows.length < 2) return { visible: false, reason: "too-few", links: [] };
|
|
263
|
+
|
|
264
|
+
var links = [];
|
|
265
|
+
var pairs = 0;
|
|
266
|
+
for (var i = 0; i + 1 < rows.length; i += 1) {
|
|
267
|
+
var pair = rows[i];
|
|
268
|
+
var next = rows[i + 1];
|
|
269
|
+
if (!pair || !next) continue;
|
|
270
|
+
pairs += 1;
|
|
271
|
+
var curve = wfChainCurve(box, pair.rect, next.rect);
|
|
272
|
+
if (!curve.visible) continue;
|
|
273
|
+
links.push({ d: curve.d, from: curve.from, to: curve.to, done: pair.done === true && next.done === true });
|
|
274
|
+
}
|
|
275
|
+
if (links.length === 0) return { visible: false, reason: "too-close", links: [] };
|
|
276
|
+
return { visible: true, reason: "ok", links: links, pairs: pairs };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The card boxes the chain links, in list order. A group row (`li.wf-group`) and a node row
|
|
281
|
+
* (`li.wf-flow-item`) are both links in the SAME chain — the spine runs through both, so the
|
|
282
|
+
* measurement must not skip a folded run. `done` comes off the row's own status attribute.
|
|
283
|
+
*
|
|
284
|
+
* Returns null when there is no list at all (a planning card, an empty step).
|
|
285
|
+
*/
|
|
286
|
+
function wfChainCards(doc, list) {
|
|
287
|
+
if (!list || typeof list.querySelectorAll !== "function") return null;
|
|
288
|
+
var rows = list.querySelectorAll(":scope > .wf-flow-item, :scope > .wf-group");
|
|
289
|
+
var out = [];
|
|
290
|
+
for (var i = 0; i < (rows ? rows.length : 0); i += 1) {
|
|
291
|
+
var row = rows[i];
|
|
292
|
+
if (!row) continue;
|
|
293
|
+
// The CONNECTOR is drawn between the CARDS, not between the list rows: a node row's card is
|
|
294
|
+
// its direct <button>, a group's card is the dashed head. Falling back to the row keeps the
|
|
295
|
+
// link honest for any future row shape instead of silently dropping it.
|
|
296
|
+
var card = null;
|
|
297
|
+
if (typeof row.querySelector === "function") card = row.querySelector(":scope > .wf-node, :scope > .wf-group-row > .wf-group-head");
|
|
298
|
+
if (!card) card = row;
|
|
299
|
+
var status = typeof row.getAttribute === "function" ? row.getAttribute("data-status") : null;
|
|
300
|
+
out.push({ rect: wfFxRectOf(card), done: status === "done", status: status });
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Measure and write the chain layer. Returns the geometry it wrote (the self-test and the browser
|
|
307
|
+
* probe both read it); writes `data-visible` / `data-reason` / `data-links` onto the SVG so the
|
|
308
|
+
* numbers are evidence in the DOM rather than a claim in a log.
|
|
309
|
+
*
|
|
310
|
+
* A missing layer or a missing list is NOT an error: the panel renders the theme's static spine
|
|
311
|
+
* when this returns null, which is exactly the degraded case the stylesheet documents.
|
|
312
|
+
*/
|
|
313
|
+
function wfChainApply(doc) {
|
|
314
|
+
if (!doc || typeof doc.querySelector !== "function") return null;
|
|
315
|
+
var list = doc.querySelector(".wf-flow-list");
|
|
316
|
+
var layer = list && typeof list.querySelector === "function" ? list.querySelector('[data-dsh-workflow-map="chain"]') : null;
|
|
317
|
+
if (!layer) return null;
|
|
318
|
+
var cards = wfChainCards(doc, list);
|
|
319
|
+
var origin = wfFxRectOf(list);
|
|
320
|
+
var geo = wfChainGeometry(origin, cards || []);
|
|
321
|
+
var paths = typeof layer.querySelectorAll === "function" ? layer.querySelectorAll(".wf-chain-core") : null;
|
|
322
|
+
var count = paths ? paths.length : 0;
|
|
323
|
+
|
|
324
|
+
if (!geo.visible) {
|
|
325
|
+
for (var i = 0; i < count; i += 1) {
|
|
326
|
+
if (paths[i] && typeof paths[i].setAttribute === "function") paths[i].setAttribute("d", "");
|
|
327
|
+
}
|
|
328
|
+
layer.setAttribute("data-visible", "false");
|
|
329
|
+
layer.setAttribute("data-reason", geo.reason);
|
|
330
|
+
layer.setAttribute("data-links", "0");
|
|
331
|
+
return geo;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The layer is rendered with a FIXED number of paths (WF_CHAIN_LINK_SLOTS): an SVG child list
|
|
335
|
+
// cannot be resized from here, so extra paths are blanked and missing ones reported honestly
|
|
336
|
+
// rather than quietly drawing fewer links than there are cards.
|
|
337
|
+
var drawn = 0;
|
|
338
|
+
for (var p = 0; p < count; p += 1) {
|
|
339
|
+
var link = geo.links[p];
|
|
340
|
+
if (!paths[p] || typeof paths[p].setAttribute !== "function") continue;
|
|
341
|
+
paths[p].setAttribute("d", link ? link.d : "");
|
|
342
|
+
if (link) {
|
|
343
|
+
drawn += 1;
|
|
344
|
+
paths[p].setAttribute("data-done", link.done ? "true" : "false");
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
layer.setAttribute("data-visible", drawn > 0 ? "true" : "false");
|
|
348
|
+
layer.setAttribute("data-reason", drawn === geo.links.length ? "ok" : "slot-shortfall");
|
|
349
|
+
layer.setAttribute("data-links", String(drawn));
|
|
350
|
+
layer.setAttribute("data-endpoints", JSON.stringify(geo.links.map(function (one) {
|
|
351
|
+
return { from: one.from, to: one.to, d: one.d };
|
|
352
|
+
})));
|
|
353
|
+
return geo;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function wfFxSetCaps(caps, from, to) {
|
|
357
|
+
if (!caps || typeof caps.length !== "number") return;
|
|
358
|
+
if (caps[0] && typeof caps[0].setAttribute === "function") {
|
|
359
|
+
caps[0].setAttribute("cx", String(from ? from.x : WF_FX_OFFSCREEN));
|
|
360
|
+
caps[0].setAttribute("cy", String(from ? from.y : WF_FX_OFFSCREEN));
|
|
361
|
+
}
|
|
362
|
+
if (caps[1] && typeof caps[1].setAttribute === "function") {
|
|
363
|
+
caps[1].setAttribute("cx", String(to ? to.x : WF_FX_OFFSCREEN));
|
|
364
|
+
caps[1].setAttribute("cy", String(to ? to.y : WF_FX_OFFSCREEN));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Measure once and write the result into the DOM. Returns the geometry (read by the tool and the self-test).
|
|
370
|
+
* Three things written into the DOM: the path's `d`, the two endpoint circles' cx/cy, and `data-visible` /
|
|
371
|
+
* `data-endpoints` (JSON, so a headless self-check takes evidence directly instead of guessing).
|
|
372
|
+
*/
|
|
373
|
+
function wfFxApply(doc) {
|
|
374
|
+
var found = wfFxQuery(doc);
|
|
375
|
+
if (!found || !found.svg) return { visible: false, reason: "no-layer", d: null };
|
|
376
|
+
var svg = found.svg;
|
|
377
|
+
var geo = wfFxGeometry(
|
|
378
|
+
wfFxRectOf(found.stepCard),
|
|
379
|
+
wfFxRectOf(found.nodeCard),
|
|
380
|
+
wfFxRectOf(svg),
|
|
381
|
+
wfFxRectOf(found.stepClip),
|
|
382
|
+
wfFxRectOf(found.nodeClip)
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
var core = typeof svg.querySelector === "function" ? svg.querySelector(".wf-fx-core") : null;
|
|
386
|
+
var glow = typeof svg.querySelector === "function" ? svg.querySelector(".wf-fx-glow") : null;
|
|
387
|
+
var caps = typeof svg.querySelectorAll === "function" ? svg.querySelectorAll(".wf-fx-cap") : null;
|
|
388
|
+
|
|
389
|
+
if (!geo.visible) {
|
|
390
|
+
if (core) core.setAttribute("d", "");
|
|
391
|
+
if (glow) glow.setAttribute("d", "");
|
|
392
|
+
wfFxSetCaps(caps, null, null);
|
|
393
|
+
svg.setAttribute("data-visible", "false");
|
|
394
|
+
svg.setAttribute("data-reason", geo.reason);
|
|
395
|
+
svg.setAttribute("data-endpoints", JSON.stringify({ visible: false, reason: geo.reason }));
|
|
396
|
+
return geo;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (core) core.setAttribute("d", geo.d);
|
|
400
|
+
if (glow) glow.setAttribute("d", geo.d);
|
|
401
|
+
wfFxSetCaps(caps, geo.from, geo.to);
|
|
402
|
+
svg.setAttribute("data-visible", "true");
|
|
403
|
+
svg.setAttribute("data-reason", "ok");
|
|
404
|
+
svg.setAttribute(
|
|
405
|
+
"data-endpoints",
|
|
406
|
+
JSON.stringify({ visible: true, from: geo.from, to: geo.to, dx: geo.dx, d: geo.d })
|
|
407
|
+
);
|
|
408
|
+
return geo;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Bind measurement to the page lifecycle: window resize, any scroll (capture, so both columns' inner scrolling is seen),
|
|
413
|
+
* and container size changes (ResizeObserver). Everything goes through a rAF throttle, at most one computation per frame.
|
|
414
|
+
*
|
|
415
|
+
* Returns { refresh, schedule, destroy, measures }: `refresh` measures synchronously, `measures` reports
|
|
416
|
+
* how many measurements happened (the headless check uses it to prove recomputation after scroll/resize, not just the first frame).
|
|
417
|
+
*/
|
|
418
|
+
function wfFxBind(doc, win) {
|
|
419
|
+
if (!doc || !win) return null;
|
|
420
|
+
var state = { destroyed: false, frame: 0, measures: 0 };
|
|
421
|
+
var observed = null;
|
|
422
|
+
var raf =
|
|
423
|
+
typeof win.requestAnimationFrame === "function"
|
|
424
|
+
? function (fn) {
|
|
425
|
+
return win.requestAnimationFrame(fn);
|
|
426
|
+
}
|
|
427
|
+
: function (fn) {
|
|
428
|
+
if (typeof win.setTimeout !== "function") return 0;
|
|
429
|
+
return win.setTimeout(fn, 16);
|
|
430
|
+
};
|
|
431
|
+
var cancel =
|
|
432
|
+
typeof win.cancelAnimationFrame === "function"
|
|
433
|
+
? function (id) {
|
|
434
|
+
win.cancelAnimationFrame(id);
|
|
435
|
+
}
|
|
436
|
+
: function (id) {
|
|
437
|
+
if (typeof win.clearTimeout === "function") win.clearTimeout(id);
|
|
438
|
+
};
|
|
439
|
+
// The safety net for environments where rAF never runs (headless --dump-dom, a throttled
|
|
440
|
+
// background tab). Absent timers are simply no fallback, not an error.
|
|
441
|
+
var timerSet =
|
|
442
|
+
typeof win.setTimeout === "function"
|
|
443
|
+
? function (fn, ms) {
|
|
444
|
+
return win.setTimeout(fn, ms);
|
|
445
|
+
}
|
|
446
|
+
: null;
|
|
447
|
+
var timerClear =
|
|
448
|
+
typeof win.clearTimeout === "function"
|
|
449
|
+
? function (id) {
|
|
450
|
+
win.clearTimeout(id);
|
|
451
|
+
}
|
|
452
|
+
: null;
|
|
453
|
+
|
|
454
|
+
function stamp(count) {
|
|
455
|
+
var body = doc.querySelector('[data-dsh-workflow-map="body"]');
|
|
456
|
+
var svg = body && body.querySelector ? body.querySelector('[data-dsh-workflow-map="fx"]') : null;
|
|
457
|
+
if (svg && typeof svg.setAttribute === "function") svg.setAttribute("data-measures", String(count));
|
|
458
|
+
return svg;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function refresh() {
|
|
462
|
+
if (state.destroyed) return null;
|
|
463
|
+
var geo = wfFxApply(doc);
|
|
464
|
+
// the chain's node-to-node links live in the flow list and are measured from the SAME
|
|
465
|
+
// frame — one schedule() drives both layers, so a scroll cannot leave one of them stale.
|
|
466
|
+
wfChainApply(doc);
|
|
467
|
+
state.measures += 1;
|
|
468
|
+
stamp(state.measures);
|
|
469
|
+
return geo;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// One pending measure per schedule(), whichever clock wins first.
|
|
473
|
+
// rAF is the normal path (it lands with the frame), but it is NOT reliable everywhere: a
|
|
474
|
+
// headless --dump-dom page, a background tab or a throttled frame never fires it, and then
|
|
475
|
+
// the curve would silently stop following the cards. A short timer is the safety net; the
|
|
476
|
+
// token makes the first one win and the second a no-op.
|
|
477
|
+
function schedule() {
|
|
478
|
+
if (state.destroyed || state.frame) return;
|
|
479
|
+
var token = { ran: false, handle: 0, timer: 0 };
|
|
480
|
+
state.frame = token;
|
|
481
|
+
function run(source) {
|
|
482
|
+
if (state.destroyed || token.ran) return;
|
|
483
|
+
token.ran = true;
|
|
484
|
+
if (state.frame === token) state.frame = 0;
|
|
485
|
+
if (token.timer && timerClear) timerClear(token.timer);
|
|
486
|
+
token.timer = 0;
|
|
487
|
+
// The rAF handle stays cancellable but its callback is a no-op after the token ran.
|
|
488
|
+
token.handle = source === "timer" ? token.handle : 0;
|
|
489
|
+
refresh();
|
|
490
|
+
}
|
|
491
|
+
token.handle = raf(function () {
|
|
492
|
+
run("raf");
|
|
493
|
+
});
|
|
494
|
+
if (timerSet) token.timer = timerSet(function () {
|
|
495
|
+
run("timer");
|
|
496
|
+
}, WF_FX_FALLBACK_MS);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function onScroll() {
|
|
500
|
+
schedule();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// capture=true: each column is its own overflow:auto scroller, so scroll does not bubble to window.
|
|
504
|
+
doc.addEventListener("scroll", onScroll, true);
|
|
505
|
+
win.addEventListener("resize", onScroll);
|
|
506
|
+
|
|
507
|
+
if (typeof win.ResizeObserver === "function") {
|
|
508
|
+
try {
|
|
509
|
+
observed = new win.ResizeObserver(onScroll);
|
|
510
|
+
var found = wfFxQuery(doc);
|
|
511
|
+
if (found) {
|
|
512
|
+
if (found.body) observed.observe(found.body);
|
|
513
|
+
if (found.stepClip) observed.observe(found.stepClip);
|
|
514
|
+
if (found.nodeClip) observed.observe(found.nodeClip);
|
|
515
|
+
if (found.stepCard) observed.observe(found.stepCard);
|
|
516
|
+
if (found.nodeCard) observed.observe(found.nodeCard);
|
|
517
|
+
}
|
|
518
|
+
} catch (error) {
|
|
519
|
+
observed = null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
refresh();
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
refresh: refresh,
|
|
527
|
+
schedule: schedule,
|
|
528
|
+
measures: function () {
|
|
529
|
+
return state.measures;
|
|
530
|
+
},
|
|
531
|
+
destroy: function () {
|
|
532
|
+
state.destroyed = true;
|
|
533
|
+
var pending = state.frame;
|
|
534
|
+
if (pending && typeof pending === "object") {
|
|
535
|
+
// A queued frame may be on the rAF clock, on the fallback timer, or on both.
|
|
536
|
+
if (pending.handle) cancel(pending.handle);
|
|
537
|
+
if (pending.timer && timerClear) timerClear(pending.timer);
|
|
538
|
+
}
|
|
539
|
+
state.frame = 0;
|
|
540
|
+
doc.removeEventListener("scroll", onScroll, true);
|
|
541
|
+
win.removeEventListener("resize", onScroll);
|
|
542
|
+
if (observed && typeof observed.disconnect === "function") observed.disconnect();
|
|
543
|
+
observed = null;
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Visual signature: rebind only when something that matters changed (an unchanged polled model does not jitter every 800 ms). */
|
|
549
|
+
function wfFxSignature(model) {
|
|
550
|
+
if (!model) return "none";
|
|
551
|
+
var flow = model.flow && typeof model.flow === "object" ? model.flow : {};
|
|
552
|
+
var nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
553
|
+
var runningStep = null;
|
|
554
|
+
for (var i = 0; i < model.steps.length; i += 1) {
|
|
555
|
+
if (model.steps[i] && model.steps[i].status === "running") {
|
|
556
|
+
runningStep = model.steps[i].index;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
var ids = [];
|
|
561
|
+
for (var k = Math.max(0, nodes.length - 8); k < nodes.length; k += 1) ids.push(nodes[k] ? nodes[k].id : "-");
|
|
562
|
+
return [
|
|
563
|
+
model.kind,
|
|
564
|
+
model.selected,
|
|
565
|
+
runningStep,
|
|
566
|
+
flow.activeNodeId || "",
|
|
567
|
+
nodes.length,
|
|
568
|
+
ids.join(","),
|
|
569
|
+
model.steps.length,
|
|
570
|
+
model.running === false ? "idle" : "live",
|
|
571
|
+
].join("|");
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The component mount point: signature change -> rebind; unmount -> remove everything (no listeners left behind). */
|
|
575
|
+
function wfUseFlowEffects(model) {
|
|
576
|
+
var signature = wfFxSignature(model);
|
|
577
|
+
React.useEffect(
|
|
578
|
+
function () {
|
|
579
|
+
if (typeof document === "undefined" || typeof window === "undefined") return undefined;
|
|
580
|
+
var binding = wfFxBind(document, window);
|
|
581
|
+
if (!binding) return undefined;
|
|
582
|
+
return function () {
|
|
583
|
+
binding.destroy();
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
[signature]
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* ------------------------------------------------------------------------- */
|
|
591
|
+
/* markup */
|
|
592
|
+
/* ------------------------------------------------------------------------- */
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The curve layer itself. Lives in .wf-body (after the two columns, absolutely positioned over the whole body);
|
|
596
|
+
* before measuring it carries data-visible="false" (display:none in CSS) and appears only once measured.
|
|
597
|
+
*
|
|
598
|
+
* Stop colours come from CSS classes (stop-color is a CSS property in Chrome):
|
|
599
|
+
* both React and the self-test's fake `h` pass it through unchanged, with no extra attribute mapping.
|
|
600
|
+
*/
|
|
601
|
+
function wfRenderFlowEffects(h) {
|
|
602
|
+
var rainbow = [];
|
|
603
|
+
var colors = WF_FX_RAINBOW;
|
|
604
|
+
for (var i = 0; i < colors.length; i += 1) {
|
|
605
|
+
rainbow.push(
|
|
606
|
+
h("stop", {
|
|
607
|
+
key: "r" + i,
|
|
608
|
+
offset: Math.round((i / (colors.length - 1)) * 100) + "%",
|
|
609
|
+
className: "wf-fx-stop wf-fx-stop-" + (i + 1),
|
|
610
|
+
})
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
var brand = [];
|
|
614
|
+
for (var b = 0; b < WF_FX_BRAND.length; b += 1) {
|
|
615
|
+
brand.push(
|
|
616
|
+
h("stop", {
|
|
617
|
+
key: "b" + b,
|
|
618
|
+
offset: Math.round((b / (WF_FX_BRAND.length - 1)) * 100) + "%",
|
|
619
|
+
className: "wf-fx-stop wf-fx-stop-b" + (b + 1),
|
|
620
|
+
})
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return h(
|
|
625
|
+
"svg",
|
|
626
|
+
{
|
|
627
|
+
className: "wf-fx",
|
|
628
|
+
key: "fx",
|
|
629
|
+
"data-dsh-workflow-map": "fx",
|
|
630
|
+
"data-visible": "false",
|
|
631
|
+
"data-curve": WF_CURVE_MODE,
|
|
632
|
+
"aria-hidden": "true",
|
|
633
|
+
focusable: "false",
|
|
634
|
+
},
|
|
635
|
+
h(
|
|
636
|
+
"defs",
|
|
637
|
+
{ key: "defs" },
|
|
638
|
+
h("linearGradient", { key: "rainbow", id: "wf-fx-rainbow" }, rainbow),
|
|
639
|
+
h("linearGradient", { key: "brand", id: "wf-fx-brand" }, brand)
|
|
640
|
+
),
|
|
641
|
+
h("path", { key: "glow", className: "wf-fx-glow", d: "" }),
|
|
642
|
+
h("path", { key: "core", className: "wf-fx-core", d: "" }),
|
|
643
|
+
h("circle", { key: "cap-a", className: "wf-fx-cap", cx: WF_FX_OFFSCREEN, cy: WF_FX_OFFSCREEN, r: 3.4 }),
|
|
644
|
+
h("circle", { key: "cap-b", className: "wf-fx-cap", cx: WF_FX_OFFSCREEN, cy: WF_FX_OFFSCREEN, r: 3.4 })
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* the chain layer: one <svg> INSIDE the flow list, and one <path> per link slot.
|
|
650
|
+
*
|
|
651
|
+
* WHY ONE LAYER AND NOT ONE SVG PER GAP: the gaps are already reserved by the list's `gap`, and a
|
|
652
|
+
* layer per gap would need a box per gap — which drifts the moment the fold expands. One layer
|
|
653
|
+
* covering the whole list is measured in one rect and draws every link from the same origin.
|
|
654
|
+
*
|
|
655
|
+
* The slots are fixed (WF_CHAIN_LINK_SLOTS) because the measuring code can only write into paths
|
|
656
|
+
* that exist. Unused slots stay empty (`d=""`), which paints nothing at all.
|
|
657
|
+
*/
|
|
658
|
+
function wfRenderChain(h) {
|
|
659
|
+
var paths = [];
|
|
660
|
+
for (var i = 0; i < WF_CHAIN_LINK_SLOTS; i += 1) {
|
|
661
|
+
paths.push(h("path", { key: "l" + i, className: "wf-chain-core", d: "" }));
|
|
662
|
+
}
|
|
663
|
+
return h(
|
|
664
|
+
"svg",
|
|
665
|
+
{
|
|
666
|
+
className: "wf-chain",
|
|
667
|
+
key: "chain",
|
|
668
|
+
"data-dsh-workflow-map": "chain",
|
|
669
|
+
"data-visible": "false",
|
|
670
|
+
"data-reason": "unmeasured",
|
|
671
|
+
"data-links": "0",
|
|
672
|
+
"aria-hidden": "true",
|
|
673
|
+
focusable: "false",
|
|
674
|
+
},
|
|
675
|
+
paths
|
|
676
|
+
);
|
|
677
|
+
}
|