capdag 0.174.430 → 0.176.436
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/cap-fab-renderer.js +649 -11
- package/capdag.js +12 -6
- package/capdag.test.js +2 -2
- package/package.json +1 -1
package/cap-fab-renderer.js
CHANGED
|
@@ -148,9 +148,219 @@ function cssVarNumber(name, fallback) {
|
|
|
148
148
|
return parsed;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Label shaping — fit-to-content with a soft max width. The renderer's
|
|
153
|
+
// node sizing is `width: 'label'` / `height: 'label'` (the cytoscape style
|
|
154
|
+
// rule), which means each node grows or shrinks to its text. We pre-shape
|
|
155
|
+
// every label so:
|
|
156
|
+
//
|
|
157
|
+
// * a single short label produces a single short line (no preset width);
|
|
158
|
+
// * a label longer than the soft max wraps to a second line;
|
|
159
|
+
// * a label longer than two soft-max-width lines is truncated with a
|
|
160
|
+
// trailing horizontal ellipsis instead of overflowing or wrapping
|
|
161
|
+
// further. Ellipses appear only past the second line.
|
|
162
|
+
//
|
|
163
|
+
// Cytoscape's label-wrap implementation respects newlines and the
|
|
164
|
+
// `text-max-width` style rule, but it cannot truncate-after-N-lines on
|
|
165
|
+
// its own. So we do the wrap+truncate here in JS, set the label to the
|
|
166
|
+
// shaped string, and drop `text-max-width` from the stylesheet — the
|
|
167
|
+
// label text itself dictates the node's width.
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
// Soft node label width in pixels. Picked to keep nodes readable on the
|
|
171
|
+
// inline (non-expanded) panel while still tolerating the longer media
|
|
172
|
+
// titles. Edge labels are NOT wrapped; their length feeds the layout
|
|
173
|
+
// engine's between-layer spacing instead.
|
|
174
|
+
const NODE_LABEL_SOFT_MAX_WIDTH_PX = 160;
|
|
175
|
+
// Maximum number of wrapped lines for a node label. Beyond this we
|
|
176
|
+
// truncate with `…` rather than letting the label overflow.
|
|
177
|
+
const NODE_LABEL_MAX_LINES = 2;
|
|
178
|
+
|
|
179
|
+
// Shared offscreen canvas used for label width measurement. Lives at
|
|
180
|
+
// module scope so we don't churn the GC creating one per call.
|
|
181
|
+
let __sharedMeasureCtx = null;
|
|
182
|
+
function measureTextWidth(text, font) {
|
|
183
|
+
if (typeof document === 'undefined') {
|
|
184
|
+
// Non-DOM environment (e.g. node tests). Approximate with a
|
|
185
|
+
// monospace-ish constant so the wrap heuristic still produces
|
|
186
|
+
// sensible output and tests can exercise it without a canvas.
|
|
187
|
+
return text.length * 6.5;
|
|
188
|
+
}
|
|
189
|
+
if (__sharedMeasureCtx === null) {
|
|
190
|
+
const canvas = document.createElement('canvas');
|
|
191
|
+
__sharedMeasureCtx = canvas.getContext('2d');
|
|
192
|
+
}
|
|
193
|
+
__sharedMeasureCtx.font = font;
|
|
194
|
+
return __sharedMeasureCtx.measureText(text).width;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Break `text` into chunks each of which fits within `maxWidth` when
|
|
198
|
+
// rendered with `font`. Word-aware: prefers to break at whitespace, but
|
|
199
|
+
// will hard-break a single word that is wider than `maxWidth`.
|
|
200
|
+
function wrapTextToWidth(text, font, maxWidth) {
|
|
201
|
+
if (text.length === 0) return [''];
|
|
202
|
+
const words = text.split(/(\s+)/); // keep the whitespace runs
|
|
203
|
+
const lines = [];
|
|
204
|
+
let current = '';
|
|
205
|
+
function pushCurrent() {
|
|
206
|
+
if (current.length > 0) {
|
|
207
|
+
lines.push(current);
|
|
208
|
+
current = '';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const piece of words) {
|
|
212
|
+
if (piece.length === 0) continue;
|
|
213
|
+
const candidate = current + piece;
|
|
214
|
+
if (measureTextWidth(candidate, font) <= maxWidth) {
|
|
215
|
+
current = candidate;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (/^\s+$/.test(piece)) {
|
|
219
|
+
// The whitespace itself overflows — start a new line and skip it.
|
|
220
|
+
pushCurrent();
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (current.length === 0) {
|
|
224
|
+
// Single word wider than maxWidth — hard-break it character by
|
|
225
|
+
// character. The terminal piece becomes `current` for the next
|
|
226
|
+
// round so a subsequent short word can still join it.
|
|
227
|
+
let chunk = '';
|
|
228
|
+
for (const ch of piece) {
|
|
229
|
+
if (measureTextWidth(chunk + ch, font) <= maxWidth) {
|
|
230
|
+
chunk += ch;
|
|
231
|
+
} else {
|
|
232
|
+
if (chunk.length > 0) lines.push(chunk);
|
|
233
|
+
chunk = ch;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
current = chunk;
|
|
237
|
+
} else {
|
|
238
|
+
pushCurrent();
|
|
239
|
+
current = piece.replace(/^\s+/, '');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
pushCurrent();
|
|
243
|
+
return lines.length === 0 ? [''] : lines;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Truncate a single line so that it fits within `maxWidth` once a
|
|
247
|
+
// trailing horizontal ellipsis has been appended. Used only for the
|
|
248
|
+
// final line of an overflowing wrapped label.
|
|
249
|
+
function truncateLineWithEllipsis(line, font, maxWidth) {
|
|
250
|
+
const ellipsis = '…';
|
|
251
|
+
if (measureTextWidth(line + ellipsis, font) <= maxWidth) return line + ellipsis;
|
|
252
|
+
let truncated = line;
|
|
253
|
+
while (truncated.length > 0 && measureTextWidth(truncated + ellipsis, font) > maxWidth) {
|
|
254
|
+
truncated = truncated.slice(0, -1);
|
|
255
|
+
}
|
|
256
|
+
return truncated + ellipsis;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Shape a node label: fit-to-content with a soft cap, wrap to at most
|
|
260
|
+
// NODE_LABEL_MAX_LINES, and truncate the last line with `…` only when
|
|
261
|
+
// the underlying text would otherwise need a third line.
|
|
262
|
+
//
|
|
263
|
+
// Returns `{ shaped, widthPx }`. `widthPx` is the natural width of the
|
|
264
|
+
// shaped label (the longer of the two line widths), which the renderer
|
|
265
|
+
// uses to seed ELK's per-graph spacing.
|
|
266
|
+
function shapeNodeLabel(rawLabel, font) {
|
|
267
|
+
const text = (rawLabel == null) ? '' : String(rawLabel);
|
|
268
|
+
if (text.length === 0) return { shaped: '', widthPx: 0 };
|
|
269
|
+
// Honour pre-existing newlines (some payload builders embed them on
|
|
270
|
+
// purpose, e.g. body titles): wrap each segment separately and
|
|
271
|
+
// concatenate.
|
|
272
|
+
const segments = text.split('\n');
|
|
273
|
+
const lines = [];
|
|
274
|
+
for (const segment of segments) {
|
|
275
|
+
const wrapped = wrapTextToWidth(segment, font, NODE_LABEL_SOFT_MAX_WIDTH_PX);
|
|
276
|
+
for (const line of wrapped) lines.push(line);
|
|
277
|
+
}
|
|
278
|
+
if (lines.length <= NODE_LABEL_MAX_LINES) {
|
|
279
|
+
const widthPx = lines.reduce(
|
|
280
|
+
(acc, line) => Math.max(acc, measureTextWidth(line, font)),
|
|
281
|
+
0
|
|
282
|
+
);
|
|
283
|
+
return { shaped: lines.join('\n'), widthPx };
|
|
284
|
+
}
|
|
285
|
+
const kept = lines.slice(0, NODE_LABEL_MAX_LINES - 1);
|
|
286
|
+
// Re-flow the leftover into the final line so the truncation
|
|
287
|
+
// happens at a natural boundary rather than mid-second-line.
|
|
288
|
+
const overflow = lines.slice(NODE_LABEL_MAX_LINES - 1).join(' ');
|
|
289
|
+
const finalLine = truncateLineWithEllipsis(
|
|
290
|
+
overflow, font, NODE_LABEL_SOFT_MAX_WIDTH_PX
|
|
291
|
+
);
|
|
292
|
+
kept.push(finalLine);
|
|
293
|
+
const widthPx = kept.reduce(
|
|
294
|
+
(acc, line) => Math.max(acc, measureTextWidth(line, font)),
|
|
295
|
+
0
|
|
296
|
+
);
|
|
297
|
+
return { shaped: kept.join('\n'), widthPx };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Walk the cytoscape elements list once, returning a NEW array in
|
|
301
|
+
// which every node has its `data.label` replaced by the shaped form.
|
|
302
|
+
// Edges and non-shape-relevant fields are passed through by reference
|
|
303
|
+
// to avoid an unnecessary clone — only the node objects whose labels
|
|
304
|
+
// we touched get shallow-copied (one level into `data`) so re-renders
|
|
305
|
+
// from the same source payload don't compound their shaping.
|
|
306
|
+
//
|
|
307
|
+
// Returns the metrics alongside the new elements so the renderer can
|
|
308
|
+
// thread them to the layout engine and the zoom backstop.
|
|
309
|
+
function shapeLabelsInElements(elements) {
|
|
310
|
+
// Cytoscape resolves node `font-size` against the element's computed
|
|
311
|
+
// style at render time; we shape against the renderer's static node
|
|
312
|
+
// font (defined in `buildStylesheet`). Kept in sync manually — bump
|
|
313
|
+
// both together.
|
|
314
|
+
const nodeFont = '500 9px "JetBrains Mono", ui-monospace, monospace';
|
|
315
|
+
const edgeFont = '500 8px "JetBrains Mono", ui-monospace, monospace';
|
|
316
|
+
let maxEdgeLabelPx = 0;
|
|
317
|
+
let maxNodeLabelPx = 0;
|
|
318
|
+
const reshaped = new Array(elements.length);
|
|
319
|
+
for (let i = 0; i < elements.length; i++) {
|
|
320
|
+
const element = elements[i];
|
|
321
|
+
if (!element || !element.data) {
|
|
322
|
+
reshaped[i] = element;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const isEdge = !!element.data.source && !!element.data.target;
|
|
326
|
+
if (isEdge) {
|
|
327
|
+
const edgeLabel = element.data.label;
|
|
328
|
+
const w = (typeof edgeLabel === 'string' && edgeLabel.length > 0)
|
|
329
|
+
? measureTextWidth(edgeLabel, edgeFont)
|
|
330
|
+
: 0;
|
|
331
|
+
if (w > maxEdgeLabelPx) maxEdgeLabelPx = w;
|
|
332
|
+
// Stamp the per-edge label width onto the element's data so the
|
|
333
|
+
// post-layout per-edge stretcher can size each edge individually
|
|
334
|
+
// (rather than inflating the whole graph by the longest label).
|
|
335
|
+
reshaped[i] = Object.assign({}, element, {
|
|
336
|
+
data: Object.assign({}, element.data, { _labelPx: w }),
|
|
337
|
+
});
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const { shaped, widthPx } = shapeNodeLabel(element.data.label, nodeFont);
|
|
341
|
+
if (widthPx > maxNodeLabelPx) maxNodeLabelPx = widthPx;
|
|
342
|
+
if (shaped === element.data.label) {
|
|
343
|
+
reshaped[i] = element;
|
|
344
|
+
} else {
|
|
345
|
+
reshaped[i] = Object.assign({}, element, {
|
|
346
|
+
data: Object.assign({}, element.data, { label: shaped }),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { elements: reshaped, maxNodeLabelPx, maxEdgeLabelPx };
|
|
351
|
+
}
|
|
352
|
+
|
|
151
353
|
// =============================================================================
|
|
152
354
|
// Layout configs per mode. Same ELK algorithm; spacing is tuned per mode
|
|
153
355
|
// to match the typical graph density and reading direction of each.
|
|
356
|
+
//
|
|
357
|
+
// We do NOT inflate `nodeNodeBetweenLayers` to fit the longest edge
|
|
358
|
+
// label — that punishes every short-labelled edge in the graph with
|
|
359
|
+
// pointless empty space. Instead, the renderer post-processes the
|
|
360
|
+
// laid-out positions in `_stretchLayersForEdgeLabels` to give each
|
|
361
|
+
// edge the horizontal room its own label needs, leaving short-labelled
|
|
362
|
+
// edges short. The per-mode defaults below are the floor; the
|
|
363
|
+
// stretcher only ever pushes nodes further apart, never closer.
|
|
154
364
|
// =============================================================================
|
|
155
365
|
|
|
156
366
|
function layoutForMode(mode) {
|
|
@@ -249,8 +459,13 @@ function buildStylesheet() {
|
|
|
249
459
|
'label': 'data(label)',
|
|
250
460
|
'text-valign': 'center',
|
|
251
461
|
'text-halign': 'center',
|
|
462
|
+
// Labels are pre-shaped in JS (see `shapeNodeLabel`): wrapped
|
|
463
|
+
// to a soft max width with a 2-line cap and trailing ellipsis
|
|
464
|
+
// when the text would otherwise need a third line. We honour
|
|
465
|
+
// the embedded newlines but never re-wrap, so `text-max-width`
|
|
466
|
+
// is intentionally absent — `width: 'label'` then makes each
|
|
467
|
+
// node fit its actual shaped text.
|
|
252
468
|
'text-wrap': 'wrap',
|
|
253
|
-
'text-max-width': '150px',
|
|
254
469
|
'line-height': 1.3,
|
|
255
470
|
'font-family': '"JetBrains Mono", ui-monospace, monospace',
|
|
256
471
|
'font-size': '9px',
|
|
@@ -306,7 +521,11 @@ function buildStylesheet() {
|
|
|
306
521
|
style: {
|
|
307
522
|
'label': 'data(label)',
|
|
308
523
|
'font-family': '"JetBrains Mono", ui-monospace, monospace',
|
|
309
|
-
|
|
524
|
+
// Slightly smaller than the node label font (9px) so edge
|
|
525
|
+
// labels read as secondary metadata rather than primary
|
|
526
|
+
// identity. Kept in sync with the `edgeFont` constant in
|
|
527
|
+
// `shapeLabelsInElements`.
|
|
528
|
+
'font-size': '8px',
|
|
310
529
|
'font-weight': '500',
|
|
311
530
|
'color': 'data(color)',
|
|
312
531
|
'text-background-color': edgeTextBg,
|
|
@@ -2088,6 +2307,16 @@ class CapFabRenderer {
|
|
|
2088
2307
|
// Rust `Machine::to_render_payload_json`).
|
|
2089
2308
|
this._machineBuilt = null;
|
|
2090
2309
|
|
|
2310
|
+
// True until the user first interacts with the graph (taps a
|
|
2311
|
+
// node/edge, drags, or zooms via wheel). While false, every
|
|
2312
|
+
// post-layout refit re-centers the entire graph in the viewport
|
|
2313
|
+
// without animation — so the very first paint, plus the 100/300ms
|
|
2314
|
+
// post-paint resize ticks (which catch container-size settling in
|
|
2315
|
+
// WebKit), all land at a stable centered fit. Flipped to true on
|
|
2316
|
+
// the first user gesture so subsequent refits respect selection,
|
|
2317
|
+
// path-mode focus, and animate normally.
|
|
2318
|
+
this._initialFitDone = false;
|
|
2319
|
+
|
|
2091
2320
|
// Theme observer.
|
|
2092
2321
|
this.themeObserver = new MutationObserver((mutations) => {
|
|
2093
2322
|
for (const mutation of mutations) {
|
|
@@ -2175,12 +2404,24 @@ class CapFabRenderer {
|
|
|
2175
2404
|
throw new Error('CapFabRenderer: container is missing');
|
|
2176
2405
|
}
|
|
2177
2406
|
|
|
2178
|
-
const
|
|
2179
|
-
if (
|
|
2407
|
+
const rawElements = this._buildCytoscapeElements();
|
|
2408
|
+
if (rawElements.length === 0) {
|
|
2180
2409
|
this.container.innerHTML = '<div class="cap-fab-empty"><p>No graph data</p></div>';
|
|
2181
2410
|
return this;
|
|
2182
2411
|
}
|
|
2183
2412
|
|
|
2413
|
+
// Shape every node label up-front (fit-to-content with a 2-line
|
|
2414
|
+
// soft cap and trailing ellipsis past that) and measure the
|
|
2415
|
+
// longest edge label in pixels. The latter feeds into ELK's
|
|
2416
|
+
// between-layer spacing so edges are always long enough for their
|
|
2417
|
+
// labels. Both the per-node label width and the per-graph edge
|
|
2418
|
+
// label width are recorded on the renderer so the zoom-backstop
|
|
2419
|
+
// logic can reason about them after layout-stop.
|
|
2420
|
+
const labelMetrics = shapeLabelsInElements(rawElements);
|
|
2421
|
+
const elements = labelMetrics.elements;
|
|
2422
|
+
this._labelMaxNodeWidthPx = labelMetrics.maxNodeLabelPx;
|
|
2423
|
+
this._labelMaxEdgeWidthPx = labelMetrics.maxEdgeLabelPx;
|
|
2424
|
+
|
|
2184
2425
|
// Clear container and size it to the window.
|
|
2185
2426
|
this.container.innerHTML = '';
|
|
2186
2427
|
this.container.style.width = window.innerWidth + 'px';
|
|
@@ -2203,18 +2444,63 @@ class CapFabRenderer {
|
|
|
2203
2444
|
stop: function () {
|
|
2204
2445
|
self.cy.resize();
|
|
2205
2446
|
self._layoutReady = true;
|
|
2206
|
-
|
|
2447
|
+
// Per-edge stretch FIRST (mutates node x-coordinates so
|
|
2448
|
+
// each edge has the horizontal room its own label needs),
|
|
2449
|
+
// THEN lock the cytoscape wheel-zoom limits to the
|
|
2450
|
+
// dynamic per-graph values, THEN fit the (possibly
|
|
2451
|
+
// stretched) bounding box to the viewport so the user
|
|
2452
|
+
// always opens at a fully-visible padded fit. Order
|
|
2453
|
+
// matters: `_recomputeZoomLimits` needs the final
|
|
2454
|
+
// post-stretch bounding box; `fitToVisibleViewport` needs
|
|
2455
|
+
// the relaxed minZoom (it sits above the tight-fit zoom)
|
|
2456
|
+
// so its padding actually shows.
|
|
2457
|
+
self._stretchLayersForEdgeLabels();
|
|
2458
|
+
self._recomputeZoomLimits();
|
|
2459
|
+
const hadPendingFocus = !!self._pendingFocusCap;
|
|
2460
|
+
if (hadPendingFocus) {
|
|
2207
2461
|
const pending = self._pendingFocusCap;
|
|
2208
2462
|
self._pendingFocusCap = null;
|
|
2209
2463
|
self.highlightCapability(pending);
|
|
2210
2464
|
}
|
|
2211
|
-
|
|
2465
|
+
// First paint with no preselected focus: snap (no
|
|
2466
|
+
// animation) to a centered fit of the full, post-stretch
|
|
2467
|
+
// graph. Going through `_centerOnGraphInitial` rather
|
|
2468
|
+
// than `refitCurrentSelection` guarantees three things
|
|
2469
|
+
// on the bootstrap pass that the selection-aware refit
|
|
2470
|
+
// can't:
|
|
2471
|
+
// - never animate, so the user never sees a transient
|
|
2472
|
+
// un-centered state on the way to the final fit;
|
|
2473
|
+
// - always operate on the entire element set, so
|
|
2474
|
+
// per-edge stretching can't push the centered focus
|
|
2475
|
+
// off-viewport;
|
|
2476
|
+
// - reapply on every post-paint resize tick (see
|
|
2477
|
+
// `resizeAndRefit` below) until the user first
|
|
2478
|
+
// interacts, absorbing late WebKit container-size
|
|
2479
|
+
// settling without surprising a user who's already
|
|
2480
|
+
// scrolled or zoomed.
|
|
2481
|
+
//
|
|
2482
|
+
// If a focus cap WAS pending (browse-mode deep link from
|
|
2483
|
+
// capdag-dot-com), defer to the selection-aware refit so
|
|
2484
|
+
// the linked element lands centered instead.
|
|
2485
|
+
if (hadPendingFocus) {
|
|
2486
|
+
self.refitCurrentSelection();
|
|
2487
|
+
// Treat the deep-link landing as the user's chosen
|
|
2488
|
+
// viewport — late resize ticks shouldn't yank them
|
|
2489
|
+
// back to a fit-of-all.
|
|
2490
|
+
self._markInitialFitDone();
|
|
2491
|
+
} else {
|
|
2492
|
+
self._centerOnGraphInitial();
|
|
2493
|
+
}
|
|
2212
2494
|
},
|
|
2213
2495
|
}
|
|
2214
2496
|
),
|
|
2215
2497
|
style: buildStylesheet(),
|
|
2216
|
-
|
|
2217
|
-
|
|
2498
|
+
// Initial loose limits — `_recomputeZoomLimits()` tightens them
|
|
2499
|
+
// on every layout-stop and resize. We keep an absolute safety
|
|
2500
|
+
// floor/ceiling so a degenerate graph (zero bbox) can't make us
|
|
2501
|
+
// pass NaN/Infinity to cytoscape.
|
|
2502
|
+
minZoom: 0.01,
|
|
2503
|
+
maxZoom: 100,
|
|
2218
2504
|
wheelSensitivity: 0.3,
|
|
2219
2505
|
boxSelectionEnabled: false,
|
|
2220
2506
|
autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
|
|
@@ -2223,17 +2509,343 @@ class CapFabRenderer {
|
|
|
2223
2509
|
const resizeAndRefit = () => {
|
|
2224
2510
|
if (!this.cy) return;
|
|
2225
2511
|
this.cy.resize();
|
|
2226
|
-
|
|
2512
|
+
// Container size may have changed — recompute the zoom limits
|
|
2513
|
+
// so "fit the entire graph" still corresponds to actual pixel
|
|
2514
|
+
// capacity, not the size we had at first paint.
|
|
2515
|
+
this._recomputeZoomLimits();
|
|
2516
|
+
// Until the user first interacts with the graph we keep
|
|
2517
|
+
// re-centering on every resize tick. WebKit's container
|
|
2518
|
+
// dimensions can lag the layout-stop callback by a frame or
|
|
2519
|
+
// two (especially in the editor split view, where the graph
|
|
2520
|
+
// pane width depends on a CSS grid that's still resolving),
|
|
2521
|
+
// and the `setTimeout(_, 100/300)` ticks below are how we
|
|
2522
|
+
// catch those late settles without an animation. Once the
|
|
2523
|
+
// user has tapped, dragged, or zoomed we explicitly do NOT
|
|
2524
|
+
// touch their viewport on these late ticks — the `cy.resize()`
|
|
2525
|
+
// and `_recomputeZoomLimits` calls above are enough to keep
|
|
2526
|
+
// the engine internally consistent with the new container
|
|
2527
|
+
// size; yanking them back to a fit would be a hostile
|
|
2528
|
+
// surprise.
|
|
2529
|
+
if (!this._initialFitDone) {
|
|
2530
|
+
this._centerOnGraphInitial();
|
|
2531
|
+
}
|
|
2227
2532
|
};
|
|
2228
2533
|
this.cy.on('ready', resizeAndRefit);
|
|
2534
|
+
this.cy.on('resize', () => this._recomputeZoomLimits());
|
|
2229
2535
|
requestAnimationFrame(resizeAndRefit);
|
|
2230
2536
|
setTimeout(resizeAndRefit, 100);
|
|
2231
2537
|
setTimeout(resizeAndRefit, 300);
|
|
2232
2538
|
|
|
2233
2539
|
this._setupEventHandlers();
|
|
2540
|
+
this._installZoomBackstop();
|
|
2234
2541
|
return this;
|
|
2235
2542
|
}
|
|
2236
2543
|
|
|
2544
|
+
// ===========================================================================
|
|
2545
|
+
// Zoom backstop — dynamic per-graph minimum and maximum zoom levels.
|
|
2546
|
+
//
|
|
2547
|
+
// Minimum zoom = the zoom at which the entire graph's bounding box just
|
|
2548
|
+
// fits inside the container. Below that the user is asking for more
|
|
2549
|
+
// blank canvas, which is what the parent scroll view should be doing.
|
|
2550
|
+
// Maximum zoom = the zoom at which a representative ("default") node
|
|
2551
|
+
// would occupy more than a quarter of the *bigger* viewport dimension.
|
|
2552
|
+
// Past that the user is zoomed in past the point where any single
|
|
2553
|
+
// node still fits visually, so further wheel events are forwarded
|
|
2554
|
+
// to the parent responder instead of continuing to zoom.
|
|
2555
|
+
//
|
|
2556
|
+
// The wheel listener reports `{ atLimit, zoomingOut }` to
|
|
2557
|
+
// `interaction.onZoomLimit` on every wheel event so the host can
|
|
2558
|
+
// latch the direction and forward subsequent wheel events up the
|
|
2559
|
+
// responder chain (see `ScrollPassthroughWebView` on the Swift side).
|
|
2560
|
+
// ===========================================================================
|
|
2561
|
+
|
|
2562
|
+
// Slack below the strict "graph fits the viewport" zoom that the
|
|
2563
|
+
// backstop allows. With the strict zoom (1.0× of fit) the graph
|
|
2564
|
+
// touches all four viewport edges; the user expects a little visual
|
|
2565
|
+
// padding at the zoomed-out limit, so we let cytoscape zoom out a
|
|
2566
|
+
// further `1 - ZOOM_OUT_FIT_SLACK` of fit before forwarding the
|
|
2567
|
+
// wheel to the parent responder. Picked to match the padding
|
|
2568
|
+
// `fitToVisibleViewport(undefined, 50)` produces on a typical
|
|
2569
|
+
// viewport (≈15% slack at 800×600).
|
|
2570
|
+
static get ZOOM_OUT_FIT_SLACK() { return 0.15; }
|
|
2571
|
+
|
|
2572
|
+
// Bootstrap fit: snap (no animation) to a centered, padded fit of
|
|
2573
|
+
// the entire graph. Used during the first paint and the post-paint
|
|
2574
|
+
// resize ticks while `_initialFitDone` is false. The padding here
|
|
2575
|
+
// matches `fitToVisibleViewport(undefined, 50)` so the visual
|
|
2576
|
+
// result is identical to the steady-state refit, just without the
|
|
2577
|
+
// animation and without the selection-aware branching.
|
|
2578
|
+
//
|
|
2579
|
+
// Math: ELK lays the graph out at an arbitrary pan; we override
|
|
2580
|
+
// both zoom and pan in one synchronous pair so the user never sees
|
|
2581
|
+
// an intermediate state. We deliberately read the bbox AFTER
|
|
2582
|
+
// `_stretchLayersForEdgeLabels` has run (the caller's
|
|
2583
|
+
// responsibility) so the centering accounts for the per-edge
|
|
2584
|
+
// stretch — without that, a graph whose layers shifted right would
|
|
2585
|
+
// appear hugging the right edge of the viewport.
|
|
2586
|
+
_centerOnGraphInitial() {
|
|
2587
|
+
if (!this.cy) return;
|
|
2588
|
+
const cy = this.cy;
|
|
2589
|
+
const containerWidth = cy.width();
|
|
2590
|
+
const containerHeight = cy.height();
|
|
2591
|
+
if (containerWidth <= 0 || containerHeight <= 0) return;
|
|
2592
|
+
const elements = cy.elements();
|
|
2593
|
+
if (elements.length === 0) return;
|
|
2594
|
+
const bb = elements.boundingBox();
|
|
2595
|
+
if (bb.w === 0 && bb.h === 0) return;
|
|
2596
|
+
|
|
2597
|
+
const padding = 50;
|
|
2598
|
+
const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
|
|
2599
|
+
const visibleWidth = containerWidth - padding * 2;
|
|
2600
|
+
const visibleHeight = containerHeight - excluded - padding * 2;
|
|
2601
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) return;
|
|
2602
|
+
|
|
2603
|
+
const fitZoom = Math.min(visibleWidth / bb.w, visibleHeight / bb.h);
|
|
2604
|
+
// Clamp to the dynamic per-graph limits set by
|
|
2605
|
+
// `_recomputeZoomLimits` so the centered fit can't exceed them
|
|
2606
|
+
// (the relaxed minZoom is `strictFit * (1 - ZOOM_OUT_FIT_SLACK)`,
|
|
2607
|
+
// which sits a hair below `fitZoom` for typical viewports — so
|
|
2608
|
+
// the clamp is usually a no-op, but we still apply it for
|
|
2609
|
+
// tiny-viewport degenerate cases).
|
|
2610
|
+
const clampedZoom = Math.min(Math.max(fitZoom, cy.minZoom()), cy.maxZoom());
|
|
2611
|
+
|
|
2612
|
+
const modelCenterX = (bb.x1 + bb.x2) / 2;
|
|
2613
|
+
const modelCenterY = (bb.y1 + bb.y2) / 2;
|
|
2614
|
+
const screenCenterX = containerWidth / 2;
|
|
2615
|
+
const screenCenterY = (containerHeight - excluded) / 2;
|
|
2616
|
+
const panX = screenCenterX - modelCenterX * clampedZoom;
|
|
2617
|
+
const panY = screenCenterY - modelCenterY * clampedZoom;
|
|
2618
|
+
|
|
2619
|
+
// Stop any in-flight animation before snapping — otherwise a
|
|
2620
|
+
// late-arriving `cy.animate` from an earlier path could fight
|
|
2621
|
+
// our zoom/pan write and leave the graph drifting.
|
|
2622
|
+
cy.stop(true);
|
|
2623
|
+
this._internalPanZoom = true;
|
|
2624
|
+
try {
|
|
2625
|
+
cy.zoom(clampedZoom);
|
|
2626
|
+
cy.pan({ x: panX, y: panY });
|
|
2627
|
+
} finally {
|
|
2628
|
+
this._internalPanZoom = false;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// Mark the bootstrap centering as complete so subsequent refits
|
|
2633
|
+
// respect selection state, path-mode focus, and animation.
|
|
2634
|
+
// Idempotent — safe to call from every interaction handler.
|
|
2635
|
+
_markInitialFitDone() {
|
|
2636
|
+
if (this._initialFitDone) return;
|
|
2637
|
+
this._initialFitDone = true;
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
_recomputeZoomLimits() {
|
|
2641
|
+
if (!this.cy) return;
|
|
2642
|
+
const w = this.cy.width();
|
|
2643
|
+
const h = this.cy.height();
|
|
2644
|
+
if (w <= 0 || h <= 0) return;
|
|
2645
|
+
// A "default node" is what a typical (single-line, average-width)
|
|
2646
|
+
// node looks like in this graph. We use the longest shaped node
|
|
2647
|
+
// label width measured during element construction, falling back
|
|
2648
|
+
// to a sensible constant when the graph has no labelled nodes
|
|
2649
|
+
// (browse-mode title bars, blank slates, etc.).
|
|
2650
|
+
const fallbackNodeWidthPx = 120;
|
|
2651
|
+
const defaultNodePx = Math.max(
|
|
2652
|
+
fallbackNodeWidthPx,
|
|
2653
|
+
(this._labelMaxNodeWidthPx || 0) + /* node padding × 2 */ 24
|
|
2654
|
+
);
|
|
2655
|
+
// Maximum zoom: one default node fills > 1/4 of the bigger
|
|
2656
|
+
// viewport dimension. Solving for the boundary:
|
|
2657
|
+
// defaultNodePx * zoomMax = max(w, h) / 4
|
|
2658
|
+
const biggerDim = Math.max(w, h);
|
|
2659
|
+
const zoomMax = (biggerDim / 4) / defaultNodePx;
|
|
2660
|
+
// Minimum zoom: the entire graph's bounding box fits, with a
|
|
2661
|
+
// small slack so the user can pull back a bit further for visual
|
|
2662
|
+
// padding before the parent-scroll forwarding kicks in. The
|
|
2663
|
+
// strict-fit zoom is `min(w/bb.w, h/bb.h)`; we multiply by
|
|
2664
|
+
// `(1 - ZOOM_OUT_FIT_SLACK)` to relax it. The initial
|
|
2665
|
+
// `fitToVisibleViewport(undefined, 50)` lands at a padded zoom
|
|
2666
|
+
// that sits comfortably above this relaxed minimum, so opening
|
|
2667
|
+
// the view shows the graph centred with margin rather than
|
|
2668
|
+
// bleeding to all four edges.
|
|
2669
|
+
const bb = this.cy.elements().boundingBox();
|
|
2670
|
+
let zoomMin;
|
|
2671
|
+
if (bb.w > 0 && bb.h > 0) {
|
|
2672
|
+
const strictFit = Math.min(w / bb.w, h / bb.h);
|
|
2673
|
+
zoomMin = strictFit * (1 - CapFabRenderer.ZOOM_OUT_FIT_SLACK);
|
|
2674
|
+
} else {
|
|
2675
|
+
// Empty / degenerate graph — leave the min loose; there's
|
|
2676
|
+
// nothing to fit.
|
|
2677
|
+
zoomMin = 0.05;
|
|
2678
|
+
}
|
|
2679
|
+
// Order-preserving guard: a graph small enough to fit the
|
|
2680
|
+
// viewport at any zoom would otherwise produce zoomMin > zoomMax.
|
|
2681
|
+
// Pin them together so cytoscape's internal `setZoom` clamp can't
|
|
2682
|
+
// throw or oscillate.
|
|
2683
|
+
if (zoomMin > zoomMax) zoomMin = zoomMax;
|
|
2684
|
+
// Avoid infinitesimal / non-finite values reaching cytoscape.
|
|
2685
|
+
if (!Number.isFinite(zoomMin) || zoomMin <= 0) zoomMin = 0.01;
|
|
2686
|
+
if (!Number.isFinite(zoomMax) || zoomMax <= 0) zoomMax = 100;
|
|
2687
|
+
this._dynamicMinZoom = zoomMin;
|
|
2688
|
+
this._dynamicMaxZoom = zoomMax;
|
|
2689
|
+
this.cy.minZoom(zoomMin);
|
|
2690
|
+
this.cy.maxZoom(zoomMax);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// ===========================================================================
|
|
2694
|
+
// Per-edge layer stretching.
|
|
2695
|
+
//
|
|
2696
|
+
// ELK's layered algorithm assigns each node to a discrete layer; every
|
|
2697
|
+
// edge between layers L and L+1 gets the same horizontal length (the
|
|
2698
|
+
// `nodeNodeBetweenLayers` spacing). That means we can size the gap per
|
|
2699
|
+
// pair of consecutive layers, but not per individual edge. We do that
|
|
2700
|
+
// here, after the layout has run: the source-side x-coordinate of
|
|
2701
|
+
// each layer's nodes is shifted right just enough that every incoming
|
|
2702
|
+
// edge has room for its own label, plus a small padding allowance.
|
|
2703
|
+
// Edges with short labels keep the engine's tight default spacing;
|
|
2704
|
+
// edges with long labels push their target layer (and every layer
|
|
2705
|
+
// downstream) further to the right.
|
|
2706
|
+
//
|
|
2707
|
+
// Algorithm:
|
|
2708
|
+
// 1. Snap nodes into layer buckets by their post-layout x.
|
|
2709
|
+
// 2. Walk the buckets left-to-right.
|
|
2710
|
+
// 3. For each edge whose source is in the previous layer and target
|
|
2711
|
+
// in the current one, compute the minimum target-x that would
|
|
2712
|
+
// give the edge label clearance:
|
|
2713
|
+
// srcEdge.x + sourceWidth/2 + targetWidth/2 + labelPx + pad
|
|
2714
|
+
// Take the max across the layer's incoming edges.
|
|
2715
|
+
// 4. If that max exceeds the layer's current x, shift every node in
|
|
2716
|
+
// the layer (and only that layer) right by the difference.
|
|
2717
|
+
//
|
|
2718
|
+
// Cross-layer edges (skipping a layer) are accounted for by the same
|
|
2719
|
+
// walk — the per-layer max is computed over every edge that lands in
|
|
2720
|
+
// the layer, regardless of how many layers it skipped.
|
|
2721
|
+
// ===========================================================================
|
|
2722
|
+
|
|
2723
|
+
_stretchLayersForEdgeLabels() {
|
|
2724
|
+
if (!this.cy) return;
|
|
2725
|
+
const cy = this.cy;
|
|
2726
|
+
const nodes = cy.nodes();
|
|
2727
|
+
if (nodes.length === 0) return;
|
|
2728
|
+
|
|
2729
|
+
// Padding around an edge label so its background and the source/
|
|
2730
|
+
// target node's right/left edges don't touch. Sized to the
|
|
2731
|
+
// stylesheet's `text-background-padding: 4px` plus arrow-head
|
|
2732
|
+
// clearance and a few pixels of breathing room.
|
|
2733
|
+
const LABEL_GAP_PX = 24;
|
|
2734
|
+
// Layer-bucketing tolerance. Nodes within `EPS` of each other on x
|
|
2735
|
+
// are treated as one layer. ELK places nodes within a layer at the
|
|
2736
|
+
// same x within rounding error.
|
|
2737
|
+
const EPS = 1.0;
|
|
2738
|
+
|
|
2739
|
+
// Group nodes into ordered layer buckets by current x.
|
|
2740
|
+
const sortedNodes = nodes.toArray().slice().sort(
|
|
2741
|
+
(a, b) => a.position('x') - b.position('x')
|
|
2742
|
+
);
|
|
2743
|
+
const layers = [];
|
|
2744
|
+
for (const node of sortedNodes) {
|
|
2745
|
+
const x = node.position('x');
|
|
2746
|
+
const last = layers.length > 0 ? layers[layers.length - 1] : null;
|
|
2747
|
+
if (last !== null && Math.abs(last.x - x) <= EPS) {
|
|
2748
|
+
last.nodes.push(node);
|
|
2749
|
+
} else {
|
|
2750
|
+
layers.push({ x, nodes: [node] });
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
if (layers.length < 2) return; // Nothing to stretch.
|
|
2754
|
+
|
|
2755
|
+
// Build a quick layer-index lookup so we can ask "what layer is
|
|
2756
|
+
// this node in?" in O(1) when iterating edges.
|
|
2757
|
+
const layerIndexById = new Map();
|
|
2758
|
+
for (let li = 0; li < layers.length; li++) {
|
|
2759
|
+
for (const n of layers[li].nodes) layerIndexById.set(n.id(), li);
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// Pre-bucket edges by their target layer so the per-layer pass
|
|
2763
|
+
// below is O(layer-edges) rather than O(all-edges) per layer.
|
|
2764
|
+
const incomingByTarget = layers.map(() => []);
|
|
2765
|
+
cy.edges().forEach((edge) => {
|
|
2766
|
+
const srcLayer = layerIndexById.get(edge.source().id());
|
|
2767
|
+
const tgtLayer = layerIndexById.get(edge.target().id());
|
|
2768
|
+
if (srcLayer === undefined || tgtLayer === undefined) return;
|
|
2769
|
+
if (tgtLayer <= srcLayer) return; // Back-edge or self-loop.
|
|
2770
|
+
incomingByTarget[tgtLayer].push(edge);
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
// Walk layers left-to-right. For each layer, compute the minimum
|
|
2774
|
+
// x its nodes need based on the CURRENT positions of source-layer
|
|
2775
|
+
// nodes (which already include any shift from earlier iterations).
|
|
2776
|
+
// Only ever shift right — never compress. This way a long-labelled
|
|
2777
|
+
// edge between layers k and k+1 stretches only the gap between
|
|
2778
|
+
// those two layers, while every later layer shifts right by the
|
|
2779
|
+
// same amount as a side-effect (so layer k+2 sits at its own
|
|
2780
|
+
// tight default distance from k+1, never inflating the whole
|
|
2781
|
+
// graph by the longest single label).
|
|
2782
|
+
for (let li = 1; li < layers.length; li++) {
|
|
2783
|
+
let minLayerX = layers[li].x;
|
|
2784
|
+
for (const edge of incomingByTarget[li]) {
|
|
2785
|
+
const labelPx = (typeof edge.data('_labelPx') === 'number')
|
|
2786
|
+
? edge.data('_labelPx')
|
|
2787
|
+
: 0;
|
|
2788
|
+
const srcNode = edge.source();
|
|
2789
|
+
const tgtNode = edge.target();
|
|
2790
|
+
// `outerWidth` includes the node's border so the gap sits
|
|
2791
|
+
// outside the visible node, not under it.
|
|
2792
|
+
const srcHalf = srcNode.outerWidth() / 2;
|
|
2793
|
+
const tgtHalf = tgtNode.outerWidth() / 2;
|
|
2794
|
+
const srcRightEdge = srcNode.position('x') + srcHalf;
|
|
2795
|
+
const candidate = srcRightEdge + LABEL_GAP_PX + labelPx + LABEL_GAP_PX + tgtHalf;
|
|
2796
|
+
if (candidate > minLayerX) minLayerX = candidate;
|
|
2797
|
+
}
|
|
2798
|
+
if (minLayerX > layers[li].x) {
|
|
2799
|
+
const dx = minLayerX - layers[li].x;
|
|
2800
|
+
for (const node of layers[li].nodes) {
|
|
2801
|
+
const p = node.position();
|
|
2802
|
+
node.position({ x: p.x + dx, y: p.y });
|
|
2803
|
+
}
|
|
2804
|
+
layers[li].x += dx;
|
|
2805
|
+
// Cascade: every later layer must shift right by at least the
|
|
2806
|
+
// same dx so the relative spacing produced by ELK between them
|
|
2807
|
+
// is preserved. The per-layer recomputation above will only
|
|
2808
|
+
// *add* to that, never subtract.
|
|
2809
|
+
for (let lj = li + 1; lj < layers.length; lj++) {
|
|
2810
|
+
for (const node of layers[lj].nodes) {
|
|
2811
|
+
const p = node.position();
|
|
2812
|
+
node.position({ x: p.x + dx, y: p.y });
|
|
2813
|
+
}
|
|
2814
|
+
layers[lj].x += dx;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
_installZoomBackstop() {
|
|
2821
|
+
if (!this.container) return;
|
|
2822
|
+
if (this._zoomBackstopInstalled) return;
|
|
2823
|
+
this._zoomBackstopInstalled = true;
|
|
2824
|
+
const self = this;
|
|
2825
|
+
// `passive: true` because we never call preventDefault — we only
|
|
2826
|
+
// observe and report. Cytoscape's own wheel listener (registered
|
|
2827
|
+
// separately on the same container) does the actual zoom and
|
|
2828
|
+
// honours the dynamic min/max we set on `cy`.
|
|
2829
|
+
this.container.addEventListener('wheel', function (evt) {
|
|
2830
|
+
if (!self.cy) return;
|
|
2831
|
+
const min = self._dynamicMinZoom;
|
|
2832
|
+
const max = self._dynamicMaxZoom;
|
|
2833
|
+
if (typeof min !== 'number' || typeof max !== 'number') return;
|
|
2834
|
+
const currentZoom = self.cy.zoom();
|
|
2835
|
+
// Tolerances to absorb the tiny rounding cytoscape's own
|
|
2836
|
+
// clamping introduces — without them we never report
|
|
2837
|
+
// at-limit because the actual zoom sits a few ulps inside.
|
|
2838
|
+
const atMin = currentZoom <= min * 1.01;
|
|
2839
|
+
const atMax = currentZoom >= max * 0.99;
|
|
2840
|
+
const zoomingOut = evt.deltaY > 0;
|
|
2841
|
+
const zoomingIn = evt.deltaY < 0;
|
|
2842
|
+
const atLimit = (zoomingOut && atMin) || (zoomingIn && atMax);
|
|
2843
|
+
if (typeof self.interaction.onZoomLimit === 'function') {
|
|
2844
|
+
self.interaction.onZoomLimit({ atLimit, zoomingOut });
|
|
2845
|
+
}
|
|
2846
|
+
}, { passive: true });
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2237
2849
|
_buildCytoscapeElements() {
|
|
2238
2850
|
if (this.mode === 'browse') {
|
|
2239
2851
|
return browseCytoscapeElements({
|
|
@@ -2271,6 +2883,21 @@ class CapFabRenderer {
|
|
|
2271
2883
|
_setupEventHandlers() {
|
|
2272
2884
|
const self = this;
|
|
2273
2885
|
|
|
2886
|
+
// First user interaction — tap, zoom, drag — flips
|
|
2887
|
+
// `_initialFitDone` so subsequent post-paint resize ticks stop
|
|
2888
|
+
// re-centering and let the user keep their viewport. Hooked here
|
|
2889
|
+
// (cytoscape-side) for taps and zoom; the wheel-based zoom path
|
|
2890
|
+
// also flips it from `_installZoomBackstop`.
|
|
2891
|
+
//
|
|
2892
|
+
// The zoom/pan listener checks `_internalPanZoom` so that
|
|
2893
|
+
// renderer-initiated centering (`_centerOnGraphInitial`,
|
|
2894
|
+
// `fitToVisibleViewport`) doesn't trip the flag — only genuine
|
|
2895
|
+
// user-initiated viewport changes do.
|
|
2896
|
+
this.cy.on('tap', function () { self._markInitialFitDone(); });
|
|
2897
|
+
this.cy.on('zoom pan', function () {
|
|
2898
|
+
if (!self._internalPanZoom) self._markInitialFitDone();
|
|
2899
|
+
});
|
|
2900
|
+
|
|
2274
2901
|
this.cy.on('tap', 'node', function (evt) {
|
|
2275
2902
|
evt.stopPropagation();
|
|
2276
2903
|
self._handleNodeTap(evt.target);
|
|
@@ -2765,16 +3392,27 @@ class CapFabRenderer {
|
|
|
2765
3392
|
const panX = screenCenterX - modelCenterX * clampedZoom;
|
|
2766
3393
|
const panY = screenCenterY - modelCenterY * clampedZoom;
|
|
2767
3394
|
|
|
3395
|
+
// Same `_internalPanZoom` guard as `_centerOnGraphInitial` — a
|
|
3396
|
+
// renderer-driven fit (highlight, dbltap, refit-after-resize)
|
|
3397
|
+
// must not be mistaken for a user gesture, or the bootstrap
|
|
3398
|
+
// re-centering ticks below would stop firing prematurely.
|
|
3399
|
+
this._internalPanZoom = true;
|
|
2768
3400
|
if (animate) {
|
|
2769
3401
|
this.cy.animate({
|
|
2770
3402
|
zoom: clampedZoom,
|
|
2771
3403
|
pan: { x: panX, y: panY },
|
|
2772
3404
|
duration: 400,
|
|
2773
3405
|
easing: 'ease-out-cubic',
|
|
3406
|
+
complete: () => { this._internalPanZoom = false; },
|
|
3407
|
+
stop: () => { this._internalPanZoom = false; },
|
|
2774
3408
|
});
|
|
2775
3409
|
} else {
|
|
2776
|
-
|
|
2777
|
-
|
|
3410
|
+
try {
|
|
3411
|
+
this.cy.zoom(clampedZoom);
|
|
3412
|
+
this.cy.pan({ x: panX, y: panY });
|
|
3413
|
+
} finally {
|
|
3414
|
+
this._internalPanZoom = false;
|
|
3415
|
+
}
|
|
2778
3416
|
}
|
|
2779
3417
|
}
|
|
2780
3418
|
|
package/capdag.js
CHANGED
|
@@ -1086,16 +1086,22 @@ const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record;textable';
|
|
|
1086
1086
|
const MEDIA_PATH_OUTPUT = 'media:model-path;record;textable';
|
|
1087
1087
|
// Media URN for embedding vector output - has record marker
|
|
1088
1088
|
const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record;textable';
|
|
1089
|
-
// Media URN for
|
|
1090
|
-
|
|
1091
|
-
//
|
|
1092
|
-
|
|
1089
|
+
// Media URN for vision inference output — a concrete textable terminal.
|
|
1090
|
+
// Carries `image-description` (the vision-specific marker), `plain-text` (the
|
|
1091
|
+
// finalised-text marker that opts into cap:save-as-txt's persistence path),
|
|
1092
|
+
// and `file-type=txt` (binds the URN to the `.txt` extension).
|
|
1093
|
+
const MEDIA_IMAGE_DESCRIPTION = 'media:image-description;plain-text;textable;txt';
|
|
1094
|
+
// Media URN for finalised plain text — the canonical input/output of cap:save-as-txt.
|
|
1095
|
+
// Producers of user-facing prose (LLM text-generation, OCR's extracted text,
|
|
1096
|
+
// summarisation) declare this URN as their `out` so the planner restricts the .txt
|
|
1097
|
+
// persistence path to those caps. See fabric/media/plain-text.toml.
|
|
1098
|
+
const MEDIA_PLAIN_TEXT = 'media:plain-text;textable;txt';
|
|
1093
1099
|
// Media URN for transcription output - has record marker
|
|
1094
1100
|
const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;textable;transcription';
|
|
1095
1101
|
// Media URN for decision output - JSON record with textable
|
|
1096
1102
|
const MEDIA_DECISION = 'media:decision;json;record;textable';
|
|
1097
1103
|
// Media URN for textable page output
|
|
1098
|
-
const MEDIA_TEXTABLE_PAGE = 'media:textable;
|
|
1104
|
+
const MEDIA_TEXTABLE_PAGE = 'media:page;plain-text;textable;txt';
|
|
1099
1105
|
// Media URN for Hugging Face API token (secret, textable)
|
|
1100
1106
|
const MEDIA_HF_TOKEN = 'media:hf-token;secret;textable';
|
|
1101
1107
|
// Media URN for a list of model architectures — JSON record
|
|
@@ -6192,8 +6198,8 @@ module.exports = {
|
|
|
6192
6198
|
MEDIA_PATH_OUTPUT,
|
|
6193
6199
|
// Semantic output types - inference
|
|
6194
6200
|
MEDIA_EMBEDDING_VECTOR,
|
|
6195
|
-
MEDIA_LLM_INFERENCE_OUTPUT,
|
|
6196
6201
|
MEDIA_IMAGE_DESCRIPTION,
|
|
6202
|
+
MEDIA_PLAIN_TEXT,
|
|
6197
6203
|
MEDIA_TRANSCRIPTION_OUTPUT,
|
|
6198
6204
|
// File path type — single URN; cardinality lives on is_sequence.
|
|
6199
6205
|
MEDIA_FILE_PATH,
|
package/capdag.test.js
CHANGED
|
@@ -22,7 +22,7 @@ const {
|
|
|
22
22
|
MEDIA_PDF, MEDIA_EPUB, MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG,
|
|
23
23
|
MEDIA_HTML, MEDIA_XML, MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA,
|
|
24
24
|
MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
|
|
25
|
-
|
|
25
|
+
MEDIA_PLAIN_TEXT,
|
|
26
26
|
MEDIA_FILE_PATH,
|
|
27
27
|
MEDIA_COLLECTION, MEDIA_COLLECTION_LIST,
|
|
28
28
|
MEDIA_DECISION,
|
|
@@ -958,7 +958,7 @@ function test072_constantsParse() {
|
|
|
958
958
|
MEDIA_MD, MEDIA_TXT, MEDIA_RST, MEDIA_LOG, MEDIA_HTML, MEDIA_XML,
|
|
959
959
|
MEDIA_JSON, MEDIA_YAML, MEDIA_JSON_SCHEMA, MEDIA_AUDIO, MEDIA_VIDEO,
|
|
960
960
|
MEDIA_MODEL_SPEC, MEDIA_AVAILABILITY_OUTPUT, MEDIA_PATH_OUTPUT,
|
|
961
|
-
|
|
961
|
+
MEDIA_PLAIN_TEXT
|
|
962
962
|
];
|
|
963
963
|
for (const constant of constants) {
|
|
964
964
|
const parsed = MediaUrn.fromString(constant);
|
package/package.json
CHANGED