@stocksharp/diagram 1.4.0 → 1.5.1
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/README.md +84 -1
- package/dist/esm/canvas-renderer.js +169 -26
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/color.js +42 -0
- package/dist/esm/color.js.map +1 -1
- package/dist/esm/core/document.js +11 -0
- package/dist/esm/core/document.js.map +1 -1
- package/dist/esm/core/model.js.map +1 -1
- package/dist/esm/diagram/stocksharp-diagram.js +31 -3
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/embed.js +104 -20
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/ssdiagram-headless.js +197 -22
- package/dist/ssdiagram-headless.js.map +2 -2
- package/dist/ssdiagram.js +310 -44
- package/dist/ssdiagram.js.map +3 -3
- package/dist/types/canvas-renderer.d.ts +25 -3
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/color.d.ts +7 -0
- package/dist/types/color.d.ts.map +1 -1
- package/dist/types/core/model.d.ts +7 -0
- package/dist/types/core/model.d.ts.map +1 -1
- package/dist/types/diagram/api.d.ts +7 -0
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts +14 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/embed.d.ts +21 -0
- package/dist/types/embed.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/canvas-renderer.ts +172 -26
- package/src/color.ts +48 -0
- package/src/core/document.ts +12 -0
- package/src/core/model.ts +7 -0
- package/src/diagram/api.ts +7 -0
- package/src/diagram/stocksharp-diagram.ts +38 -3
- package/src/embed.ts +147 -29
- package/src/index.ts +2 -0
package/README.md
CHANGED
|
@@ -177,6 +177,89 @@ option and `setFullscreenLabels()` on `StockSharpDiagram`. With neither the
|
|
|
177
177
|
attribute nor the option, the wording comes from the bundle, so a page that has
|
|
178
178
|
already been translated needs no per-diagram markup at all.
|
|
179
179
|
|
|
180
|
+
Two more attributes change what a host is rather than what it says:
|
|
181
|
+
`data-diagram-kind="document"` marks a generic diagram document (see
|
|
182
|
+
*Architecture diagrams* below), and `data-diagram-export="off"` removes the
|
|
183
|
+
download button and the export entries for that one diagram, the same as the
|
|
184
|
+
`allowExport: false` option.
|
|
185
|
+
|
|
186
|
+
### Architecture diagrams: generic documents
|
|
187
|
+
|
|
188
|
+
The same engine draws pictures that are not strategies - a system architecture,
|
|
189
|
+
a data flow, a deployment - from a generic diagram document. Such a document
|
|
190
|
+
names its own nodes, colours and zones, so it needs no palette:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
import { createDiagramDocument, renderDocument } from '@stocksharp/diagram';
|
|
194
|
+
|
|
195
|
+
const drawing = createDiagramDocument({
|
|
196
|
+
nodes: [
|
|
197
|
+
{
|
|
198
|
+
id: 'gate', name: 'Gateway', subtitle: 'FIX / REST / WebSocket',
|
|
199
|
+
description: 'Shown as the hover tooltip',
|
|
200
|
+
color: 'var(--ss-red, #c0392b)', border: 'var(--ss-red-dark, #7a1f16)',
|
|
201
|
+
x: 0, y: 0, outPorts: [{ id: 'out', name: 'Orders' }],
|
|
202
|
+
},
|
|
203
|
+
{ id: 'core', name: 'Matching engine', x: 420, y: 0, inPorts: [{ id: 'in', name: 'Orders' }] },
|
|
204
|
+
],
|
|
205
|
+
links: [{
|
|
206
|
+
from: { nodeId: 'gate', portId: 'out' },
|
|
207
|
+
to: { nodeId: 'core', portId: 'in' },
|
|
208
|
+
label: 'SBE', // short text at the middle of the routed wire
|
|
209
|
+
style: 'dashed', // 'solid' (default) or 'dashed'
|
|
210
|
+
}],
|
|
211
|
+
zones: [{ id: 'colo', name: 'Colocation', x: -60, y: -60, width: 760, height: 200, color: 'var(--ss-gold)' }],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const handle = renderDocument(document.querySelector<HTMLElement>('#architecture')!, drawing);
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
- `subtitle` is an optional second line under the node name, smaller and
|
|
218
|
+
muted; the node grows to fit it. `label` is an optional short text drawn on a
|
|
219
|
+
plate of the canvas colour at the middle of the link. Both are left out of a
|
|
220
|
+
saved document when empty, so documents written before them keep their shape.
|
|
221
|
+
- Zones count as content: `zoomToFit()` and `takeScreenshot` / `takeSvg` with
|
|
222
|
+
`scope: 'content'` frame every zone and its caption, not only the nodes.
|
|
223
|
+
- Node `color` / `border` and zone `color` may name a page token -
|
|
224
|
+
`var(--ss-red)`, with or without a fallback. It is resolved against the element
|
|
225
|
+
the diagram is mounted in on every repaint, so the picture follows the page's
|
|
226
|
+
theme; the saved document keeps the reference, and an SVG export carries the
|
|
227
|
+
resolved colour. A token that is unset and has no fallback gets the default
|
|
228
|
+
node, border or zone colour.
|
|
229
|
+
|
|
230
|
+
`renderDocument(div, document, options)` mounts a read-only viewer: no minimap,
|
|
231
|
+
no link validation, the canvas and grid colours read from `--diagram-bg` /
|
|
232
|
+
`--diagram-grid` on the page (repainted when `data-theme` changes on `<html>`,
|
|
233
|
+
which also re-reads every token colour), and a `ResizeObserver` that keeps the
|
|
234
|
+
drawing fitted. It accepts a saved document or one written by hand, leaving out
|
|
235
|
+
whatever has a default, and throws `DiagramDocumentError` for anything else. It
|
|
236
|
+
takes the same `onFullscreenRequested`, `onExportRequested` and `onDestroyed`
|
|
237
|
+
hooks as `renderScheme`, plus:
|
|
238
|
+
|
|
239
|
+
- `pageScroll` - on by default for a document: a plain wheel scrolls the page and
|
|
240
|
+
only Ctrl/Meta+wheel (and a trackpad pinch) zooms; on touch screens a
|
|
241
|
+
one-finger vertical swipe scrolls the page (`touch-action: pan-y`), while a
|
|
242
|
+
horizontal drag pans the diagram and a two-finger pinch zooms it. Pass `false`
|
|
243
|
+
to keep every gesture in the diagram. `StockSharpDiagram` has the same switch
|
|
244
|
+
as the `pageScroll` option and `setPageScroll()`; it is off there by default.
|
|
245
|
+
- `allowExport: false` - no download button and no export entries in the menu,
|
|
246
|
+
even when `onExportRequested` is passed (`StockSharpDiagram.setExportEnabled()`
|
|
247
|
+
underneath).
|
|
248
|
+
|
|
249
|
+
Pages that use `renderAll()` mark such a host with `data-diagram-kind="document"`
|
|
250
|
+
and give it the document inline or through `data-diagram-src`. A host without
|
|
251
|
+
the attribute is still recognised when its JSON has a numeric `version` and a
|
|
252
|
+
`nodes` array at the root, which a saved strategy never has; strategy hosts are
|
|
253
|
+
drawn exactly as before.
|
|
254
|
+
|
|
255
|
+
```html
|
|
256
|
+
<div class="ss-diagram-host" data-diagram-kind="document" data-diagram-export="off">
|
|
257
|
+
<script type="application/json">
|
|
258
|
+
{ "nodes": [{ "id": "a", "name": "Client", "subtitle": "FIX" }], "links": [] }
|
|
259
|
+
</script>
|
|
260
|
+
</div>
|
|
261
|
+
```
|
|
262
|
+
|
|
180
263
|
### Node actions and errors
|
|
181
264
|
|
|
182
265
|
Double-click handling is opt-in. Give only the node types controlled by the
|
|
@@ -354,7 +437,7 @@ The component has one document model and a separate rendering layer:
|
|
|
354
437
|
| Document core | `src/core/*` | Versioned document serialization plus independent runtime, view and selection state |
|
|
355
438
|
| Public component API | `src/diagram/stocksharp-diagram.ts` | `StockSharpDiagram`: catalog-aware nodes, ports, validation, events, persistence, history and theming |
|
|
356
439
|
| Models and palette | `src/diagram/{types,catalog,palette}.ts` | Public data model and draggable HTML element palette |
|
|
357
|
-
| Web embed | `src/embed.ts` | Self-contained read-only rendering for web applications |
|
|
440
|
+
| Web embed | `src/embed.ts` | Self-contained read-only rendering of strategy schemes and generic documents for web applications |
|
|
358
441
|
| Canvas renderer | `src/canvas-renderer.ts` | Internal drawing, routing, selection, editing, zoom, touch and overview |
|
|
359
442
|
|
|
360
443
|
Applications use `StockSharpDiagram` or the read-only embed. The renderer is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SvgSurface } from './svg-surface.js';
|
|
2
|
-
import { cappedLightness, legibleOn, readableOutlineOn, readableTextOn } from './color.js';
|
|
2
|
+
import { cappedLightness, legibleOn, readableOutlineOn, readableTextOn, resolveCssVariables } from './color.js';
|
|
3
3
|
// Internal dependency-free canvas renderer used by StockSharpDiagram.
|
|
4
4
|
//
|
|
5
5
|
// Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
|
|
@@ -104,6 +104,7 @@ export class NodeModel {
|
|
|
104
104
|
this.id = id;
|
|
105
105
|
this.typeId = init.typeId ?? id;
|
|
106
106
|
this.name = init.name;
|
|
107
|
+
this.subtitle = init.subtitle ?? '';
|
|
107
108
|
this.description = init.description ?? '';
|
|
108
109
|
this.groupName = init.groupName ?? 'Common';
|
|
109
110
|
this.color = init.color ?? '#d7d7d7';
|
|
@@ -129,6 +130,7 @@ export class NodeModel {
|
|
|
129
130
|
id: this.id,
|
|
130
131
|
typeId: this.typeId,
|
|
131
132
|
name: this.name,
|
|
133
|
+
...(this.subtitle === '' ? {} : { subtitle: this.subtitle }),
|
|
132
134
|
description: this.description,
|
|
133
135
|
groupName: this.groupName,
|
|
134
136
|
color: this.color,
|
|
@@ -151,12 +153,13 @@ export class NodeModel {
|
|
|
151
153
|
}
|
|
152
154
|
}
|
|
153
155
|
export class LinkModel {
|
|
154
|
-
constructor(from, fromPort, to, toPort, id = '', metadata, style = 'solid') {
|
|
156
|
+
constructor(from, fromPort, to, toPort, id = '', metadata, style = 'solid', label = '') {
|
|
155
157
|
this.from = from;
|
|
156
158
|
this.fromPort = fromPort;
|
|
157
159
|
this.to = to;
|
|
158
160
|
this.toPort = toPort;
|
|
159
161
|
this.style = style;
|
|
162
|
+
this.label = label;
|
|
160
163
|
this.id = id;
|
|
161
164
|
this.metadata = copyJsonObject(metadata);
|
|
162
165
|
}
|
|
@@ -169,6 +172,7 @@ export class LinkModel {
|
|
|
169
172
|
to: this.to,
|
|
170
173
|
toPort: this.toPort,
|
|
171
174
|
style: this.style,
|
|
175
|
+
...(this.label === '' ? {} : { label: this.label }),
|
|
172
176
|
metadata: copyJsonObject(this.metadata),
|
|
173
177
|
};
|
|
174
178
|
}
|
|
@@ -189,6 +193,15 @@ const INTRO_MS = 520; // entrance animation duration
|
|
|
189
193
|
const INTRO_RISE = 70; // px the scheme rises from below
|
|
190
194
|
const ERROR_FLASH_MS = 1100;
|
|
191
195
|
const ERROR_RED = '#f6465d';
|
|
196
|
+
const DEFAULT_NODE_COLOR = '#d7d7d7';
|
|
197
|
+
const DEFAULT_NODE_BORDER = '#8c8c8c';
|
|
198
|
+
const DEFAULT_ZONE_TINT = '#8a8f98';
|
|
199
|
+
const TITLE_FONT = '600 12px Segoe UI, Tahoma, sans-serif';
|
|
200
|
+
const SUBTITLE_FONT = '11px Segoe UI, Tahoma, sans-serif';
|
|
201
|
+
const SUBTITLE_MIN_H = 62; // body height that fits a name and a subtitle line
|
|
202
|
+
const LINK_LABEL_FONT = '600 10px Segoe UI, Tahoma, sans-serif';
|
|
203
|
+
const ZONE_CAPTION_TOP = 9; // caption offset inside the zone's top-left corner
|
|
204
|
+
const ZONE_CAPTION_H = 14;
|
|
192
205
|
const ERROR_BACKGROUND = '#7d2632';
|
|
193
206
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
194
207
|
function hashHue(s) {
|
|
@@ -290,6 +303,9 @@ export class Diagram {
|
|
|
290
303
|
this.ovGeo = null;
|
|
291
304
|
this.validator = null;
|
|
292
305
|
this.handlers = new Map();
|
|
306
|
+
// Computed style of the host, read at most once per frame: colours written as var(--token)
|
|
307
|
+
// resolve against it, so a frame drawn after a theme switch picks up the new values.
|
|
308
|
+
this.hostStyle = null;
|
|
293
309
|
this.opts = opts;
|
|
294
310
|
// Same normalization setTypeColors applies, so a palette handed in at construction is
|
|
295
311
|
// keyed like one handed in later.
|
|
@@ -297,6 +313,7 @@ export class Diagram {
|
|
|
297
313
|
this.setTypeColorsInternal(opts.typeColors);
|
|
298
314
|
this.host = opts.host;
|
|
299
315
|
this.gridSnapEnabled = opts.gridSnap ?? false;
|
|
316
|
+
this.pageScroll = opts.pageScroll ?? false;
|
|
300
317
|
this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
|
|
301
318
|
this.history = new DiagramCommandHistory(() => {
|
|
302
319
|
// The gated values, not the raw stack: setInteractionPermissions and setReadOnly
|
|
@@ -308,9 +325,9 @@ export class Diagram {
|
|
|
308
325
|
this.canvas.style.display = 'block';
|
|
309
326
|
this.canvas.tabIndex = 0; // focusable → key events
|
|
310
327
|
this.canvas.style.outline = 'none';
|
|
311
|
-
// Eat browser-native touch gestures (page scroll / pinch-zoom) so
|
|
312
|
-
//
|
|
313
|
-
this.
|
|
328
|
+
// Eat browser-native touch gestures (page scroll / pinch-zoom) so fingers actually drive our
|
|
329
|
+
// pan/zoom on mobile instead of the page - unless the page asked to keep its scrolling.
|
|
330
|
+
this.applyTouchAction();
|
|
314
331
|
// No OS text-selection on long-press (would pop the iOS magnifier
|
|
315
332
|
// and select node/link captions instead of firing our menu).
|
|
316
333
|
this.canvas.style.userSelect = 'none';
|
|
@@ -368,6 +385,17 @@ export class Diagram {
|
|
|
368
385
|
this.gridSize = nextSize;
|
|
369
386
|
this.scheduleDraw();
|
|
370
387
|
}
|
|
388
|
+
/** See DiagramOptions.pageScroll. */
|
|
389
|
+
setPageScroll(enabled) {
|
|
390
|
+
this.pageScroll = enabled;
|
|
391
|
+
this.applyTouchAction();
|
|
392
|
+
}
|
|
393
|
+
isPageScrollEnabled() { return this.pageScroll; }
|
|
394
|
+
// pan-y hands a vertical swipe to the browser, which scrolls the page; a horizontal one and a
|
|
395
|
+
// two-finger pinch still reach the canvas.
|
|
396
|
+
applyTouchAction() {
|
|
397
|
+
this.canvas.style.touchAction = this.pageScroll ? 'pan-y' : 'none';
|
|
398
|
+
}
|
|
371
399
|
getGridSnap() {
|
|
372
400
|
return { enabled: this.gridSnapEnabled, size: this.gridSize };
|
|
373
401
|
}
|
|
@@ -601,7 +629,7 @@ export class Diagram {
|
|
|
601
629
|
if (!validation.allowed)
|
|
602
630
|
return false;
|
|
603
631
|
}
|
|
604
|
-
const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? 'solid');
|
|
632
|
+
const link = new LinkModel(init.from, init.fromPort, init.to, init.toPort, init.id ?? this.nextLinkId(), init.metadata, init.style ?? 'solid', init.label ?? '');
|
|
605
633
|
if (this.links.some((l) => l.id === link.id))
|
|
606
634
|
return false;
|
|
607
635
|
this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
|
|
@@ -921,6 +949,8 @@ export class Diagram {
|
|
|
921
949
|
return this.updateNodeState(nodeId, 'update node', (node) => {
|
|
922
950
|
if (patch.name !== undefined)
|
|
923
951
|
node.name = patch.name;
|
|
952
|
+
if (patch.subtitle !== undefined)
|
|
953
|
+
node.subtitle = patch.subtitle;
|
|
924
954
|
if (patch.description !== undefined)
|
|
925
955
|
node.description = patch.description;
|
|
926
956
|
if (patch.color !== undefined)
|
|
@@ -973,6 +1003,7 @@ export class Diagram {
|
|
|
973
1003
|
const restored = new NodeModel(snapshot, nodeId);
|
|
974
1004
|
node.typeId = restored.typeId;
|
|
975
1005
|
node.name = restored.name;
|
|
1006
|
+
node.subtitle = restored.subtitle;
|
|
976
1007
|
node.description = restored.description;
|
|
977
1008
|
node.groupName = restored.groupName;
|
|
978
1009
|
node.color = restored.color;
|
|
@@ -1006,6 +1037,10 @@ export class Diagram {
|
|
|
1006
1037
|
return true;
|
|
1007
1038
|
}
|
|
1008
1039
|
load(nodes, links) {
|
|
1040
|
+
this.loadModel(nodes, links, []);
|
|
1041
|
+
}
|
|
1042
|
+
// Zones are placed before the view is fitted, so the first frame frames them too.
|
|
1043
|
+
loadModel(nodes, links, zones) {
|
|
1009
1044
|
this.clear();
|
|
1010
1045
|
this.idSeq = 1;
|
|
1011
1046
|
this.linkSeq = 1;
|
|
@@ -1043,10 +1078,11 @@ export class Diagram {
|
|
|
1043
1078
|
if (this.links.some((existing) => existing.id === id)) {
|
|
1044
1079
|
throw new Error(`ssdiagram: duplicate link id "${id}"`);
|
|
1045
1080
|
}
|
|
1046
|
-
const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? 'solid');
|
|
1081
|
+
const model = new LinkModel(link.from, link.fromPort, link.to, link.toPort, id, link.metadata, link.style ?? 'solid', link.label ?? '');
|
|
1047
1082
|
if (!this.links.some((existing) => existing.key() === model.key()))
|
|
1048
1083
|
this.links.push(model);
|
|
1049
1084
|
}
|
|
1085
|
+
this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
|
|
1050
1086
|
for (const node of this.nodes) {
|
|
1051
1087
|
if (node.loadError.length === 0)
|
|
1052
1088
|
continue;
|
|
@@ -1071,22 +1107,23 @@ export class Diagram {
|
|
|
1071
1107
|
to: l.to,
|
|
1072
1108
|
toPort: l.toPort,
|
|
1073
1109
|
style: l.style,
|
|
1110
|
+
...(l.label === '' ? {} : { label: l.label }),
|
|
1074
1111
|
metadata: copyJsonObject(l.metadata),
|
|
1075
1112
|
})),
|
|
1076
1113
|
};
|
|
1077
1114
|
}
|
|
1078
1115
|
loadDocument(source) {
|
|
1079
1116
|
const document = parseDiagramDocument(source);
|
|
1080
|
-
this.
|
|
1117
|
+
this.loadModel(document.nodes, document.links.map((link) => ({
|
|
1081
1118
|
id: link.id,
|
|
1082
1119
|
from: link.from.nodeId,
|
|
1083
1120
|
fromPort: link.from.portId,
|
|
1084
1121
|
to: link.to.nodeId,
|
|
1085
1122
|
toPort: link.to.portId,
|
|
1086
1123
|
style: link.style,
|
|
1124
|
+
label: link.label,
|
|
1087
1125
|
metadata: link.metadata,
|
|
1088
|
-
})));
|
|
1089
|
-
this.zones = document.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
|
|
1126
|
+
})), document.zones);
|
|
1090
1127
|
this.documentMetadata = copyJsonObject(document.metadata);
|
|
1091
1128
|
}
|
|
1092
1129
|
saveDocument() {
|
|
@@ -1097,6 +1134,7 @@ export class Diagram {
|
|
|
1097
1134
|
from: { nodeId: link.from, portId: link.fromPort },
|
|
1098
1135
|
to: { nodeId: link.to, portId: link.toPort },
|
|
1099
1136
|
style: link.style,
|
|
1137
|
+
label: link.label,
|
|
1100
1138
|
metadata: link.metadata,
|
|
1101
1139
|
})),
|
|
1102
1140
|
zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
|
|
@@ -1571,8 +1609,10 @@ export class Diagram {
|
|
|
1571
1609
|
this.opts.overviewViewportFill = t.overviewViewportFill;
|
|
1572
1610
|
this.scheduleDraw();
|
|
1573
1611
|
}
|
|
1612
|
+
// Zones count: fitting or exporting only the nodes cut off the places they sit in, captions
|
|
1613
|
+
// included. A caption that is taller than its zone still has to fit.
|
|
1574
1614
|
graphBounds() {
|
|
1575
|
-
if (this.nodes.length === 0)
|
|
1615
|
+
if (this.nodes.length === 0 && this.zones.length === 0)
|
|
1576
1616
|
return null;
|
|
1577
1617
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1578
1618
|
for (const n of this.nodes) {
|
|
@@ -1581,6 +1621,15 @@ export class Diagram {
|
|
|
1581
1621
|
maxX = Math.max(maxX, n.x + n.w);
|
|
1582
1622
|
maxY = Math.max(maxY, n.y + n.h);
|
|
1583
1623
|
}
|
|
1624
|
+
for (const zone of this.zones) {
|
|
1625
|
+
const bottom = zone.name.length > 0
|
|
1626
|
+
? Math.max(zone.height, ZONE_CAPTION_TOP + ZONE_CAPTION_H)
|
|
1627
|
+
: zone.height;
|
|
1628
|
+
minX = Math.min(minX, zone.x - 1);
|
|
1629
|
+
minY = Math.min(minY, zone.y - 1);
|
|
1630
|
+
maxX = Math.max(maxX, zone.x + zone.width + 1);
|
|
1631
|
+
maxY = Math.max(maxY, zone.y + bottom + 1);
|
|
1632
|
+
}
|
|
1584
1633
|
return { minX, minY, maxX, maxY };
|
|
1585
1634
|
}
|
|
1586
1635
|
resize(w, h) {
|
|
@@ -1637,12 +1686,17 @@ export class Diagram {
|
|
|
1637
1686
|
// ---- geometry ---------------------------------------------------
|
|
1638
1687
|
layoutNode(n) {
|
|
1639
1688
|
const ctx = this.ctx;
|
|
1640
|
-
ctx.font =
|
|
1689
|
+
ctx.font = TITLE_FONT;
|
|
1641
1690
|
const titleW = ctx.measureText(n.name).width;
|
|
1691
|
+
let subtitleW = 0;
|
|
1692
|
+
if (n.subtitle.length > 0) {
|
|
1693
|
+
ctx.font = SUBTITLE_FONT;
|
|
1694
|
+
subtitleW = ctx.measureText(n.subtitle).width;
|
|
1695
|
+
}
|
|
1642
1696
|
const rows = Math.max(n.inPorts.length, n.outPorts.length, 1);
|
|
1643
|
-
// Node sizing: min 170 wide, height grows by rows.
|
|
1644
|
-
n.w = Math.max(170, Math.ceil(titleW) + 28);
|
|
1645
|
-
n.h = Math.max(48, rows * PORT_ROW_H + 16);
|
|
1697
|
+
// Node sizing: min 170 wide, height grows by rows; a subtitle adds a second text line.
|
|
1698
|
+
n.w = Math.max(170, Math.ceil(titleW) + 28, Math.ceil(subtitleW) + 28);
|
|
1699
|
+
n.h = Math.max(n.subtitle.length > 0 ? SUBTITLE_MIN_H : 48, rows * PORT_ROW_H + 16);
|
|
1646
1700
|
// Each side's port stack is vertically centred; the socket
|
|
1647
1701
|
// square sits OUTSIDE the body (its inner edge flush with the
|
|
1648
1702
|
// node border).
|
|
@@ -2038,6 +2092,7 @@ export class Diagram {
|
|
|
2038
2092
|
// The clipboard is built from saveDocument(), so it carries the style;
|
|
2039
2093
|
// rebuilding without it silently turned every pasted dashed link solid.
|
|
2040
2094
|
style: l.style,
|
|
2095
|
+
label: l.label,
|
|
2041
2096
|
metadata: l.metadata,
|
|
2042
2097
|
});
|
|
2043
2098
|
}
|
|
@@ -2506,6 +2561,9 @@ export class Diagram {
|
|
|
2506
2561
|
this.listen(window, 'pointerup', finish);
|
|
2507
2562
|
this.listen(window, 'pointercancel', finish);
|
|
2508
2563
|
this.listen(this.canvas, 'wheel', (e) => {
|
|
2564
|
+
// Ctrl+wheel is also what a trackpad pinch arrives as, so zoom stays one gesture away.
|
|
2565
|
+
if (this.pageScroll && !e.ctrlKey && !e.metaKey)
|
|
2566
|
+
return;
|
|
2509
2567
|
e.preventDefault();
|
|
2510
2568
|
const [sx, sy] = localXY(e);
|
|
2511
2569
|
const [wx, wy] = this.toWorld(sx, sy);
|
|
@@ -2678,6 +2736,7 @@ export class Diagram {
|
|
|
2678
2736
|
}
|
|
2679
2737
|
draw(options = SCREEN_RENDER_OPTIONS) {
|
|
2680
2738
|
const ctx = this.ctx;
|
|
2739
|
+
this.hostStyle = null;
|
|
2681
2740
|
ctx.clearRect(0, 0, this.width, this.height);
|
|
2682
2741
|
const background = this.opts.background ?? '#1b1b1f';
|
|
2683
2742
|
ctx.fillStyle = background;
|
|
@@ -2708,6 +2767,7 @@ export class Diagram {
|
|
|
2708
2767
|
// Links next. Each link jumps over the verticals of the links
|
|
2709
2768
|
// drawn before it (deterministic bridge — Link.JumpOver parity).
|
|
2710
2769
|
const prior = []; // segments of links already drawn
|
|
2770
|
+
const labels = [];
|
|
2711
2771
|
const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
|
|
2712
2772
|
for (const l of this.links) {
|
|
2713
2773
|
const a = this.endpoint(l, 'from');
|
|
@@ -2729,6 +2789,8 @@ export class Diagram {
|
|
|
2729
2789
|
const pts = this.routeLink(a, b, new Set([l.from, l.to]));
|
|
2730
2790
|
this.strokeRoute(pts, color, width, prior, l.style);
|
|
2731
2791
|
this.drawArrow(pts, color);
|
|
2792
|
+
if (l.label.length > 0)
|
|
2793
|
+
labels.push({ pts, text: l.label, color });
|
|
2732
2794
|
for (let k = 1; k < pts.length; k += 1) {
|
|
2733
2795
|
const [x1, y1] = pts[k - 1];
|
|
2734
2796
|
const [x2, y2] = pts[k];
|
|
@@ -2738,6 +2800,9 @@ export class Diagram {
|
|
|
2738
2800
|
prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
|
|
2739
2801
|
}
|
|
2740
2802
|
}
|
|
2803
|
+
// After every wire, so no later wire runs across a label; before the nodes, which stay on top.
|
|
2804
|
+
for (const label of labels)
|
|
2805
|
+
this.drawLinkLabel(label.pts, label.text, label.color, background);
|
|
2741
2806
|
if (options.transient && (this.linking !== null || this.relinking !== null))
|
|
2742
2807
|
this.drawPendingLink();
|
|
2743
2808
|
this.portFeed = this.feedTypes();
|
|
@@ -2846,7 +2911,9 @@ export class Diagram {
|
|
|
2846
2911
|
text = n.description;
|
|
2847
2912
|
}
|
|
2848
2913
|
else {
|
|
2849
|
-
|
|
2914
|
+
// A node given no type carries its id as one; an id is a key, not a word for the reader.
|
|
2915
|
+
const typed = n.typeId.length > 0 && n.typeId !== n.name && n.typeId !== n.id;
|
|
2916
|
+
text = typed ? `${n.name} [${n.typeId}]` : n.name;
|
|
2850
2917
|
}
|
|
2851
2918
|
}
|
|
2852
2919
|
else {
|
|
@@ -2905,8 +2972,9 @@ export class Diagram {
|
|
|
2905
2972
|
if (this.zones.length === 0)
|
|
2906
2973
|
return;
|
|
2907
2974
|
const ctx = this.ctx;
|
|
2975
|
+
const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
|
|
2908
2976
|
for (const zone of this.zones) {
|
|
2909
|
-
const tint = zone.color.length > 0 ? zone.color :
|
|
2977
|
+
const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
|
|
2910
2978
|
ctx.save();
|
|
2911
2979
|
roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
|
|
2912
2980
|
ctx.globalAlpha = 0.12;
|
|
@@ -2923,12 +2991,12 @@ export class Diagram {
|
|
|
2923
2991
|
ctx.save();
|
|
2924
2992
|
ctx.globalAlpha = 0.75;
|
|
2925
2993
|
ctx.fillStyle = tint;
|
|
2926
|
-
ctx.font =
|
|
2994
|
+
ctx.font = TITLE_FONT;
|
|
2927
2995
|
ctx.textAlign = 'left';
|
|
2928
2996
|
ctx.textBaseline = 'top';
|
|
2929
2997
|
// Inside the top-left corner: a caption outside the box would collide with whatever
|
|
2930
2998
|
// sits above it, and the corner is the one spot a rectangle always has to spare.
|
|
2931
|
-
ctx.fillText(zone.name, zone.x + 12, zone.y +
|
|
2999
|
+
ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
|
|
2932
3000
|
ctx.restore();
|
|
2933
3001
|
}
|
|
2934
3002
|
}
|
|
@@ -2971,11 +3039,12 @@ export class Diagram {
|
|
|
2971
3039
|
const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
|
|
2972
3040
|
const active = options.runtime
|
|
2973
3041
|
&& (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
|
|
3042
|
+
const fill = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
|
|
2974
3043
|
roundRect(ctx, n.x, n.y, n.w, n.h, 6);
|
|
2975
|
-
ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' :
|
|
3044
|
+
ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : fill;
|
|
2976
3045
|
ctx.fill();
|
|
2977
3046
|
ctx.lineWidth = selected ? 2 : 1.5;
|
|
2978
|
-
ctx.strokeStyle = selected ? this.selectionColor() : n.border;
|
|
3047
|
+
ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n.border, DEFAULT_NODE_BORDER);
|
|
2979
3048
|
ctx.stroke();
|
|
2980
3049
|
if (hasLoadError || hasRuntimeError) {
|
|
2981
3050
|
let alpha = 1;
|
|
@@ -3013,13 +3082,26 @@ export class Diagram {
|
|
|
3013
3082
|
// takes its colour from the fill it sits on: that fill comes from the
|
|
3014
3083
|
// scheme, so a node may well be darker than the title used to assume.
|
|
3015
3084
|
const titleShift = n.icon ? iconW + 4 : 0;
|
|
3085
|
+
const textX = n.x + titleShift + (n.w - titleShift) / 2;
|
|
3086
|
+
const textWidth = n.w - titleShift - 14;
|
|
3016
3087
|
ctx.fillStyle = hasLoadError
|
|
3017
3088
|
? '#ffffff'
|
|
3018
|
-
: readableTextOn(active ? '#ffd1dc' :
|
|
3019
|
-
ctx.font =
|
|
3089
|
+
: readableTextOn(active ? '#ffd1dc' : fill);
|
|
3090
|
+
ctx.font = TITLE_FONT;
|
|
3020
3091
|
ctx.textAlign = 'center';
|
|
3021
3092
|
ctx.textBaseline = 'middle';
|
|
3022
|
-
|
|
3093
|
+
if (n.subtitle.length === 0) {
|
|
3094
|
+
ctx.fillText(n.name, textX, n.y + n.h / 2, textWidth);
|
|
3095
|
+
}
|
|
3096
|
+
else {
|
|
3097
|
+
// Two lines centred as a pair: the name above, the subtitle smaller and muted below.
|
|
3098
|
+
ctx.fillText(n.name, textX, n.y + n.h / 2 - 8, textWidth);
|
|
3099
|
+
ctx.save();
|
|
3100
|
+
ctx.globalAlpha *= 0.72;
|
|
3101
|
+
ctx.font = SUBTITLE_FONT;
|
|
3102
|
+
ctx.fillText(n.subtitle, textX, n.y + n.h / 2 + 9, textWidth);
|
|
3103
|
+
ctx.restore();
|
|
3104
|
+
}
|
|
3023
3105
|
for (const p of n.inPorts)
|
|
3024
3106
|
this.drawPort(n, p, options);
|
|
3025
3107
|
for (const p of n.outPorts)
|
|
@@ -3163,6 +3245,48 @@ export class Diagram {
|
|
|
3163
3245
|
ctx.fill();
|
|
3164
3246
|
ctx.restore();
|
|
3165
3247
|
}
|
|
3248
|
+
// A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
|
|
3249
|
+
// whatever the wire crosses, and still says which wire it belongs to.
|
|
3250
|
+
drawLinkLabel(pts, text, color, background) {
|
|
3251
|
+
const [x, y] = pathMidpoint(pts);
|
|
3252
|
+
const ctx = this.ctx;
|
|
3253
|
+
ctx.save();
|
|
3254
|
+
ctx.font = LINK_LABEL_FONT;
|
|
3255
|
+
const w = Math.ceil(ctx.measureText(text).width) + 12;
|
|
3256
|
+
const h = 16;
|
|
3257
|
+
roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
|
|
3258
|
+
ctx.fillStyle = background;
|
|
3259
|
+
ctx.fill();
|
|
3260
|
+
ctx.lineWidth = 1;
|
|
3261
|
+
ctx.strokeStyle = color;
|
|
3262
|
+
ctx.stroke();
|
|
3263
|
+
ctx.fillStyle = readableTextOn(background);
|
|
3264
|
+
ctx.textAlign = 'center';
|
|
3265
|
+
ctx.textBaseline = 'middle';
|
|
3266
|
+
ctx.fillText(text, x, y);
|
|
3267
|
+
ctx.restore();
|
|
3268
|
+
}
|
|
3269
|
+
// Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
|
|
3270
|
+
// paint. They resolve against the host element; one that resolves to nothing and has no
|
|
3271
|
+
// fallback of its own gets the renderer's default for that slot.
|
|
3272
|
+
resolveColor(value, fallback) {
|
|
3273
|
+
if (!value.includes('var('))
|
|
3274
|
+
return value;
|
|
3275
|
+
return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
|
|
3276
|
+
}
|
|
3277
|
+
hostProperty(name) {
|
|
3278
|
+
if (this.hostStyle === null) {
|
|
3279
|
+
if (typeof getComputedStyle !== 'function')
|
|
3280
|
+
return '';
|
|
3281
|
+
try {
|
|
3282
|
+
this.hostStyle = getComputedStyle(this.host);
|
|
3283
|
+
}
|
|
3284
|
+
catch {
|
|
3285
|
+
return '';
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
return this.hostStyle.getPropertyValue(name);
|
|
3289
|
+
}
|
|
3166
3290
|
drawSelectedLinkEndpoints() {
|
|
3167
3291
|
if (this.selectedLink === null)
|
|
3168
3292
|
return;
|
|
@@ -3296,9 +3420,9 @@ export class Diagram {
|
|
|
3296
3420
|
ctx.stroke();
|
|
3297
3421
|
ctx.clip();
|
|
3298
3422
|
for (const n of this.nodes) {
|
|
3299
|
-
ctx.fillStyle = n.color;
|
|
3423
|
+
ctx.fillStyle = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
|
|
3300
3424
|
ctx.fillRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
|
|
3301
|
-
ctx.strokeStyle = n.border;
|
|
3425
|
+
ctx.strokeStyle = this.resolveColor(n.border, DEFAULT_NODE_BORDER);
|
|
3302
3426
|
ctx.lineWidth = 1;
|
|
3303
3427
|
ctx.strokeRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
|
|
3304
3428
|
}
|
|
@@ -3346,6 +3470,25 @@ function roundRect(ctx, x, y, w, h, r) {
|
|
|
3346
3470
|
// a 3-segment mid-X elbow. Backward/too-close: a 5-segment detour that
|
|
3347
3471
|
// leaves the source rightward and re-enters the target from the left
|
|
3348
3472
|
// (AvoidsNodes-style, never a diagonal).
|
|
3473
|
+
/** The point halfway along a polyline, measured by length rather than by vertex count. */
|
|
3474
|
+
function pathMidpoint(pts) {
|
|
3475
|
+
let total = 0;
|
|
3476
|
+
for (let i = 1; i < pts.length; i += 1)
|
|
3477
|
+
total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
|
3478
|
+
let remaining = total / 2;
|
|
3479
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
3480
|
+
const [x1, y1] = pts[i - 1];
|
|
3481
|
+
const [x2, y2] = pts[i];
|
|
3482
|
+
const length = Math.hypot(x2 - x1, y2 - y1);
|
|
3483
|
+
if (length > 0 && remaining <= length) {
|
|
3484
|
+
const t = remaining / length;
|
|
3485
|
+
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
|
|
3486
|
+
}
|
|
3487
|
+
remaining -= length;
|
|
3488
|
+
}
|
|
3489
|
+
const last = pts[pts.length - 1];
|
|
3490
|
+
return [last[0], last[1]];
|
|
3491
|
+
}
|
|
3349
3492
|
function route(a, b) {
|
|
3350
3493
|
const [ax, ay] = a;
|
|
3351
3494
|
const [bx, by] = b;
|