@stocksharp/diagram 1.3.0 → 1.5.0
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 +178 -31
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/color.js +110 -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 +23 -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 +247 -26
- package/dist/ssdiagram-headless.js.map +2 -2
- package/dist/ssdiagram.js +352 -48
- package/dist/ssdiagram.js.map +3 -3
- package/dist/types/canvas-renderer.d.ts +26 -3
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/color.d.ts +9 -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 +6 -0
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts +11 -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 +182 -30
- package/src/color.ts +130 -0
- package/src/core/document.ts +12 -0
- package/src/core/model.ts +7 -0
- package/src/diagram/api.ts +6 -0
- package/src/diagram/stocksharp-diagram.ts +27 -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 { 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) {
|
|
@@ -202,10 +215,7 @@ function hashHue(s) {
|
|
|
202
215
|
// wash out; a light theme passes a low ceiling to darken the links just enough to stay readable
|
|
203
216
|
// while keeping each hue distinct. Non-hsl inputs are returned untouched.
|
|
204
217
|
function clampLightness(color, maxL) {
|
|
205
|
-
|
|
206
|
-
if (m === null)
|
|
207
|
-
return color;
|
|
208
|
-
return `hsl(${m[1]}, ${m[2]}%, ${Math.min(+m[3], maxL * 100)}%)`;
|
|
218
|
+
return cappedLightness(color, maxL);
|
|
209
219
|
}
|
|
210
220
|
function colorLuminance(color) {
|
|
211
221
|
if (color === undefined)
|
|
@@ -293,6 +303,9 @@ export class Diagram {
|
|
|
293
303
|
this.ovGeo = null;
|
|
294
304
|
this.validator = null;
|
|
295
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;
|
|
296
309
|
this.opts = opts;
|
|
297
310
|
// Same normalization setTypeColors applies, so a palette handed in at construction is
|
|
298
311
|
// keyed like one handed in later.
|
|
@@ -300,6 +313,7 @@ export class Diagram {
|
|
|
300
313
|
this.setTypeColorsInternal(opts.typeColors);
|
|
301
314
|
this.host = opts.host;
|
|
302
315
|
this.gridSnapEnabled = opts.gridSnap ?? false;
|
|
316
|
+
this.pageScroll = opts.pageScroll ?? false;
|
|
303
317
|
this.gridSize = this.normalizeGridSize(opts.gridSize ?? DEFAULT_GRID_SIZE);
|
|
304
318
|
this.history = new DiagramCommandHistory(() => {
|
|
305
319
|
// The gated values, not the raw stack: setInteractionPermissions and setReadOnly
|
|
@@ -311,9 +325,9 @@ export class Diagram {
|
|
|
311
325
|
this.canvas.style.display = 'block';
|
|
312
326
|
this.canvas.tabIndex = 0; // focusable → key events
|
|
313
327
|
this.canvas.style.outline = 'none';
|
|
314
|
-
// Eat browser-native touch gestures (page scroll / pinch-zoom) so
|
|
315
|
-
//
|
|
316
|
-
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();
|
|
317
331
|
// No OS text-selection on long-press (would pop the iOS magnifier
|
|
318
332
|
// and select node/link captions instead of firing our menu).
|
|
319
333
|
this.canvas.style.userSelect = 'none';
|
|
@@ -371,6 +385,17 @@ export class Diagram {
|
|
|
371
385
|
this.gridSize = nextSize;
|
|
372
386
|
this.scheduleDraw();
|
|
373
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
|
+
}
|
|
374
399
|
getGridSnap() {
|
|
375
400
|
return { enabled: this.gridSnapEnabled, size: this.gridSize };
|
|
376
401
|
}
|
|
@@ -604,7 +629,7 @@ export class Diagram {
|
|
|
604
629
|
if (!validation.allowed)
|
|
605
630
|
return false;
|
|
606
631
|
}
|
|
607
|
-
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 ?? '');
|
|
608
633
|
if (this.links.some((l) => l.id === link.id))
|
|
609
634
|
return false;
|
|
610
635
|
this.links.splice(clamp(Math.trunc(index), 0, this.links.length), 0, link);
|
|
@@ -924,6 +949,8 @@ export class Diagram {
|
|
|
924
949
|
return this.updateNodeState(nodeId, 'update node', (node) => {
|
|
925
950
|
if (patch.name !== undefined)
|
|
926
951
|
node.name = patch.name;
|
|
952
|
+
if (patch.subtitle !== undefined)
|
|
953
|
+
node.subtitle = patch.subtitle;
|
|
927
954
|
if (patch.description !== undefined)
|
|
928
955
|
node.description = patch.description;
|
|
929
956
|
if (patch.color !== undefined)
|
|
@@ -976,6 +1003,7 @@ export class Diagram {
|
|
|
976
1003
|
const restored = new NodeModel(snapshot, nodeId);
|
|
977
1004
|
node.typeId = restored.typeId;
|
|
978
1005
|
node.name = restored.name;
|
|
1006
|
+
node.subtitle = restored.subtitle;
|
|
979
1007
|
node.description = restored.description;
|
|
980
1008
|
node.groupName = restored.groupName;
|
|
981
1009
|
node.color = restored.color;
|
|
@@ -1009,6 +1037,10 @@ export class Diagram {
|
|
|
1009
1037
|
return true;
|
|
1010
1038
|
}
|
|
1011
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) {
|
|
1012
1044
|
this.clear();
|
|
1013
1045
|
this.idSeq = 1;
|
|
1014
1046
|
this.linkSeq = 1;
|
|
@@ -1046,10 +1078,11 @@ export class Diagram {
|
|
|
1046
1078
|
if (this.links.some((existing) => existing.id === id)) {
|
|
1047
1079
|
throw new Error(`ssdiagram: duplicate link id "${id}"`);
|
|
1048
1080
|
}
|
|
1049
|
-
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 ?? '');
|
|
1050
1082
|
if (!this.links.some((existing) => existing.key() === model.key()))
|
|
1051
1083
|
this.links.push(model);
|
|
1052
1084
|
}
|
|
1085
|
+
this.zones = zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
|
|
1053
1086
|
for (const node of this.nodes) {
|
|
1054
1087
|
if (node.loadError.length === 0)
|
|
1055
1088
|
continue;
|
|
@@ -1074,22 +1107,23 @@ export class Diagram {
|
|
|
1074
1107
|
to: l.to,
|
|
1075
1108
|
toPort: l.toPort,
|
|
1076
1109
|
style: l.style,
|
|
1110
|
+
...(l.label === '' ? {} : { label: l.label }),
|
|
1077
1111
|
metadata: copyJsonObject(l.metadata),
|
|
1078
1112
|
})),
|
|
1079
1113
|
};
|
|
1080
1114
|
}
|
|
1081
1115
|
loadDocument(source) {
|
|
1082
1116
|
const document = parseDiagramDocument(source);
|
|
1083
|
-
this.
|
|
1117
|
+
this.loadModel(document.nodes, document.links.map((link) => ({
|
|
1084
1118
|
id: link.id,
|
|
1085
1119
|
from: link.from.nodeId,
|
|
1086
1120
|
fromPort: link.from.portId,
|
|
1087
1121
|
to: link.to.nodeId,
|
|
1088
1122
|
toPort: link.to.portId,
|
|
1089
1123
|
style: link.style,
|
|
1124
|
+
label: link.label,
|
|
1090
1125
|
metadata: link.metadata,
|
|
1091
|
-
})));
|
|
1092
|
-
this.zones = document.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) }));
|
|
1126
|
+
})), document.zones);
|
|
1093
1127
|
this.documentMetadata = copyJsonObject(document.metadata);
|
|
1094
1128
|
}
|
|
1095
1129
|
saveDocument() {
|
|
@@ -1100,6 +1134,7 @@ export class Diagram {
|
|
|
1100
1134
|
from: { nodeId: link.from, portId: link.fromPort },
|
|
1101
1135
|
to: { nodeId: link.to, portId: link.toPort },
|
|
1102
1136
|
style: link.style,
|
|
1137
|
+
label: link.label,
|
|
1103
1138
|
metadata: link.metadata,
|
|
1104
1139
|
})),
|
|
1105
1140
|
zones: this.zones.map((zone) => ({ ...zone, metadata: copyJsonObject(zone.metadata) })),
|
|
@@ -1574,8 +1609,10 @@ export class Diagram {
|
|
|
1574
1609
|
this.opts.overviewViewportFill = t.overviewViewportFill;
|
|
1575
1610
|
this.scheduleDraw();
|
|
1576
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.
|
|
1577
1614
|
graphBounds() {
|
|
1578
|
-
if (this.nodes.length === 0)
|
|
1615
|
+
if (this.nodes.length === 0 && this.zones.length === 0)
|
|
1579
1616
|
return null;
|
|
1580
1617
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1581
1618
|
for (const n of this.nodes) {
|
|
@@ -1584,6 +1621,15 @@ export class Diagram {
|
|
|
1584
1621
|
maxX = Math.max(maxX, n.x + n.w);
|
|
1585
1622
|
maxY = Math.max(maxY, n.y + n.h);
|
|
1586
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
|
+
}
|
|
1587
1633
|
return { minX, minY, maxX, maxY };
|
|
1588
1634
|
}
|
|
1589
1635
|
resize(w, h) {
|
|
@@ -1640,12 +1686,17 @@ export class Diagram {
|
|
|
1640
1686
|
// ---- geometry ---------------------------------------------------
|
|
1641
1687
|
layoutNode(n) {
|
|
1642
1688
|
const ctx = this.ctx;
|
|
1643
|
-
ctx.font =
|
|
1689
|
+
ctx.font = TITLE_FONT;
|
|
1644
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
|
+
}
|
|
1645
1696
|
const rows = Math.max(n.inPorts.length, n.outPorts.length, 1);
|
|
1646
|
-
// Node sizing: min 170 wide, height grows by rows.
|
|
1647
|
-
n.w = Math.max(170, Math.ceil(titleW) + 28);
|
|
1648
|
-
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);
|
|
1649
1700
|
// Each side's port stack is vertically centred; the socket
|
|
1650
1701
|
// square sits OUTSIDE the body (its inner edge flush with the
|
|
1651
1702
|
// node border).
|
|
@@ -1691,6 +1742,15 @@ export class Diagram {
|
|
|
1691
1742
|
return p.type;
|
|
1692
1743
|
return this.portFeed.get(p) ?? p.type;
|
|
1693
1744
|
}
|
|
1745
|
+
/// A type's colour as a wire.
|
|
1746
|
+
///
|
|
1747
|
+
/// The same colour a socket of that type is filled with, moved into the band the canvas
|
|
1748
|
+
/// leaves room for. A socket can afford the host's exact colour because it is a filled
|
|
1749
|
+
/// square with an outline; a wire is a thin line with nothing behind it, and one drawn in
|
|
1750
|
+
/// #000000 on a dark canvas is not there at all.
|
|
1751
|
+
linkColor(type) {
|
|
1752
|
+
return legibleOn(this.portColor(type), this.opts.background ?? '#1b1b1f');
|
|
1753
|
+
}
|
|
1694
1754
|
portColor(type) {
|
|
1695
1755
|
const maxL = this.opts.linkMaxLightness;
|
|
1696
1756
|
const light = maxL !== undefined && maxL < 0.5;
|
|
@@ -2032,6 +2092,7 @@ export class Diagram {
|
|
|
2032
2092
|
// The clipboard is built from saveDocument(), so it carries the style;
|
|
2033
2093
|
// rebuilding without it silently turned every pasted dashed link solid.
|
|
2034
2094
|
style: l.style,
|
|
2095
|
+
label: l.label,
|
|
2035
2096
|
metadata: l.metadata,
|
|
2036
2097
|
});
|
|
2037
2098
|
}
|
|
@@ -2500,6 +2561,9 @@ export class Diagram {
|
|
|
2500
2561
|
this.listen(window, 'pointerup', finish);
|
|
2501
2562
|
this.listen(window, 'pointercancel', finish);
|
|
2502
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;
|
|
2503
2567
|
e.preventDefault();
|
|
2504
2568
|
const [sx, sy] = localXY(e);
|
|
2505
2569
|
const [wx, wy] = this.toWorld(sx, sy);
|
|
@@ -2672,6 +2736,7 @@ export class Diagram {
|
|
|
2672
2736
|
}
|
|
2673
2737
|
draw(options = SCREEN_RENDER_OPTIONS) {
|
|
2674
2738
|
const ctx = this.ctx;
|
|
2739
|
+
this.hostStyle = null;
|
|
2675
2740
|
ctx.clearRect(0, 0, this.width, this.height);
|
|
2676
2741
|
const background = this.opts.background ?? '#1b1b1f';
|
|
2677
2742
|
ctx.fillStyle = background;
|
|
@@ -2702,6 +2767,7 @@ export class Diagram {
|
|
|
2702
2767
|
// Links next. Each link jumps over the verticals of the links
|
|
2703
2768
|
// drawn before it (deterministic bridge — Link.JumpOver parity).
|
|
2704
2769
|
const prior = []; // segments of links already drawn
|
|
2770
|
+
const labels = [];
|
|
2705
2771
|
const hoveredNode = this.hoverPort?.node ?? this.hoverNode;
|
|
2706
2772
|
for (const l of this.links) {
|
|
2707
2773
|
const a = this.endpoint(l, 'from');
|
|
@@ -2710,7 +2776,7 @@ export class Diagram {
|
|
|
2710
2776
|
continue;
|
|
2711
2777
|
const fp = this.nodes.find((n) => n.id === l.from)?.outPorts.find((p) => p.id === l.fromPort);
|
|
2712
2778
|
const baseColor = (this.opts.typedLinkColors ?? false) && fp !== undefined
|
|
2713
|
-
? this.
|
|
2779
|
+
? this.linkColor(fp.type)
|
|
2714
2780
|
: this.portColor('');
|
|
2715
2781
|
const state = options.selection && l === this.selectedLink
|
|
2716
2782
|
? 'sel'
|
|
@@ -2723,6 +2789,8 @@ export class Diagram {
|
|
|
2723
2789
|
const pts = this.routeLink(a, b, new Set([l.from, l.to]));
|
|
2724
2790
|
this.strokeRoute(pts, color, width, prior, l.style);
|
|
2725
2791
|
this.drawArrow(pts, color);
|
|
2792
|
+
if (l.label.length > 0)
|
|
2793
|
+
labels.push({ pts, text: l.label, color });
|
|
2726
2794
|
for (let k = 1; k < pts.length; k += 1) {
|
|
2727
2795
|
const [x1, y1] = pts[k - 1];
|
|
2728
2796
|
const [x2, y2] = pts[k];
|
|
@@ -2732,6 +2800,9 @@ export class Diagram {
|
|
|
2732
2800
|
prior.push({ h: true, c: y1, a: Math.min(x1, x2), b: Math.max(x1, x2) });
|
|
2733
2801
|
}
|
|
2734
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);
|
|
2735
2806
|
if (options.transient && (this.linking !== null || this.relinking !== null))
|
|
2736
2807
|
this.drawPendingLink();
|
|
2737
2808
|
this.portFeed = this.feedTypes();
|
|
@@ -2899,8 +2970,9 @@ export class Diagram {
|
|
|
2899
2970
|
if (this.zones.length === 0)
|
|
2900
2971
|
return;
|
|
2901
2972
|
const ctx = this.ctx;
|
|
2973
|
+
const defaultTint = this.resolveColor(this.opts.zoneColor ?? DEFAULT_ZONE_TINT, DEFAULT_ZONE_TINT);
|
|
2902
2974
|
for (const zone of this.zones) {
|
|
2903
|
-
const tint = zone.color.length > 0 ? zone.color :
|
|
2975
|
+
const tint = zone.color.length > 0 ? this.resolveColor(zone.color, defaultTint) : defaultTint;
|
|
2904
2976
|
ctx.save();
|
|
2905
2977
|
roundRect(ctx, zone.x, zone.y, zone.width, zone.height, 10);
|
|
2906
2978
|
ctx.globalAlpha = 0.12;
|
|
@@ -2917,12 +2989,12 @@ export class Diagram {
|
|
|
2917
2989
|
ctx.save();
|
|
2918
2990
|
ctx.globalAlpha = 0.75;
|
|
2919
2991
|
ctx.fillStyle = tint;
|
|
2920
|
-
ctx.font =
|
|
2992
|
+
ctx.font = TITLE_FONT;
|
|
2921
2993
|
ctx.textAlign = 'left';
|
|
2922
2994
|
ctx.textBaseline = 'top';
|
|
2923
2995
|
// Inside the top-left corner: a caption outside the box would collide with whatever
|
|
2924
2996
|
// sits above it, and the corner is the one spot a rectangle always has to spare.
|
|
2925
|
-
ctx.fillText(zone.name, zone.x + 12, zone.y +
|
|
2997
|
+
ctx.fillText(zone.name, zone.x + 12, zone.y + ZONE_CAPTION_TOP, Math.max(0, zone.width - 24));
|
|
2926
2998
|
ctx.restore();
|
|
2927
2999
|
}
|
|
2928
3000
|
}
|
|
@@ -2965,11 +3037,12 @@ export class Diagram {
|
|
|
2965
3037
|
const runtime = options.runtime ? getJsonKey(this.runtimeState.nodes, n.id) : undefined;
|
|
2966
3038
|
const active = options.runtime
|
|
2967
3039
|
&& (this.runtimeState.activeNodeId === n.id || runtime?.active === true);
|
|
3040
|
+
const fill = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
|
|
2968
3041
|
roundRect(ctx, n.x, n.y, n.w, n.h, 6);
|
|
2969
|
-
ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' :
|
|
3042
|
+
ctx.fillStyle = hasLoadError ? ERROR_BACKGROUND : active ? '#ffd1dc' : fill;
|
|
2970
3043
|
ctx.fill();
|
|
2971
3044
|
ctx.lineWidth = selected ? 2 : 1.5;
|
|
2972
|
-
ctx.strokeStyle = selected ? this.selectionColor() : n.border;
|
|
3045
|
+
ctx.strokeStyle = selected ? this.selectionColor() : this.resolveColor(n.border, DEFAULT_NODE_BORDER);
|
|
2973
3046
|
ctx.stroke();
|
|
2974
3047
|
if (hasLoadError || hasRuntimeError) {
|
|
2975
3048
|
let alpha = 1;
|
|
@@ -3007,13 +3080,26 @@ export class Diagram {
|
|
|
3007
3080
|
// takes its colour from the fill it sits on: that fill comes from the
|
|
3008
3081
|
// scheme, so a node may well be darker than the title used to assume.
|
|
3009
3082
|
const titleShift = n.icon ? iconW + 4 : 0;
|
|
3083
|
+
const textX = n.x + titleShift + (n.w - titleShift) / 2;
|
|
3084
|
+
const textWidth = n.w - titleShift - 14;
|
|
3010
3085
|
ctx.fillStyle = hasLoadError
|
|
3011
3086
|
? '#ffffff'
|
|
3012
|
-
: readableTextOn(active ? '#ffd1dc' :
|
|
3013
|
-
ctx.font =
|
|
3087
|
+
: readableTextOn(active ? '#ffd1dc' : fill);
|
|
3088
|
+
ctx.font = TITLE_FONT;
|
|
3014
3089
|
ctx.textAlign = 'center';
|
|
3015
3090
|
ctx.textBaseline = 'middle';
|
|
3016
|
-
|
|
3091
|
+
if (n.subtitle.length === 0) {
|
|
3092
|
+
ctx.fillText(n.name, textX, n.y + n.h / 2, textWidth);
|
|
3093
|
+
}
|
|
3094
|
+
else {
|
|
3095
|
+
// Two lines centred as a pair: the name above, the subtitle smaller and muted below.
|
|
3096
|
+
ctx.fillText(n.name, textX, n.y + n.h / 2 - 8, textWidth);
|
|
3097
|
+
ctx.save();
|
|
3098
|
+
ctx.globalAlpha *= 0.72;
|
|
3099
|
+
ctx.font = SUBTITLE_FONT;
|
|
3100
|
+
ctx.fillText(n.subtitle, textX, n.y + n.h / 2 + 9, textWidth);
|
|
3101
|
+
ctx.restore();
|
|
3102
|
+
}
|
|
3017
3103
|
for (const p of n.inPorts)
|
|
3018
3104
|
this.drawPort(n, p, options);
|
|
3019
3105
|
for (const p of n.outPorts)
|
|
@@ -3157,6 +3243,48 @@ export class Diagram {
|
|
|
3157
3243
|
ctx.fill();
|
|
3158
3244
|
ctx.restore();
|
|
3159
3245
|
}
|
|
3246
|
+
// A plate of the canvas colour under the text, edged in the wire's colour: the label reads the same
|
|
3247
|
+
// whatever the wire crosses, and still says which wire it belongs to.
|
|
3248
|
+
drawLinkLabel(pts, text, color, background) {
|
|
3249
|
+
const [x, y] = pathMidpoint(pts);
|
|
3250
|
+
const ctx = this.ctx;
|
|
3251
|
+
ctx.save();
|
|
3252
|
+
ctx.font = LINK_LABEL_FONT;
|
|
3253
|
+
const w = Math.ceil(ctx.measureText(text).width) + 12;
|
|
3254
|
+
const h = 16;
|
|
3255
|
+
roundRect(ctx, x - w / 2, y - h / 2, w, h, h / 2);
|
|
3256
|
+
ctx.fillStyle = background;
|
|
3257
|
+
ctx.fill();
|
|
3258
|
+
ctx.lineWidth = 1;
|
|
3259
|
+
ctx.strokeStyle = color;
|
|
3260
|
+
ctx.stroke();
|
|
3261
|
+
ctx.fillStyle = readableTextOn(background);
|
|
3262
|
+
ctx.textAlign = 'center';
|
|
3263
|
+
ctx.textBaseline = 'middle';
|
|
3264
|
+
ctx.fillText(text, x, y);
|
|
3265
|
+
ctx.restore();
|
|
3266
|
+
}
|
|
3267
|
+
// Scheme colours may name a custom property of the page, var(--token), which a canvas cannot
|
|
3268
|
+
// paint. They resolve against the host element; one that resolves to nothing and has no
|
|
3269
|
+
// fallback of its own gets the renderer's default for that slot.
|
|
3270
|
+
resolveColor(value, fallback) {
|
|
3271
|
+
if (!value.includes('var('))
|
|
3272
|
+
return value;
|
|
3273
|
+
return resolveCssVariables(value, (name) => this.hostProperty(name)) ?? fallback;
|
|
3274
|
+
}
|
|
3275
|
+
hostProperty(name) {
|
|
3276
|
+
if (this.hostStyle === null) {
|
|
3277
|
+
if (typeof getComputedStyle !== 'function')
|
|
3278
|
+
return '';
|
|
3279
|
+
try {
|
|
3280
|
+
this.hostStyle = getComputedStyle(this.host);
|
|
3281
|
+
}
|
|
3282
|
+
catch {
|
|
3283
|
+
return '';
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
return this.hostStyle.getPropertyValue(name);
|
|
3287
|
+
}
|
|
3160
3288
|
drawSelectedLinkEndpoints() {
|
|
3161
3289
|
if (this.selectedLink === null)
|
|
3162
3290
|
return;
|
|
@@ -3225,7 +3353,7 @@ export class Diagram {
|
|
|
3225
3353
|
if (snapped !== null)
|
|
3226
3354
|
excludedNodes.add(snapped.node.id);
|
|
3227
3355
|
const points = this.routeLink(a, b, excludedNodes);
|
|
3228
|
-
ctx.strokeStyle = this.
|
|
3356
|
+
ctx.strokeStyle = this.linkColor(colorType);
|
|
3229
3357
|
if (snapped !== null) {
|
|
3230
3358
|
ctx.setLineDash([]);
|
|
3231
3359
|
ctx.lineWidth = 2.6;
|
|
@@ -3290,9 +3418,9 @@ export class Diagram {
|
|
|
3290
3418
|
ctx.stroke();
|
|
3291
3419
|
ctx.clip();
|
|
3292
3420
|
for (const n of this.nodes) {
|
|
3293
|
-
ctx.fillStyle = n.color;
|
|
3421
|
+
ctx.fillStyle = this.resolveColor(n.color, DEFAULT_NODE_COLOR);
|
|
3294
3422
|
ctx.fillRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
|
|
3295
|
-
ctx.strokeStyle = n.border;
|
|
3423
|
+
ctx.strokeStyle = this.resolveColor(n.border, DEFAULT_NODE_BORDER);
|
|
3296
3424
|
ctx.lineWidth = 1;
|
|
3297
3425
|
ctx.strokeRect(mx(n.x), my(n.y), Math.max(2, n.w * s), Math.max(2, n.h * s));
|
|
3298
3426
|
}
|
|
@@ -3340,6 +3468,25 @@ function roundRect(ctx, x, y, w, h, r) {
|
|
|
3340
3468
|
// a 3-segment mid-X elbow. Backward/too-close: a 5-segment detour that
|
|
3341
3469
|
// leaves the source rightward and re-enters the target from the left
|
|
3342
3470
|
// (AvoidsNodes-style, never a diagonal).
|
|
3471
|
+
/** The point halfway along a polyline, measured by length rather than by vertex count. */
|
|
3472
|
+
function pathMidpoint(pts) {
|
|
3473
|
+
let total = 0;
|
|
3474
|
+
for (let i = 1; i < pts.length; i += 1)
|
|
3475
|
+
total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
|
|
3476
|
+
let remaining = total / 2;
|
|
3477
|
+
for (let i = 1; i < pts.length; i += 1) {
|
|
3478
|
+
const [x1, y1] = pts[i - 1];
|
|
3479
|
+
const [x2, y2] = pts[i];
|
|
3480
|
+
const length = Math.hypot(x2 - x1, y2 - y1);
|
|
3481
|
+
if (length > 0 && remaining <= length) {
|
|
3482
|
+
const t = remaining / length;
|
|
3483
|
+
return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t];
|
|
3484
|
+
}
|
|
3485
|
+
remaining -= length;
|
|
3486
|
+
}
|
|
3487
|
+
const last = pts[pts.length - 1];
|
|
3488
|
+
return [last[0], last[1]];
|
|
3489
|
+
}
|
|
3343
3490
|
function route(a, b) {
|
|
3344
3491
|
const [ax, ay] = a;
|
|
3345
3492
|
const [bx, by] = b;
|