@stocksharp/diagram 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/canvas-renderer.js +113 -38
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/color.js +109 -0
- package/dist/esm/color.js.map +1 -0
- package/dist/esm/diagram/context-menu.js +1 -1
- package/dist/esm/diagram/context-menu.js.map +1 -1
- package/dist/esm/diagram/stocksharp-diagram.js +10 -0
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/embed.js +1 -51
- package/dist/esm/embed.js.map +1 -1
- package/dist/ssdiagram-headless.js +145 -29
- package/dist/ssdiagram-headless.js.map +4 -4
- package/dist/ssdiagram.js +163 -67
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/canvas-renderer.d.ts +5 -0
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/color.d.ts +12 -0
- package/dist/types/color.d.ts.map +1 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts +8 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/embed.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/canvas-renderer.ts +111 -35
- package/src/color.ts +125 -0
- package/src/diagram/context-menu.ts +1 -1
- package/src/diagram/stocksharp-diagram.ts +11 -0
- package/src/embed.ts +1 -56
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SvgSurface } from './svg-surface.js';
|
|
2
|
+
import { readableTextOn } from './color.js';
|
|
2
3
|
// Internal dependency-free canvas renderer used by StockSharpDiagram.
|
|
3
4
|
//
|
|
4
5
|
// Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
|
|
@@ -49,6 +50,12 @@ function isWildcardPortType(type) {
|
|
|
49
50
|
|| normalized === 'system.object'
|
|
50
51
|
|| normalized.startsWith('system.object,');
|
|
51
52
|
}
|
|
53
|
+
// The empty socket a grow-on-connect input spawns its siblings from. The scheme records it
|
|
54
|
+
// first, but it accepts nothing until something is wired into it, so it belongs under the
|
|
55
|
+
// sockets that do carry data.
|
|
56
|
+
function isGrowAnchor(port) {
|
|
57
|
+
return port.isDynamic && !port.isSibling;
|
|
58
|
+
}
|
|
52
59
|
function arePortTypesCompatible(fromType, toType, availableTypes) {
|
|
53
60
|
if (isWildcardPortType(fromType) || isWildcardPortType(toType))
|
|
54
61
|
return true;
|
|
@@ -261,6 +268,8 @@ export class Diagram {
|
|
|
261
268
|
this.hoverPort = null;
|
|
262
269
|
this.hoverNode = null;
|
|
263
270
|
this.hoveredLink = null;
|
|
271
|
+
// Type feeding each wired input, keyed by node and port, rebuilt for every frame.
|
|
272
|
+
this.portFeed = new Map();
|
|
264
273
|
this.tipTimer = null;
|
|
265
274
|
this.tipShow = false;
|
|
266
275
|
this.tipTarget = null;
|
|
@@ -314,8 +323,21 @@ export class Diagram {
|
|
|
314
323
|
throw new Error('ssdiagram: 2d context unavailable');
|
|
315
324
|
this.ctx = ctx;
|
|
316
325
|
this.resize(this.host.clientWidth || 800, this.host.clientHeight || 480);
|
|
326
|
+
this.observeHostSize();
|
|
317
327
|
this.bind();
|
|
318
328
|
}
|
|
329
|
+
/// The control creates the canvas, so it keeps it the size of the element it was mounted in.
|
|
330
|
+
/// A host that lays its panels out after construction - a docking shell, a tab that opens
|
|
331
|
+
/// hidden - measures nothing at that point, and the fallback size above would then outlive
|
|
332
|
+
/// the layout: a strip of the panel that draws nothing and answers no clicks. resize()
|
|
333
|
+
/// ignores anything smaller than two pixels, so a panel switched away from keeps its view.
|
|
334
|
+
observeHostSize() {
|
|
335
|
+
if (typeof ResizeObserver === 'undefined')
|
|
336
|
+
return;
|
|
337
|
+
const observer = new ResizeObserver(() => this.resize(this.host.clientWidth, this.host.clientHeight));
|
|
338
|
+
observer.observe(this.host);
|
|
339
|
+
this.domDisposables.push(() => observer.disconnect());
|
|
340
|
+
}
|
|
319
341
|
// ---- events -----------------------------------------------------
|
|
320
342
|
on(ev, h) {
|
|
321
343
|
let set = this.handlers.get(ev);
|
|
@@ -1627,8 +1649,11 @@ export class Diagram {
|
|
|
1627
1649
|
// node border).
|
|
1628
1650
|
const off = PORT_SQ / 2;
|
|
1629
1651
|
const place = (ports, x) => {
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1652
|
+
const stack = ports.some(isGrowAnchor)
|
|
1653
|
+
? [...ports.filter((p) => !isGrowAnchor(p)), ...ports.filter(isGrowAnchor)]
|
|
1654
|
+
: ports;
|
|
1655
|
+
const startY = n.y + (n.h - stack.length * PORT_ROW_H) / 2 + PORT_ROW_H / 2;
|
|
1656
|
+
stack.forEach((p, i) => { p.cx = x; p.cy = startY + i * PORT_ROW_H; });
|
|
1632
1657
|
};
|
|
1633
1658
|
place(n.inPorts, n.x - off);
|
|
1634
1659
|
place(n.outPorts, n.x + n.w + off);
|
|
@@ -1641,6 +1666,29 @@ export class Diagram {
|
|
|
1641
1666
|
toWorld(sx, sy) {
|
|
1642
1667
|
return [(sx - this.offX) / this.scale, (sy - this.offY) / this.scale];
|
|
1643
1668
|
}
|
|
1669
|
+
/// Type of the output wired into each connected input. First link wins, which is the one
|
|
1670
|
+
/// drawn first, so a socket accepting several links agrees with the wire on top.
|
|
1671
|
+
feedTypes() {
|
|
1672
|
+
const feed = new Map();
|
|
1673
|
+
for (const l of this.links) {
|
|
1674
|
+
const from = this.findNode(l.from)?.outPorts.find((p) => p.id === l.fromPort);
|
|
1675
|
+
if (from === undefined || isWildcardPortType(from.type))
|
|
1676
|
+
continue;
|
|
1677
|
+
const to = this.findNode(l.to)?.inPorts.find((p) => p.id === l.toPort);
|
|
1678
|
+
if (to === undefined || feed.has(to))
|
|
1679
|
+
continue;
|
|
1680
|
+
feed.set(to, from.type);
|
|
1681
|
+
}
|
|
1682
|
+
return feed;
|
|
1683
|
+
}
|
|
1684
|
+
/// Colour type of a socket: its own, unless it is a wildcard input with something wired in.
|
|
1685
|
+
/// A wildcard promises to take whatever arrives, so once something has arrived the socket
|
|
1686
|
+
/// shows what that is. An untyped socket ('') promises nothing and keeps its neutral fill.
|
|
1687
|
+
portFillType(p) {
|
|
1688
|
+
if (p.direction !== 'in' || p.type === '' || !isWildcardPortType(p.type))
|
|
1689
|
+
return p.type;
|
|
1690
|
+
return this.portFeed.get(p) ?? p.type;
|
|
1691
|
+
}
|
|
1644
1692
|
portColor(type) {
|
|
1645
1693
|
const maxL = this.opts.linkMaxLightness;
|
|
1646
1694
|
const light = maxL !== undefined && maxL < 0.5;
|
|
@@ -2017,10 +2065,44 @@ export class Diagram {
|
|
|
2017
2065
|
}
|
|
2018
2066
|
this.lpStart = null;
|
|
2019
2067
|
}
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2068
|
+
/// Ends a node drag the way releasing the pointer does: the whole multi-node move becomes
|
|
2069
|
+
/// one undo step, and every node that actually moved is announced. Nodes are moved in place
|
|
2070
|
+
/// as the pointer travels, so a caller that clears the drag without coming through here
|
|
2071
|
+
/// leaves them where the drag put them with nothing in the history and the host unaware.
|
|
2072
|
+
commitNodeDrag() {
|
|
2073
|
+
if (this.dragNode === null)
|
|
2074
|
+
return;
|
|
2075
|
+
// Capture before/after positions so the whole multi-node
|
|
2076
|
+
// drag is ONE undo step. Skip the action if nothing
|
|
2077
|
+
// actually moved (a "click" that bypassed the threshold).
|
|
2078
|
+
const moves = this.dragStart
|
|
2079
|
+
.map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
|
|
2080
|
+
.filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
|
|
2081
|
+
// Announce the same set the undo step records. Emitting for every node the drag
|
|
2082
|
+
// touched reported a move for a plain selection click -- one per selected node --
|
|
2083
|
+
// and a host doing dirty tracking marked the strategy modified for nothing.
|
|
2084
|
+
const moved = new Set(moves.map((m) => m.id));
|
|
2085
|
+
for (const it of this.dragStart) {
|
|
2086
|
+
if (moved.has(it.n.id))
|
|
2087
|
+
this.emit('nodeMoved', { node: it.n });
|
|
2088
|
+
}
|
|
2089
|
+
this.dragNode = null;
|
|
2090
|
+
this.dragStart = [];
|
|
2091
|
+
if (moves.length > 0) {
|
|
2092
|
+
this.record({
|
|
2093
|
+
do: () => { for (const m of moves)
|
|
2094
|
+
this.doMoveNode(m.id, m.toX, m.toY); },
|
|
2095
|
+
undo: () => { for (const m of moves)
|
|
2096
|
+
this.doMoveNode(m.id, m.fromX, m.fromY); },
|
|
2097
|
+
label: 'drag',
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
// Hit-test at screen coords and emit a contextMenu event. Ends whatever gesture was in
|
|
2102
|
+
// flight — once the menu opens we don't want a half-drag racing it — and a node drag ends
|
|
2103
|
+
// properly rather than being dropped, because its nodes have already moved.
|
|
2023
2104
|
fireContextMenu(sx, sy, pageX, pageY) {
|
|
2105
|
+
this.commitNodeDrag();
|
|
2024
2106
|
const [wx, wy] = this.toWorld(sx, sy);
|
|
2025
2107
|
const port = this.portAt(wx, wy);
|
|
2026
2108
|
const node = port?.node ?? this.nodeAt(wx, wy);
|
|
@@ -2043,11 +2125,17 @@ export class Diagram {
|
|
|
2043
2125
|
this.dragNode = null;
|
|
2044
2126
|
this.dragStart = [];
|
|
2045
2127
|
this.rubber = null;
|
|
2046
|
-
this.panning = false;
|
|
2047
2128
|
this.linking = null;
|
|
2048
2129
|
this.relinking = null;
|
|
2049
2130
|
this.relinkCandidate = null;
|
|
2050
2131
|
this.linkSnap = null;
|
|
2132
|
+
// A pan ends here too, and it ends settled: a host that persists the viewport on the
|
|
2133
|
+
// settled event would otherwise keep the position the pan started from.
|
|
2134
|
+
const viewportMoved = this.panning || this.ovDragging;
|
|
2135
|
+
this.panning = false;
|
|
2136
|
+
this.ovDragging = false;
|
|
2137
|
+
if (viewportMoved)
|
|
2138
|
+
this.emitViewChanged(false);
|
|
2051
2139
|
// A menu is about to cover this spot, and it opens under a cursor that has not moved --
|
|
2052
2140
|
// which fires no boundary event, so nothing else would end the hover. Without this the
|
|
2053
2141
|
// tooltip armed by the last pointermove appears 400ms later, through the open menu.
|
|
@@ -2348,33 +2436,7 @@ export class Diagram {
|
|
|
2348
2436
|
const finish = (e) => {
|
|
2349
2437
|
this.cancelLongPress();
|
|
2350
2438
|
this.relinkCandidate = null;
|
|
2351
|
-
|
|
2352
|
-
// Capture before/after positions so the whole multi-node
|
|
2353
|
-
// drag is ONE undo step. Skip the action if nothing
|
|
2354
|
-
// actually moved (a "click" that bypassed the threshold).
|
|
2355
|
-
const moves = this.dragStart
|
|
2356
|
-
.map((it) => ({ id: it.n.id, fromX: it.x, fromY: it.y, toX: it.n.x, toY: it.n.y }))
|
|
2357
|
-
.filter((m) => m.fromX !== m.toX || m.fromY !== m.toY);
|
|
2358
|
-
// Announce the same set the undo step records. Emitting for every node the drag
|
|
2359
|
-
// touched reported a move for a plain selection click -- one per selected node --
|
|
2360
|
-
// and a host doing dirty tracking marked the strategy modified for nothing.
|
|
2361
|
-
const moved = new Set(moves.map((m) => m.id));
|
|
2362
|
-
for (const it of this.dragStart) {
|
|
2363
|
-
if (moved.has(it.n.id))
|
|
2364
|
-
this.emit('nodeMoved', { node: it.n });
|
|
2365
|
-
}
|
|
2366
|
-
this.dragNode = null;
|
|
2367
|
-
this.dragStart = [];
|
|
2368
|
-
if (moves.length > 0) {
|
|
2369
|
-
this.record({
|
|
2370
|
-
do: () => { for (const m of moves)
|
|
2371
|
-
this.doMoveNode(m.id, m.toX, m.toY); },
|
|
2372
|
-
undo: () => { for (const m of moves)
|
|
2373
|
-
this.doMoveNode(m.id, m.fromX, m.fromY); },
|
|
2374
|
-
label: 'drag',
|
|
2375
|
-
});
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2439
|
+
this.commitNodeDrag();
|
|
2378
2440
|
if (this.rubber !== null) {
|
|
2379
2441
|
const rx0 = Math.min(this.rubber.x0, this.rubber.x);
|
|
2380
2442
|
const ry0 = Math.min(this.rubber.y0, this.rubber.y);
|
|
@@ -2469,13 +2531,21 @@ export class Diagram {
|
|
|
2469
2531
|
this.zoomToFit();
|
|
2470
2532
|
});
|
|
2471
2533
|
// Desktop right-click → same contextMenu event as touch long-press.
|
|
2472
|
-
|
|
2534
|
+
const openContextMenu = (e) => {
|
|
2473
2535
|
e.preventDefault();
|
|
2474
2536
|
if (!this.permissions.inspect)
|
|
2475
2537
|
return;
|
|
2476
2538
|
const [sx, sy] = localXY(e);
|
|
2477
2539
|
this.cancelLongPress();
|
|
2478
2540
|
this.fireContextMenu(sx, sy, e.clientX, e.clientY);
|
|
2541
|
+
};
|
|
2542
|
+
this.listen(this.canvas, 'contextmenu', openContextMenu);
|
|
2543
|
+
// The control is more than its canvas: its own buttons sit over the corners, and a
|
|
2544
|
+
// right-click on one of those stops at the button, so the browser drew its own menu
|
|
2545
|
+
// there. The canvas keeps its own handler, and this one skips what that already took.
|
|
2546
|
+
this.listen(this.host, 'contextmenu', (e) => {
|
|
2547
|
+
if (e.target !== this.canvas)
|
|
2548
|
+
openContextMenu(e);
|
|
2479
2549
|
});
|
|
2480
2550
|
// Two-finger pinch → uniform zoom around the initial midpoint
|
|
2481
2551
|
// between the fingers. Mobile analog of mouse wheel.
|
|
@@ -2658,6 +2728,7 @@ export class Diagram {
|
|
|
2658
2728
|
}
|
|
2659
2729
|
if (options.transient && (this.linking !== null || this.relinking !== null))
|
|
2660
2730
|
this.drawPendingLink();
|
|
2731
|
+
this.portFeed = this.feedTypes();
|
|
2661
2732
|
for (const n of this.nodes)
|
|
2662
2733
|
this.drawNode(n, options.selection && this.selectedNodes.has(n), options);
|
|
2663
2734
|
if (options.selection && this.permissions.createLinks && this.relinking === null)
|
|
@@ -2925,10 +2996,14 @@ export class Diagram {
|
|
|
2925
2996
|
catch { /* undecodable */ }
|
|
2926
2997
|
}
|
|
2927
2998
|
}
|
|
2928
|
-
// Bold title centred on the
|
|
2929
|
-
//
|
|
2999
|
+
// Bold title centred on the body (nudged right when an icon shows so they
|
|
3000
|
+
// don't overlap); ports are bare colour squares on the edges. The title
|
|
3001
|
+
// takes its colour from the fill it sits on: that fill comes from the
|
|
3002
|
+
// scheme, so a node may well be darker than the title used to assume.
|
|
2930
3003
|
const titleShift = n.icon ? iconW + 4 : 0;
|
|
2931
|
-
ctx.fillStyle = hasLoadError
|
|
3004
|
+
ctx.fillStyle = hasLoadError
|
|
3005
|
+
? '#ffffff'
|
|
3006
|
+
: readableTextOn(active ? '#ffd1dc' : n.color);
|
|
2932
3007
|
ctx.font = '600 12px Segoe UI, Tahoma, sans-serif';
|
|
2933
3008
|
ctx.textAlign = 'center';
|
|
2934
3009
|
ctx.textBaseline = 'middle';
|
|
@@ -2955,7 +3030,7 @@ export class Diagram {
|
|
|
2955
3030
|
// Single rounded path filled + stroked (aligned, anti-aliased —
|
|
2956
3031
|
// no half-pixel double edge).
|
|
2957
3032
|
roundRect(ctx, x, y, s, s, r);
|
|
2958
|
-
ctx.fillStyle = this.portColor(p
|
|
3033
|
+
ctx.fillStyle = this.portColor(this.portFillType(p));
|
|
2959
3034
|
ctx.fill();
|
|
2960
3035
|
ctx.lineWidth = magnet ? 1.5 : 1;
|
|
2961
3036
|
ctx.strokeStyle = magnet ? '#ffffff' : 'rgba(12,12,16,0.55)';
|