@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.
@@ -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 startY = n.y + (n.h - ports.length * PORT_ROW_H) / 2 + PORT_ROW_H / 2;
1631
- ports.forEach((p, i) => { p.cx = x; p.cy = startY + i * PORT_ROW_H; });
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
- // Hit-test at screen coords and emit a contextMenu event. Cancels
2021
- // any partial drag/rubber/link gesture that may have started once
2022
- // the menu opens we don't want a half-drag racing it.
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
- if (this.dragNode !== null) {
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
- this.listen(this.canvas, 'contextmenu', (e) => {
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 light body (nudged right when an icon shows so
2929
- // they don't overlap); ports are bare colour squares on the edges.
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 ? '#ffffff' : '#1b1b1b';
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.type);
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)';