@takodotid/circuit 0.43.1 → 1.0.0-rc.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/README.md +115 -37
- package/package.json +21 -58
- package/src/checks/across.ts +402 -0
- package/src/checks/built-in.ts +256 -0
- package/src/checks/device.ts +436 -0
- package/src/checks/index.ts +53 -0
- package/src/cli/advisories.ts +108 -0
- package/src/cli/app.ts +71 -0
- package/src/cli/editor-types.generated.ts +30 -0
- package/src/cli/editor-types.ts +69 -0
- package/src/cli/format.ts +124 -0
- package/src/cli/git-hooks.ts +79 -0
- package/src/cli/index.ts +350 -1170
- package/src/cli/migrate.ts +554 -0
- package/src/cli/project-commands.ts +656 -0
- package/src/cli/releases.ts +374 -0
- package/src/cli/remember.ts +339 -0
- package/src/cli/rename.ts +27 -33
- package/src/cli/secrets-password.ts +60 -0
- package/src/cli/secrets.ts +258 -0
- package/src/cli/setup.ts +300 -0
- package/src/cli/signing.ts +23 -0
- package/src/cli/text-editor.ts +268 -0
- package/src/cli/ui.ts +298 -0
- package/src/cli/version.ts +2 -0
- package/src/cli/versions.ts +158 -0
- package/src/config/address.ts +102 -0
- package/src/config/circuit.d.ts +30 -0
- package/src/config/common.ts +65 -0
- package/src/config/communities.ts +479 -0
- package/src/config/define.ts +684 -0
- package/src/config/device.ts +164 -0
- package/src/config/firewall.ts +150 -0
- package/src/config/index.ts +22 -0
- package/src/{schema → config}/interfaces.ts +121 -85
- package/src/config/keys.ts +434 -0
- package/src/config/management.ts +61 -0
- package/src/config/merge.ts +335 -0
- package/src/config/models.ts +70 -0
- package/src/config/monitoring.ts +64 -0
- package/src/config/network.ts +180 -0
- package/src/config/presets.ts +149 -0
- package/src/config/routing.ts +401 -0
- package/src/config/services.ts +51 -0
- package/src/config/switching.ts +84 -0
- package/src/config/system.ts +103 -0
- package/src/{schema → config}/time-zones.ts +1 -1
- package/src/core/address.ts +1 -1
- package/src/core/cabling.ts +8 -43
- package/src/core/graph.ts +40 -0
- package/src/{integrations → core}/registries.ts +5 -3
- package/src/core/secrets-file.ts +539 -0
- package/src/core/secrets.ts +83 -64
- package/src/core/topology.ts +103 -146
- package/src/core/utils.ts +12 -1
- package/src/model.ts +83 -0
- package/src/platforms/derive.ts +64 -37
- package/src/platforms/finding.ts +9 -0
- package/src/platforms/fortios/firewall.ts +76 -64
- package/src/platforms/fortios/index.ts +189 -125
- package/src/platforms/fortios/names.ts +51 -18
- package/src/platforms/fortios/render.ts +69 -62
- package/src/platforms/fortios/routing.ts +89 -58
- package/src/platforms/fortios/vdoms.ts +47 -16
- package/src/platforms/junos/TRAPS.md +2 -2
- package/src/platforms/junos/bgp.ts +63 -42
- package/src/platforms/junos/index.ts +122 -89
- package/src/platforms/junos/render.ts +139 -104
- package/src/platforms/platform.ts +10 -8
- package/src/platforms/raisecom-ros/TRAPS.md +1 -1
- package/src/platforms/raisecom-ros/index.ts +89 -70
- package/src/platforms/raisecom-ros/render.ts +49 -45
- package/src/platforms/routeros/TRAPS.md +3 -3
- package/src/platforms/routeros/index.ts +121 -60
- package/src/platforms/routeros/render.ts +214 -195
- package/src/platforms/vrp/TRAPS.md +1 -1
- package/src/platforms/vrp/index.ts +74 -50
- package/src/platforms/vrp/render.ts +67 -56
- package/src/project/project.ts +725 -0
- package/src/studio/app/env.d.ts +9 -0
- package/src/studio/app/index.html +18 -0
- package/src/studio/app/src/App.vue +95 -0
- package/src/studio/app/src/components/AddressesView.vue +38 -0
- package/src/studio/app/src/components/CablesView.vue +29 -0
- package/src/studio/app/src/components/CompareView.vue +160 -0
- package/src/studio/app/src/components/DataTable.vue +49 -0
- package/src/studio/app/src/components/DeviceView.vue +237 -0
- package/src/studio/app/src/components/DevicesView.vue +32 -0
- package/src/studio/app/src/components/Faceplate.vue +75 -0
- package/src/studio/app/src/components/GraphView.vue +282 -0
- package/src/studio/app/src/components/LineDiff.vue +24 -0
- package/src/studio/app/src/components/ProblemsView.vue +42 -0
- package/src/studio/app/src/components/SearchView.vue +72 -0
- package/src/studio/app/src/components/SecretsView.vue +112 -0
- package/src/studio/app/src/components/SessionsView.vue +35 -0
- package/src/studio/app/src/components/Settings.vue +95 -0
- package/src/studio/app/src/components/ToolsView.vue +118 -0
- package/src/studio/app/src/components/VlansView.vue +50 -0
- package/src/studio/app/src/lib/csv.ts +19 -0
- package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
- package/src/studio/app/src/lib/format.ts +38 -0
- package/src/studio/app/src/lib/net.ts +68 -0
- package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
- package/src/studio/app/src/lib/store.ts +71 -0
- package/src/studio/app/src/lib/tools.ts +217 -0
- package/src/studio/app/src/main.ts +9 -0
- package/src/studio/{public → app/src}/style.css +14 -4
- package/src/studio/app/tsconfig.json +17 -0
- package/src/studio/app/vite.config.ts +16 -0
- package/src/studio/assets.generated.ts +3 -0
- package/src/studio/data.ts +96 -188
- package/src/studio/git.ts +27 -43
- package/src/studio/server.ts +196 -107
- package/src/studio/types.ts +141 -0
- package/src/transport/console.ts +3 -3
- package/src/transport/ssh.ts +1 -1
- package/dist/chunks/index-3b343xva.js +0 -177
- package/dist/chunks/index-6wb41q67.js +0 -62
- package/dist/chunks/index-n7w3tg5y.js +0 -149
- package/dist/chunks/index-yzw86rc8.js +0 -921
- package/dist/chunks/new-w3zys5y4.js +0 -172
- package/dist/chunks/server-cxr9ynxc.js +0 -259
- package/dist/cli/index.js +0 -12922
- package/dist/helpers/index.js +0 -881
- package/dist/index.js +0 -28
- package/dist/types/core/address-space.d.ts +0 -62
- package/dist/types/core/address.d.ts +0 -44
- package/dist/types/core/cabling.d.ts +0 -41
- package/dist/types/core/cross-checks.d.ts +0 -28
- package/dist/types/core/define.d.ts +0 -79
- package/dist/types/core/project-files.d.ts +0 -12
- package/dist/types/core/schema-docs.d.ts +0 -25
- package/dist/types/core/secrets.d.ts +0 -62
- package/dist/types/core/topology.d.ts +0 -96
- package/dist/types/core/utils.d.ts +0 -10
- package/dist/types/core/validator.d.ts +0 -23
- package/dist/types/helpers/bgp.d.ts +0 -195
- package/dist/types/helpers/cables.d.ts +0 -69
- package/dist/types/helpers/checks.d.ts +0 -36
- package/dist/types/helpers/exchange.d.ts +0 -55
- package/dist/types/helpers/filters.d.ts +0 -51
- package/dist/types/helpers/index.d.ts +0 -14
- package/dist/types/helpers/route-server.d.ts +0 -52
- package/dist/types/helpers/upstreams.d.ts +0 -192
- package/dist/types/index.d.ts +0 -10
- package/dist/types/integrations/librenms.d.ts +0 -60
- package/dist/types/integrations/netbox.d.ts +0 -89
- package/dist/types/integrations/peeringdb.d.ts +0 -24
- package/dist/types/integrations/prometheus.d.ts +0 -24
- package/dist/types/integrations/registries.d.ts +0 -8
- package/dist/types/platforms/bgp-wait.d.ts +0 -24
- package/dist/types/platforms/catalog.d.ts +0 -109
- package/dist/types/platforms/derive.d.ts +0 -17
- package/dist/types/platforms/fortios/firewall.d.ts +0 -47
- package/dist/types/platforms/fortios/index.d.ts +0 -140
- package/dist/types/platforms/fortios/models.d.ts +0 -14
- package/dist/types/platforms/fortios/names.d.ts +0 -52
- package/dist/types/platforms/fortios/plan.d.ts +0 -114
- package/dist/types/platforms/fortios/render.d.ts +0 -77
- package/dist/types/platforms/fortios/routing.d.ts +0 -56
- package/dist/types/platforms/fortios/tree.d.ts +0 -37
- package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
- package/dist/types/platforms/index.d.ts +0 -13
- package/dist/types/platforms/junos/bgp.d.ts +0 -44
- package/dist/types/platforms/junos/index.d.ts +0 -114
- package/dist/types/platforms/junos/models.d.ts +0 -14
- package/dist/types/platforms/junos/render.d.ts +0 -154
- package/dist/types/platforms/line-diff.d.ts +0 -67
- package/dist/types/platforms/platform.d.ts +0 -135
- package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
- package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
- package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
- package/dist/types/platforms/routeros/export.d.ts +0 -59
- package/dist/types/platforms/routeros/index.d.ts +0 -116
- package/dist/types/platforms/routeros/models.d.ts +0 -18
- package/dist/types/platforms/routeros/plan.d.ts +0 -58
- package/dist/types/platforms/routeros/render.d.ts +0 -250
- package/dist/types/platforms/vrp/index.d.ts +0 -83
- package/dist/types/platforms/vrp/models.d.ts +0 -12
- package/dist/types/platforms/vrp/render.d.ts +0 -96
- package/dist/types/schema/common.d.ts +0 -30
- package/dist/types/schema/device.d.ts +0 -126
- package/dist/types/schema/firewall.d.ts +0 -131
- package/dist/types/schema/index.d.ts +0 -8
- package/dist/types/schema/interfaces.d.ts +0 -205
- package/dist/types/schema/policy.d.ts +0 -107
- package/dist/types/schema/routing.d.ts +0 -161
- package/dist/types/schema/system.d.ts +0 -198
- package/dist/types/schema/time-zones.d.ts +0 -2
- package/dist/types/transport/console.d.ts +0 -115
- package/dist/types/transport/path-check.d.ts +0 -32
- package/dist/types/transport/ssh.d.ts +0 -27
- package/src/cli/graph-format.ts +0 -91
- package/src/cli/new.ts +0 -234
- package/src/core/address-space.ts +0 -320
- package/src/core/cross-checks.ts +0 -369
- package/src/core/define.ts +0 -124
- package/src/core/project-files.ts +0 -35
- package/src/core/schema-docs.ts +0 -174
- package/src/core/validator.ts +0 -508
- package/src/helpers/bgp.ts +0 -473
- package/src/helpers/cables.ts +0 -74
- package/src/helpers/checks.ts +0 -201
- package/src/helpers/exchange.ts +0 -123
- package/src/helpers/filters.ts +0 -107
- package/src/helpers/index.ts +0 -25
- package/src/helpers/route-server.ts +0 -212
- package/src/helpers/upstreams.ts +0 -369
- package/src/index.ts +0 -13
- package/src/integrations/librenms.ts +0 -291
- package/src/integrations/netbox.ts +0 -620
- package/src/integrations/peeringdb.ts +0 -173
- package/src/integrations/prometheus.ts +0 -90
- package/src/schema/common.ts +0 -41
- package/src/schema/device.ts +0 -145
- package/src/schema/firewall.ts +0 -140
- package/src/schema/index.ts +0 -8
- package/src/schema/policy.ts +0 -115
- package/src/schema/routing.ts +0 -173
- package/src/schema/system.ts +0 -203
- package/src/studio/public/app.js +0 -186
- package/src/studio/public/compare.js +0 -250
- package/src/studio/public/dom.js +0 -138
- package/src/studio/public/evaluate.js +0 -687
- package/src/studio/public/export.js +0 -130
- package/src/studio/public/graph.js +0 -1541
- package/src/studio/public/index.html +0 -83
- package/src/studio/public/net.js +0 -134
- package/src/studio/public/tables.js +0 -1289
- package/src/studio/public/tools.js +0 -610
- package/templates/colocation/checks.ts +0 -53
- package/templates/colocation/circuit.config.ts +0 -9
- package/templates/colocation/edge-01.ts +0 -133
- package/templates/colocation/routing.ts +0 -13
- package/templates/colocation/tenancy.ts +0 -76
- package/templates/colocation/tenants/acme.ts +0 -19
- package/templates/colocation/tenants/index.ts +0 -6
- package/templates/colocation/tor-01.ts +0 -33
- package/templates/edge-router/checks.ts +0 -20
- package/templates/edge-router/circuit.config.ts +0 -8
- package/templates/edge-router/edge-01.ts +0 -110
- package/templates/edge-router/routing.ts +0 -13
- package/templates/project/AGENTS.md +0 -51
- package/templates/project/CLAUDE.md +0 -1
- package/templates/project/README.md +0 -16
- package/templates/project/gitignore +0 -5
- package/templates/project/tsconfig.json +0 -13
- package/templates/single-site/checks.ts +0 -26
- package/templates/single-site/circuit.config.ts +0 -8
- package/templates/single-site/router.ts +0 -86
- package/templates/single-site/site.ts +0 -18
- package/templates/single-site/switch.ts +0 -34
- /package/src/studio/{public → app/public}/favicon.svg +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
|
@@ -1,1541 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/// <reference lib="dom" />
|
|
3
|
-
// The graph view: a force layout of every node, drawn one layer at a time, with panning, zooming, dragging, and a side panel for what is selected.
|
|
4
|
-
|
|
5
|
-
import { Dom } from "./dom.js";
|
|
6
|
-
import { Evaluate } from "./evaluate.js";
|
|
7
|
-
import { Export } from "./export.js";
|
|
8
|
-
|
|
9
|
-
/** @typedef {import("../../core/topology").GraphNode} GraphNode */
|
|
10
|
-
/** @typedef {import("../../core/topology").GraphEdge} GraphEdge */
|
|
11
|
-
/** @typedef {import("../../core/topology").Layer} Layer */
|
|
12
|
-
/** @typedef {import("../../core/topology").CarriedVlan} CarriedVlan */
|
|
13
|
-
/** @typedef {import("../../core/topology").Annotated} Annotated */
|
|
14
|
-
/** @typedef {{ x: number, y: number }} Point */
|
|
15
|
-
/** An edge as drawn: one of the graph's, or several between the same two nodes drawn as one, or one a comparison shows is gone. @typedef {GraphEdge & { members?: DrawnEdge[], gone?: true }} DrawnEdge */
|
|
16
|
-
/** @typedef {{ node: string } | { edge: DrawnEdge } | undefined} Selection */
|
|
17
|
-
/** @typedef {{ x: number, y: number, k: number }} View */
|
|
18
|
-
|
|
19
|
-
export class Graph {
|
|
20
|
-
/** @type {Record<Layer, string>} */
|
|
21
|
-
static LAYER_NAMES = {
|
|
22
|
-
l1: "Cables",
|
|
23
|
-
l2: "VLANs",
|
|
24
|
-
l3: "Addresses",
|
|
25
|
-
bgp: "BGP",
|
|
26
|
-
ospf: "OSPF",
|
|
27
|
-
tunnels: "Tunnels",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
/** @type {Record<Layer, string>} */
|
|
31
|
-
static #LAYER_HELP = {
|
|
32
|
-
l1: "Each cable a port's link names. A LAG's cables are one line.",
|
|
33
|
-
l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
|
|
34
|
-
l3: "Prefixes two or more devices hold an address in, and point-to-point addresses. Loopbacks and prefixes only one device holds are left out.",
|
|
35
|
-
bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
|
|
36
|
-
ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
|
|
37
|
-
tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers.",
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
static LAYERS = /** @type {Layer[]} */ (Object.keys(Graph.LAYER_NAMES));
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {string | undefined} name
|
|
44
|
-
* @returns {name is Layer}
|
|
45
|
-
*/
|
|
46
|
-
static isLayer(name) {
|
|
47
|
-
return Graph.LAYERS.includes(/** @type {Layer} */ (name));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// The parts of index.html this view draws into.
|
|
51
|
-
static #graphView = Dom.byId("graph-view");
|
|
52
|
-
static #layersNav = Dom.byId("layers");
|
|
53
|
-
static #svg = Dom.svgById("canvas");
|
|
54
|
-
static #viewport = Dom.svgById("viewport");
|
|
55
|
-
static #edgeLayer = Dom.svgById("edges");
|
|
56
|
-
static #nodeLayer = Dom.svgById("nodes");
|
|
57
|
-
static #panel = Dom.byId("panel");
|
|
58
|
-
static #presentButton = Dom.byId("present");
|
|
59
|
-
static #caption = Dom.byId("present-caption");
|
|
60
|
-
|
|
61
|
-
// State.
|
|
62
|
-
/** @type {Annotated | undefined} */
|
|
63
|
-
static #data;
|
|
64
|
-
/** @type {Layer} */
|
|
65
|
-
static #layer = Graph.LAYERS.find((name) => name === Dom.store.get("layer")) ?? "l1";
|
|
66
|
-
/** The graph has been drawn while visible at least once; until then it is fitted when shown. */
|
|
67
|
-
static #drawn = false;
|
|
68
|
-
/** @type {Selection} */
|
|
69
|
-
static #selection;
|
|
70
|
-
/** @type {string | undefined} */
|
|
71
|
-
static #hovered;
|
|
72
|
-
/** @type {number | undefined} */
|
|
73
|
-
static #vlanFocus;
|
|
74
|
-
/** A VLAN to light up once the VLAN layer is shown, asked for from the tables. @type {number | undefined} */
|
|
75
|
-
static #pendingVlan;
|
|
76
|
-
/** Cables to light up as one path, asked for from the tools, and those lit now. @type {GraphEdge[] | undefined} */
|
|
77
|
-
static #pendingPath;
|
|
78
|
-
/** @type {Set<string> | undefined} */
|
|
79
|
-
static #pathFocus;
|
|
80
|
-
/** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
|
|
81
|
-
static #comparison;
|
|
82
|
-
/** Presenting: the network on a big screen, going through every layer and lighting its busiest devices in turn. Kept, so a screen that reloads stays on it. */
|
|
83
|
-
static #presenting = Dom.store.get("present") === true;
|
|
84
|
-
/** The device lit while presenting; none while the whole layer is shown. @type {string | undefined} */
|
|
85
|
-
static #touring;
|
|
86
|
-
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
87
|
-
static #tour;
|
|
88
|
-
|
|
89
|
-
/** @param {GraphEdge} edge */
|
|
90
|
-
static #edgeKey(edge) {
|
|
91
|
-
return `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
static #view = { x: 0, y: 0, k: 1 };
|
|
95
|
-
/** Where the reader left each layer's view, as each layer is a drawing of its own. @type {Map<Layer, View>} */
|
|
96
|
-
static #views = new Map();
|
|
97
|
-
|
|
98
|
-
/** @typedef {Record<string, [number, number]>} SavedPlaces */
|
|
99
|
-
|
|
100
|
-
static #saved = /** @type {Partial<Record<Layer, SavedPlaces>>} */ (Dom.store.get("layouts") ?? {});
|
|
101
|
-
// The one layout an earlier Studio kept for every layer carries on as the cables layer's.
|
|
102
|
-
static #earlier = /** @type {SavedPlaces | undefined} */ (Dom.store.get("positions"));
|
|
103
|
-
|
|
104
|
-
/** Where each node is on each layer: every layer is laid out for its own lines, and keeps where its devices were moved. @type {Map<Layer, Map<string, Point>>} */
|
|
105
|
-
static #layouts = new Map(
|
|
106
|
-
Graph.LAYERS.map((name) => {
|
|
107
|
-
const places = Graph.#saved[name] ?? (name === "l1" ? Graph.#earlier : undefined) ?? {};
|
|
108
|
-
return [name, new Map(Object.entries(places).map(([id, [x, y]]) => [id, { x, y }]))];
|
|
109
|
-
})
|
|
110
|
-
);
|
|
111
|
-
|
|
112
|
-
/** @param {Layer} name */
|
|
113
|
-
static #layoutOf(name) {
|
|
114
|
-
return /** @type {Map<string, Point>} */ (Graph.#layouts.get(name));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Where nodes are drawn now: the layer's own places, or, while the layer changes, the places on the way there. */
|
|
118
|
-
static #positions = Graph.#layoutOf(Graph.#layer);
|
|
119
|
-
|
|
120
|
-
/** @type {Map<string, SVGGElement>} */
|
|
121
|
-
static #nodeElements = new Map();
|
|
122
|
-
/** @type {{ edge: DrawnEdge, group: SVGGElement, path: SVGPathElement, hit: SVGPathElement, label: SVGTextElement, offset: number }[]} */
|
|
123
|
-
static #edgeElements = [];
|
|
124
|
-
|
|
125
|
-
static #savePositions() {
|
|
126
|
-
/** @param {Map<string, Point>} places */
|
|
127
|
-
const rounded = (places) => Object.fromEntries([...places].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]]));
|
|
128
|
-
Dom.store.set("layouts", Object.fromEntries([...Graph.#layouts].map(([name, places]) => [name, rounded(places)])));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* A number from 0 to 1 that is always the same for the same text, so a layout comes out the same each time.
|
|
133
|
-
*
|
|
134
|
-
* @param {string} text
|
|
135
|
-
*/
|
|
136
|
-
static #hash(text) {
|
|
137
|
-
let value = 2166136261;
|
|
138
|
-
for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
|
|
139
|
-
return (value >>> 0) / 4294967296;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Place one layer's nodes with a force layout: every node pushes every other away, and each of the layer's edges pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
144
|
-
* A node new to a layer starts where it is on the cables layer, so each layer is laid out for its own lines yet still looks like the same network.
|
|
145
|
-
*
|
|
146
|
-
* @param {GraphNode[]} nodes
|
|
147
|
-
* @param {GraphEdge[]} edges
|
|
148
|
-
* @param {boolean} fresh
|
|
149
|
-
* @param {Map<string, Point>} places the layer's places, filled in
|
|
150
|
-
* @param {Map<string, Point>} [seed] places to start new nodes from
|
|
151
|
-
*/
|
|
152
|
-
static #layout(nodes, edges, fresh, places, seed) {
|
|
153
|
-
if (fresh) places.clear();
|
|
154
|
-
|
|
155
|
-
/** @param {GraphNode} node */
|
|
156
|
-
const halfWidth = (node) =>
|
|
157
|
-
node.kind === "device"
|
|
158
|
-
? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60
|
|
159
|
-
: node.label.length * 4 + 30;
|
|
160
|
-
|
|
161
|
-
const fixed = new Set(nodes.filter((node) => places.has(node.id)).map((node) => node.id));
|
|
162
|
-
/** @type {Map<string, Set<string>>} */
|
|
163
|
-
const neighbors = new Map();
|
|
164
|
-
|
|
165
|
-
for (const edge of edges) {
|
|
166
|
-
if (edge.from === edge.to) continue;
|
|
167
|
-
if (!neighbors.has(edge.from)) neighbors.set(edge.from, new Set());
|
|
168
|
-
if (!neighbors.has(edge.to)) neighbors.set(edge.to, new Set());
|
|
169
|
-
neighbors.get(edge.from)?.add(edge.to);
|
|
170
|
-
neighbors.get(edge.to)?.add(edge.from);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// A device with no line on this layer is set in a row under the drawing, rather than pushed out to its edges by the rest.
|
|
174
|
-
const loose = edges.length ? nodes.filter((node) => !neighbors.has(node.id) && !places.has(node.id)) : [];
|
|
175
|
-
const linked = nodes.filter((node) => !loose.includes(node));
|
|
176
|
-
|
|
177
|
-
function park() {
|
|
178
|
-
if (!loose.length) return;
|
|
179
|
-
|
|
180
|
-
const placed = linked.flatMap((node) => places.get(node.id) ?? []);
|
|
181
|
-
const left = placed.length ? Math.min(...placed.map((point) => point.x)) : 0;
|
|
182
|
-
const right = left + Math.max(placed.length ? Math.max(...placed.map((point) => point.x)) - left : 0, 900);
|
|
183
|
-
let x = left;
|
|
184
|
-
let y = (placed.length ? Math.max(...placed.map((point) => point.y)) : 0) + 130;
|
|
185
|
-
|
|
186
|
-
for (const node of [...loose].sort((a, b) => a.label.localeCompare(b.label))) {
|
|
187
|
-
const half = halfWidth(node);
|
|
188
|
-
if (x > left && x + half * 2 > right) {
|
|
189
|
-
x = left;
|
|
190
|
-
y += 96;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
places.set(node.id, { x: x + half, y });
|
|
194
|
-
x += half * 2 + 24;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
Graph.#savePositions();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
nodes = linked;
|
|
201
|
-
|
|
202
|
-
// A new node starts beside a neighbor already placed, or on a circle.
|
|
203
|
-
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
204
|
-
|
|
205
|
-
for (const node of nodes) {
|
|
206
|
-
if (places.has(node.id)) continue;
|
|
207
|
-
|
|
208
|
-
const start = seed?.get(node.id);
|
|
209
|
-
if (start) {
|
|
210
|
-
places.set(node.id, { ...start });
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const angle = Graph.#hash(node.id) * Math.PI * 2;
|
|
215
|
-
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
|
|
216
|
-
const distance = placed ? 90 : radius;
|
|
217
|
-
const origin = placed ?? { x: 0, y: 0 };
|
|
218
|
-
|
|
219
|
-
places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
223
|
-
if (!moving.length) return park();
|
|
224
|
-
|
|
225
|
-
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
|
|
226
|
-
const ideal = 170;
|
|
227
|
-
const rounds = fixed.size ? 200 : 500;
|
|
228
|
-
|
|
229
|
-
/** @param {string} id */
|
|
230
|
-
const at = (id) => /** @type {Point} */ (places.get(id));
|
|
231
|
-
|
|
232
|
-
for (let round = 0; round < rounds; round++) {
|
|
233
|
-
const heat = 40 * (1 - round / rounds) + 1;
|
|
234
|
-
const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
|
|
235
|
-
/** @param {string} id */
|
|
236
|
-
const pushOf = (id) => /** @type {Point} */ (push.get(id));
|
|
237
|
-
|
|
238
|
-
for (const [index, a] of nodes.entries()) {
|
|
239
|
-
for (const b of nodes.slice(index + 1)) {
|
|
240
|
-
let dx = at(a.id).x - at(b.id).x;
|
|
241
|
-
let dy = at(a.id).y - at(b.id).y;
|
|
242
|
-
|
|
243
|
-
// Two nodes in one place are pulled apart in a direction of their own.
|
|
244
|
-
if (dx === 0 && dy === 0) {
|
|
245
|
-
dx = Graph.#hash(a.id + b.id) - 0.5;
|
|
246
|
-
dy = Graph.#hash(b.id + a.id) - 0.5;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Nodes far apart no longer push, so parts that are not connected stay near each other.
|
|
250
|
-
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
251
|
-
if (distance > ideal * 3) continue;
|
|
252
|
-
|
|
253
|
-
const force = (ideal * ideal) / distance;
|
|
254
|
-
pushOf(a.id).x += (dx / distance) * force;
|
|
255
|
-
pushOf(a.id).y += (dy / distance) * force;
|
|
256
|
-
pushOf(b.id).x -= (dx / distance) * force;
|
|
257
|
-
pushOf(b.id).y -= (dy / distance) * force;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
for (const [from, to] of pairs) {
|
|
262
|
-
if (!from || !to || !places.has(from) || !places.has(to)) continue;
|
|
263
|
-
|
|
264
|
-
const dx = at(from).x - at(to).x;
|
|
265
|
-
const dy = at(from).y - at(to).y;
|
|
266
|
-
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
267
|
-
const force = (distance * distance) / ideal;
|
|
268
|
-
pushOf(from).x -= (dx / distance) * force;
|
|
269
|
-
pushOf(from).y -= (dy / distance) * force;
|
|
270
|
-
pushOf(to).x += (dx / distance) * force;
|
|
271
|
-
pushOf(to).y += (dy / distance) * force;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
for (const node of moving) {
|
|
275
|
-
const point = at(node.id);
|
|
276
|
-
const force = pushOf(node.id);
|
|
277
|
-
|
|
278
|
-
// A pull to the middle keeps parts that are not connected from drifting away.
|
|
279
|
-
force.x -= point.x * 0.15;
|
|
280
|
-
force.y -= point.y * 0.15;
|
|
281
|
-
|
|
282
|
-
const length = Math.max(Math.hypot(force.x, force.y), 1);
|
|
283
|
-
const step = Math.min(length, heat);
|
|
284
|
-
point.x += (force.x / length) * step;
|
|
285
|
-
point.y += (force.y / length) * step;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// No box may cover another: two that overlap are pushed apart the shorter way out, with room for the label of the line between them.
|
|
290
|
-
|
|
291
|
-
for (let round = 0; round < 80; round++) {
|
|
292
|
-
let overlapping = false;
|
|
293
|
-
|
|
294
|
-
for (const [index, a] of nodes.entries()) {
|
|
295
|
-
for (const b of nodes.slice(index + 1)) {
|
|
296
|
-
const aMoves = !fixed.has(a.id);
|
|
297
|
-
const bMoves = !fixed.has(b.id);
|
|
298
|
-
if (!aMoves && !bMoves) continue;
|
|
299
|
-
|
|
300
|
-
const pa = at(a.id);
|
|
301
|
-
const pb = at(b.id);
|
|
302
|
-
const overlapX = halfWidth(a) + halfWidth(b) + 24 - Math.abs(pa.x - pb.x);
|
|
303
|
-
const overlapY = 96 - Math.abs(pa.y - pb.y);
|
|
304
|
-
if (overlapX <= 0 || overlapY <= 0) continue;
|
|
305
|
-
|
|
306
|
-
overlapping = true;
|
|
307
|
-
const share = aMoves && bMoves ? 0.5 : 1;
|
|
308
|
-
const alongX = overlapX < overlapY * 2;
|
|
309
|
-
const sign = alongX ? Math.sign(pa.x - pb.x) || 1 : Math.sign(pa.y - pb.y) || 1;
|
|
310
|
-
const distance = (alongX ? overlapX : overlapY) * share;
|
|
311
|
-
|
|
312
|
-
if (aMoves) alongX ? (pa.x += sign * distance) : (pa.y += sign * distance);
|
|
313
|
-
if (bMoves) alongX ? (pb.x -= sign * distance) : (pb.y -= sign * distance);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (!overlapping) break;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
park();
|
|
321
|
-
Graph.#savePositions();
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// What is drawn.
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* A layer's edges, with those a comparison says are gone.
|
|
328
|
-
*
|
|
329
|
-
* @param {Layer} name
|
|
330
|
-
* @returns {DrawnEdge[]}
|
|
331
|
-
*/
|
|
332
|
-
static #edgesOn(name) {
|
|
333
|
-
const current = Graph.#data?.edges.filter((edge) => edge.layer === name) ?? [];
|
|
334
|
-
if (!Graph.#comparison) return current;
|
|
335
|
-
|
|
336
|
-
// An edge the comparison says is gone is drawn when both its ends are still drawn.
|
|
337
|
-
const nodes = new Set(Graph.#data?.nodes.map((node) => node.id));
|
|
338
|
-
const gone = Graph.#comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
|
|
339
|
-
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* A layer's nodes: every device, and whatever else its edges end at.
|
|
344
|
-
*
|
|
345
|
-
* @param {Layer} name
|
|
346
|
-
* @returns {GraphNode[]}
|
|
347
|
-
*/
|
|
348
|
-
static #nodesOn(name) {
|
|
349
|
-
const ends = new Set(Graph.#edgesOn(name).flatMap((edge) => [edge.from, edge.to]));
|
|
350
|
-
return Graph.#data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
static #visibleEdges() {
|
|
354
|
-
return Graph.#edgesOn(Graph.#layer);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
static #visibleNodes() {
|
|
358
|
-
return Graph.#nodesOn(Graph.#layer);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Lay out a layer for its own lines. The cables layer comes first, as every other layer's new nodes start from it.
|
|
363
|
-
*
|
|
364
|
-
* @param {Layer} name
|
|
365
|
-
* @param {boolean} fresh forget where the layer's nodes were, and lay it out again
|
|
366
|
-
*/
|
|
367
|
-
static #layOut(name, fresh) {
|
|
368
|
-
if (!Graph.#data) return;
|
|
369
|
-
if (name !== "l1") Graph.#layOut("l1", false);
|
|
370
|
-
|
|
371
|
-
Graph.#layout(
|
|
372
|
-
Graph.#nodesOn(name),
|
|
373
|
-
Graph.#edgesOn(name),
|
|
374
|
-
fresh,
|
|
375
|
-
Graph.#layoutOf(name),
|
|
376
|
-
name === "l1" ? undefined : Graph.#layoutOf("l1")
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** The node glide between two layers' places, which a press stops. @type {number | undefined} */
|
|
381
|
-
static #regrouping;
|
|
382
|
-
|
|
383
|
-
/** Put the nodes at their own layer's places now, ending a glide between layers. */
|
|
384
|
-
static #settle() {
|
|
385
|
-
if (Graph.#regrouping !== undefined) cancelAnimationFrame(Graph.#regrouping);
|
|
386
|
-
Graph.#regrouping = undefined;
|
|
387
|
-
Graph.#positions = Graph.#layoutOf(Graph.#layer);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Move the nodes from where they are drawn to their places on the layer shown, so the eye can follow each device to where it is on this layer.
|
|
392
|
-
*
|
|
393
|
-
* @param {Map<string, Point>} from
|
|
394
|
-
*/
|
|
395
|
-
static #glideNodes(from) {
|
|
396
|
-
Graph.#settle();
|
|
397
|
-
|
|
398
|
-
const to = Graph.#layoutOf(Graph.#layer);
|
|
399
|
-
if (Dom.still.matches || from === to) return;
|
|
400
|
-
|
|
401
|
-
/** @type {Map<string, Point>} */
|
|
402
|
-
const shown = new Map();
|
|
403
|
-
const began = performance.now();
|
|
404
|
-
|
|
405
|
-
/** @param {number} now */
|
|
406
|
-
const step = (now) => {
|
|
407
|
-
const t = Math.min((now - began) / 700, 1);
|
|
408
|
-
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
409
|
-
|
|
410
|
-
for (const [id, end] of to) {
|
|
411
|
-
const start = from.get(id) ?? end;
|
|
412
|
-
shown.set(id, { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased });
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
Graph.#positions = t < 1 ? shown : to;
|
|
416
|
-
Graph.#place();
|
|
417
|
-
Graph.#regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
418
|
-
};
|
|
419
|
-
|
|
420
|
-
Graph.#positions = shown;
|
|
421
|
-
step(began);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** @param {string} id */
|
|
425
|
-
static #nodeById(id) {
|
|
426
|
-
return Graph.#data?.nodes.find((node) => node.id === id);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* @param {GraphNode} node
|
|
431
|
-
* @returns {"error" | "warning" | undefined}
|
|
432
|
-
*/
|
|
433
|
-
static #markOf(node) {
|
|
434
|
-
if (!Graph.#data || node.kind !== "device") return undefined;
|
|
435
|
-
|
|
436
|
-
const findings = Graph.#data.findings.filter((finding) => finding.device === node.label);
|
|
437
|
-
if (findings.some((finding) => finding.level === "error")) return "error";
|
|
438
|
-
|
|
439
|
-
const drift = Graph.#data.drift[node.label]?.status;
|
|
440
|
-
if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
|
|
441
|
-
|
|
442
|
-
return undefined;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/**
|
|
446
|
-
* @param {GraphEdge} edge
|
|
447
|
-
* @param {number} offset
|
|
448
|
-
* @returns {{ d: string, label: Point }}
|
|
449
|
-
*/
|
|
450
|
-
static #curve(edge, offset) {
|
|
451
|
-
const a = Graph.#positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
452
|
-
const b = Graph.#positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
453
|
-
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
454
|
-
|
|
455
|
-
const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
|
|
456
|
-
const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
|
|
457
|
-
const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
|
|
458
|
-
|
|
459
|
-
return {
|
|
460
|
-
d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
|
|
461
|
-
label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset },
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** Move what is drawn to where the nodes are now, without drawing it again. */
|
|
466
|
-
static #place() {
|
|
467
|
-
Graph.#viewport.setAttribute("transform", `translate(${Graph.#view.x},${Graph.#view.y}) scale(${Graph.#view.k})`);
|
|
468
|
-
Graph.#svg.classList.toggle("far", Graph.#view.k < 0.4);
|
|
469
|
-
|
|
470
|
-
for (const [id, group] of Graph.#nodeElements) {
|
|
471
|
-
const point = Graph.#positions.get(id) ?? { x: 0, y: 0 };
|
|
472
|
-
group.setAttribute("transform", `translate(${point.x},${point.y})`);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
for (const { edge, path, hit, label, offset } of Graph.#edgeElements) {
|
|
476
|
-
const { d, label: middle } = Graph.#curve(edge, offset);
|
|
477
|
-
path.setAttribute("d", d);
|
|
478
|
-
hit.setAttribute("d", d);
|
|
479
|
-
label.setAttribute("x", String(middle.x));
|
|
480
|
-
label.setAttribute("y", String(middle.y));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/** Past this many edges between the same two nodes, they are drawn as one, which lists them all when selected. */
|
|
485
|
-
static #MOST_APART = 3;
|
|
486
|
-
|
|
487
|
-
/** @type {Record<Layer, string>} */
|
|
488
|
-
static #MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
|
|
489
|
-
|
|
490
|
-
/** @type {Record<Layer, string>} */
|
|
491
|
-
static #ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
|
|
495
|
-
*
|
|
496
|
-
* @param {DrawnEdge[]} edges
|
|
497
|
-
* @returns {DrawnEdge[]}
|
|
498
|
-
*/
|
|
499
|
-
static #bundled(edges) {
|
|
500
|
-
/** @type {Map<string, DrawnEdge[]>} */
|
|
501
|
-
const byPair = new Map();
|
|
502
|
-
for (const edge of edges) {
|
|
503
|
-
const pair = `${[edge.from, edge.to].sort().join("|")}${edge.gone ? " gone" : ""}`;
|
|
504
|
-
byPair.set(pair, [...(byPair.get(pair) ?? []), edge]);
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return [...byPair.values()].flatMap((members) => {
|
|
508
|
-
const [first] = members;
|
|
509
|
-
if (!first || members.length <= Graph.#MOST_APART) return members;
|
|
510
|
-
|
|
511
|
-
/** @type {Map<number, import("../../core/topology").CarriedVlan>} */
|
|
512
|
-
const vlans = new Map();
|
|
513
|
-
for (const member of members) for (const vlan of member.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
514
|
-
|
|
515
|
-
return [
|
|
516
|
-
{
|
|
517
|
-
layer: first.layer,
|
|
518
|
-
from: first.from,
|
|
519
|
-
to: first.to,
|
|
520
|
-
label: `${members.length} ${Graph.#MANY[first.layer]}`,
|
|
521
|
-
detail: members.flatMap((member) => [member.label, ...member.detail.map((line) => ` ${line}`)]),
|
|
522
|
-
...(members.some((member) => member.oneSided) ? { oneSided: /** @type {const} */ (true) } : {}),
|
|
523
|
-
...(vlans.size ? { vlans: [...vlans.values()].sort((a, b) => a.id - b.id) } : {}),
|
|
524
|
-
...(first.gone ? { gone: /** @type {const} */ (true) } : {}),
|
|
525
|
-
members,
|
|
526
|
-
},
|
|
527
|
-
];
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* Dots that travel a cable both ends declare, so a link that works reads as one that carries traffic: one dot, or one each way while presenting. A cable only one end declares, or one a comparison shows is gone, carries none.
|
|
533
|
-
*
|
|
534
|
-
* @param {DrawnEdge} edge
|
|
535
|
-
* @param {string} id the id of the cable's drawn path, which the dots follow as it moves
|
|
536
|
-
*/
|
|
537
|
-
static #signals(edge, id) {
|
|
538
|
-
if (Dom.still.matches || edge.oneSided || edge.gone) return [];
|
|
539
|
-
|
|
540
|
-
const a = Graph.#positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
541
|
-
const b = Graph.#positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
542
|
-
const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (Graph.#presenting ? 110 : 45), 1.6), 12);
|
|
543
|
-
const phase = Graph.#hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
|
|
544
|
-
const backwards = Graph.#presenting ? [false, true] : [Graph.#hash(edge.label) < 0.5];
|
|
545
|
-
|
|
546
|
-
return backwards.map((back, index) => {
|
|
547
|
-
const dot = Dom.s("circle", { class: "signal", r: 3.5 });
|
|
548
|
-
const motion = Dom.s("animateMotion", {
|
|
549
|
-
dur: `${seconds.toFixed(2)}s`,
|
|
550
|
-
begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
|
|
551
|
-
repeatCount: "indefinite",
|
|
552
|
-
...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
motion.append(Dom.s("mpath", { href: `#${id}` }));
|
|
556
|
-
dot.append(motion);
|
|
557
|
-
return dot;
|
|
558
|
-
});
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/** A confident arrival: fast at first, settling at the end. */
|
|
562
|
-
static #EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
|
|
566
|
-
*
|
|
567
|
-
* @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
|
|
568
|
-
*/
|
|
569
|
-
static #arrive(fresh) {
|
|
570
|
-
if (Dom.still.matches || !Graph.#edgeElements.length) return;
|
|
571
|
-
|
|
572
|
-
/** @param {string} id */
|
|
573
|
-
const degree = (id) => Graph.#edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
|
|
574
|
-
const hub = [...Graph.#nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
|
|
575
|
-
|
|
576
|
-
/** @type {Map<string, number>} */
|
|
577
|
-
const ring = new Map(hub ? [[hub, 0]] : []);
|
|
578
|
-
for (let queue = hub ? [hub] : []; queue.length;) {
|
|
579
|
-
const id = /** @type {string} */ (queue.shift());
|
|
580
|
-
for (const { edge } of Graph.#edgeElements) {
|
|
581
|
-
const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
|
|
582
|
-
if (other === undefined || ring.has(other)) continue;
|
|
583
|
-
ring.set(other, (ring.get(id) ?? 0) + 1);
|
|
584
|
-
queue.push(other);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// The whole drawing arrives within about a second, however many rings it has.
|
|
589
|
-
const farthest = Math.max(0, ...ring.values()) + 1;
|
|
590
|
-
const step = Math.min(110, 800 / farthest);
|
|
591
|
-
/** @param {string} id */
|
|
592
|
-
const ringOf = (id) => ring.get(id) ?? farthest;
|
|
593
|
-
|
|
594
|
-
for (const [id, group] of Graph.#nodeElements) {
|
|
595
|
-
if (!fresh.has(id)) continue;
|
|
596
|
-
group.animate([{ opacity: 0 }, { opacity: 1 }], {
|
|
597
|
-
duration: 360,
|
|
598
|
-
delay: ringOf(id) * step,
|
|
599
|
-
easing: Graph.#EASE,
|
|
600
|
-
fill: "backwards",
|
|
601
|
-
});
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
for (const { edge, group, path, label } of Graph.#edgeElements) {
|
|
605
|
-
const forward = ringOf(edge.from) <= ringOf(edge.to);
|
|
606
|
-
const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
|
|
607
|
-
|
|
608
|
-
if (edge.oneSided || edge.gone) {
|
|
609
|
-
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: Graph.#EASE, fill: "backwards" });
|
|
610
|
-
} else {
|
|
611
|
-
// Measured as one whole length, so the line draws in right even while its ends glide to a new layer's places.
|
|
612
|
-
path.setAttribute("pathLength", "1");
|
|
613
|
-
const start = { strokeDasharray: "1 1", strokeDashoffset: forward ? 1 : -1 };
|
|
614
|
-
const end = { strokeDasharray: "1 1", strokeDashoffset: 0 };
|
|
615
|
-
const drawing = path.animate([start, end], { duration: 560, delay, easing: Graph.#EASE, fill: "backwards" });
|
|
616
|
-
drawing.addEventListener("finish", () => path.removeAttribute("pathLength"));
|
|
617
|
-
drawing.addEventListener("cancel", () => path.removeAttribute("pathLength"));
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
621
|
-
part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Draw the layer. `arrival` says what is new to the eye: the whole network the first time it is shown, its cables when the layer changes, and nothing when the config is read again.
|
|
628
|
-
*
|
|
629
|
-
* @param {"network" | "cables"} [arrival]
|
|
630
|
-
*/
|
|
631
|
-
static #draw(arrival) {
|
|
632
|
-
const before = new Set(Graph.#nodeElements.keys());
|
|
633
|
-
|
|
634
|
-
Graph.#edgeLayer.replaceChildren();
|
|
635
|
-
Graph.#nodeLayer.replaceChildren();
|
|
636
|
-
Graph.#nodeElements.clear();
|
|
637
|
-
Graph.#edgeElements.length = 0;
|
|
638
|
-
|
|
639
|
-
const edges = Graph.#bundled(Graph.#visibleEdges());
|
|
640
|
-
|
|
641
|
-
// Edges between the same two nodes bend apart, so each stays readable.
|
|
642
|
-
/** @type {Map<string, number>} */
|
|
643
|
-
const between = new Map();
|
|
644
|
-
/** @param {DrawnEdge} edge */
|
|
645
|
-
const pairOf = (edge) => [edge.from, edge.to].sort().join("|");
|
|
646
|
-
for (const edge of edges) between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
|
|
647
|
-
/** @type {Map<string, number>} */
|
|
648
|
-
const seen = new Map();
|
|
649
|
-
|
|
650
|
-
for (const [number, edge] of edges.entries()) {
|
|
651
|
-
const pair = pairOf(edge);
|
|
652
|
-
const index = seen.get(pair) ?? 0;
|
|
653
|
-
seen.set(pair, index + 1);
|
|
654
|
-
|
|
655
|
-
// Bend in one direction whichever way round the edge was written.
|
|
656
|
-
const sign = edge.from < edge.to ? 1 : -1;
|
|
657
|
-
const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
|
|
658
|
-
|
|
659
|
-
const added =
|
|
660
|
-
Graph.#comparison &&
|
|
661
|
-
[edge, ...(edge.members ?? [])].some((one) => !one.gone && Graph.#comparison?.added.has(Evaluate.changeKey(one)));
|
|
662
|
-
const change = edge.gone ? " gone" : added ? " added" : "";
|
|
663
|
-
const group = Dom.s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
|
|
664
|
-
const hit = Dom.s("path", { class: "hit" });
|
|
665
|
-
const path = Dom.s("path", { id: `cable-${number}` });
|
|
666
|
-
const label = Dom.s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
667
|
-
label.textContent = edge.label;
|
|
668
|
-
|
|
669
|
-
group.append(path, hit, ...Graph.#signals(edge, `cable-${number}`), label);
|
|
670
|
-
|
|
671
|
-
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
672
|
-
group.addEventListener("pointerdown", (event) => {
|
|
673
|
-
event.stopPropagation();
|
|
674
|
-
Graph.#select({ edge });
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
Graph.#edgeLayer.append(group);
|
|
678
|
-
Graph.#edgeElements.push({ edge, group, path, hit, label, offset });
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
for (const node of Graph.#visibleNodes()) {
|
|
682
|
-
const group = Dom.s("g", { class: `node ${node.kind} ${Graph.#markOf(node) ?? ""}`, tabindex: 0 });
|
|
683
|
-
const rect = Dom.s("rect", { rx: node.kind === "segment" ? 14 : 8 });
|
|
684
|
-
group.append(rect);
|
|
685
|
-
|
|
686
|
-
const device = node.kind === "device";
|
|
687
|
-
const lines = device ? [node.label, node.detail[0] ?? ""] : [node.label];
|
|
688
|
-
const texts = lines.map((line, index) => {
|
|
689
|
-
const y = device ? (index === 0 ? -9 : 13) : 0;
|
|
690
|
-
const text = Dom.s("text", { class: index === 0 ? "name" : "sub", y });
|
|
691
|
-
text.textContent = line;
|
|
692
|
-
group.append(text);
|
|
693
|
-
return text;
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
Graph.#nodeLayer.append(group);
|
|
697
|
-
|
|
698
|
-
// Each box fits its text, so it is measured once drawn. A device's text starts after its chassis mark.
|
|
699
|
-
const mark = device ? 46 : 0;
|
|
700
|
-
const width = Math.max(...texts.map((text) => text.getComputedTextLength()), device ? 110 : 0) + 32 + mark;
|
|
701
|
-
const height = device ? 62 : 34;
|
|
702
|
-
rect.setAttribute("x", String(-width / 2));
|
|
703
|
-
rect.setAttribute("y", String(-height / 2));
|
|
704
|
-
rect.setAttribute("width", String(width));
|
|
705
|
-
rect.setAttribute("height", String(height));
|
|
706
|
-
|
|
707
|
-
// The mark of the device presenting lights now: a ring around its frame, shown only then.
|
|
708
|
-
const beacon = Dom.s("g", { class: "beacon", "aria-hidden": "true" });
|
|
709
|
-
beacon.append(Dom.s("rect", { x: -width / 2 - 10, y: -height / 2 - 10, width: width + 20, height: height + 20, rx: 16 }));
|
|
710
|
-
group.insertBefore(beacon, rect);
|
|
711
|
-
|
|
712
|
-
if (device) {
|
|
713
|
-
for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
|
|
714
|
-
|
|
715
|
-
// A small front panel: a chassis with two rows of ports, drawn rather than pictured.
|
|
716
|
-
const left = -width / 2 + 16;
|
|
717
|
-
const chassis = Dom.s("g", { class: "chassis", "aria-hidden": "true" });
|
|
718
|
-
chassis.append(Dom.s("rect", { x: left, y: -15, width: 34, height: 30, rx: 4 }));
|
|
719
|
-
for (const row of [0, 1]) {
|
|
720
|
-
for (const column of [0, 1, 2]) {
|
|
721
|
-
chassis.append(
|
|
722
|
-
Dom.s("rect", { class: "socket", x: left + 6 + column * 8, y: -8 + row * 9, width: 6, height: 6, rx: 1 })
|
|
723
|
-
);
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
group.insertBefore(chassis, texts[0] ?? null);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
group.addEventListener("pointerdown", (event) => Graph.#startDrag(event, node.id));
|
|
730
|
-
group.addEventListener("pointerenter", () => {
|
|
731
|
-
Graph.#hovered = node.id;
|
|
732
|
-
Graph.#emphasize();
|
|
733
|
-
});
|
|
734
|
-
group.addEventListener("pointerleave", () => {
|
|
735
|
-
Graph.#hovered = undefined;
|
|
736
|
-
Graph.#emphasize();
|
|
737
|
-
});
|
|
738
|
-
group.addEventListener("keydown", (event) => event.key === "Enter" && Graph.#select({ node: node.id }));
|
|
739
|
-
|
|
740
|
-
Graph.#nodeElements.set(node.id, group);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
Graph.#place();
|
|
744
|
-
Graph.#emphasize();
|
|
745
|
-
|
|
746
|
-
if (arrival) Graph.#arrive(new Set([...Graph.#nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
/** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
|
|
750
|
-
static #emphasize() {
|
|
751
|
-
const focus =
|
|
752
|
-
Graph.#hovered ?? Graph.#touring ?? (Graph.#selection && "node" in Graph.#selection ? Graph.#selection.node : undefined);
|
|
753
|
-
const selectedEdge = Graph.#selection && "edge" in Graph.#selection ? Graph.#selection.edge : undefined;
|
|
754
|
-
|
|
755
|
-
/** @param {GraphEdge} edge */
|
|
756
|
-
const touching = (edge) => edge.from === focus || edge.to === focus;
|
|
757
|
-
/** @param {GraphEdge} edge */
|
|
758
|
-
const carries = (edge) => edge.vlans?.some((vlan) => vlan.id === Graph.#vlanFocus) ?? false;
|
|
759
|
-
|
|
760
|
-
/** @type {Set<GraphEdge> | undefined} */
|
|
761
|
-
let litEdges;
|
|
762
|
-
if (focus) litEdges = new Set(Graph.#edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
|
|
763
|
-
else if (Graph.#pathFocus)
|
|
764
|
-
litEdges = new Set(
|
|
765
|
-
Graph.#edgeElements
|
|
766
|
-
.filter(({ edge }) => [edge, ...(edge.members ?? [])].some((one) => Graph.#pathFocus?.has(Graph.#edgeKey(one))))
|
|
767
|
-
.map(({ edge }) => edge)
|
|
768
|
-
);
|
|
769
|
-
else if (Graph.#vlanFocus !== undefined && Graph.#layer === "l2")
|
|
770
|
-
litEdges = new Set(Graph.#edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
|
|
771
|
-
else if (selectedEdge) litEdges = new Set([selectedEdge]);
|
|
772
|
-
|
|
773
|
-
const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
|
|
774
|
-
if (focus) litNodes?.add(focus);
|
|
775
|
-
|
|
776
|
-
for (const { edge, group } of Graph.#edgeElements) {
|
|
777
|
-
group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
|
|
778
|
-
group.classList.toggle("selected", edge === selectedEdge);
|
|
779
|
-
group.classList.toggle("dim", litEdges !== undefined && !litEdges.has(edge));
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
for (const [id, group] of Graph.#nodeElements) {
|
|
783
|
-
group.classList.toggle("selected", Boolean(Graph.#selection && "node" in Graph.#selection && Graph.#selection.node === id));
|
|
784
|
-
group.classList.toggle("touring", Graph.#presenting && id === Graph.#touring && !Graph.#hovered);
|
|
785
|
-
group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
// Panning, zooming and dragging.
|
|
790
|
-
/** @type {{ id?: string, start: Point, origin: Point, moved: boolean } | undefined} */
|
|
791
|
-
static #drag;
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* @param {{ clientX: number, clientY: number }} event
|
|
795
|
-
* @returns {Point}
|
|
796
|
-
*/
|
|
797
|
-
static #toWorld(event) {
|
|
798
|
-
const box = Graph.#svg.getBoundingClientRect();
|
|
799
|
-
return {
|
|
800
|
-
x: (event.clientX - box.left - Graph.#view.x) / Graph.#view.k,
|
|
801
|
-
y: (event.clientY - box.top - Graph.#view.y) / Graph.#view.k,
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* @param {PointerEvent} event
|
|
807
|
-
* @param {string} [id]
|
|
808
|
-
*/
|
|
809
|
-
static #startDrag(event, id) {
|
|
810
|
-
if (event.button !== 0) return;
|
|
811
|
-
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
812
|
-
Graph.#gliding = undefined;
|
|
813
|
-
|
|
814
|
-
// A press ends a glide between layers, so a device is moved from its own place on this layer.
|
|
815
|
-
if (Graph.#regrouping !== undefined) {
|
|
816
|
-
Graph.#settle();
|
|
817
|
-
Graph.#place();
|
|
818
|
-
}
|
|
819
|
-
event.stopPropagation();
|
|
820
|
-
|
|
821
|
-
Graph.#svg.setPointerCapture(event.pointerId);
|
|
822
|
-
const point = id ? Graph.#positions.get(id) : undefined;
|
|
823
|
-
const origin = point ? { ...point } : { x: Graph.#view.x, y: Graph.#view.y };
|
|
824
|
-
Graph.#drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
|
|
825
|
-
if (!id) Graph.#svg.classList.add("panning");
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/** The scale below which a device's name is too small to read: a first view stops there, and the reader pans for the rest. */
|
|
829
|
-
static #READABLE = 0.6;
|
|
830
|
-
|
|
831
|
-
/** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
|
|
832
|
-
static #gliding;
|
|
833
|
-
|
|
834
|
-
/**
|
|
835
|
-
* Move the view to a new place over a second, zooming evenly and keeping the eye on the point between them. At once when the reader asked for less motion.
|
|
836
|
-
*
|
|
837
|
-
* @param {View} target
|
|
838
|
-
*/
|
|
839
|
-
static #glideTo(target) {
|
|
840
|
-
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
841
|
-
Graph.#gliding = undefined;
|
|
842
|
-
|
|
843
|
-
if (Dom.still.matches) {
|
|
844
|
-
Graph.#view = target;
|
|
845
|
-
Graph.#place();
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const box = Graph.#svg.getBoundingClientRect();
|
|
850
|
-
const from = { ...Graph.#view };
|
|
851
|
-
/** @param {View} one */
|
|
852
|
-
const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
|
|
853
|
-
const start = centreOf(from);
|
|
854
|
-
const end = centreOf(target);
|
|
855
|
-
const began = performance.now();
|
|
856
|
-
|
|
857
|
-
/** @param {number} now */
|
|
858
|
-
const step = (now) => {
|
|
859
|
-
const t = Math.min((now - began) / 1100, 1);
|
|
860
|
-
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
861
|
-
const k = from.k * (target.k / from.k) ** eased;
|
|
862
|
-
const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
|
|
863
|
-
|
|
864
|
-
Graph.#view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
865
|
-
Graph.#place();
|
|
866
|
-
Graph.#gliding = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
867
|
-
};
|
|
868
|
-
|
|
869
|
-
Graph.#gliding = requestAnimationFrame(step);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* Show the whole drawing, or, when that would make it unreadable and `readable` is asked, as much of it as stays readable. `glide` moves there instead of jumping.
|
|
874
|
-
*
|
|
875
|
-
* @param {boolean} [readable]
|
|
876
|
-
* @param {boolean} [glide]
|
|
877
|
-
*/
|
|
878
|
-
static #fit(readable = false, glide = false) {
|
|
879
|
-
const target = Graph.#framing(Graph.#visibleNodes(), readable);
|
|
880
|
-
if (!target) return;
|
|
881
|
-
|
|
882
|
-
if (glide) return Graph.#glideTo(target);
|
|
883
|
-
Graph.#view = target;
|
|
884
|
-
Graph.#place();
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
/**
|
|
888
|
-
* The view that frames these nodes.
|
|
889
|
-
*
|
|
890
|
-
* @param {GraphNode[]} nodes
|
|
891
|
-
* @param {boolean} readable
|
|
892
|
-
* @returns {View | undefined}
|
|
893
|
-
*/
|
|
894
|
-
static #framing(nodes, readable) {
|
|
895
|
-
// Framed where the nodes are going, not where a glide between layers has them now.
|
|
896
|
-
const places = Graph.#layoutOf(Graph.#layer);
|
|
897
|
-
const points = nodes.flatMap((node) => {
|
|
898
|
-
const point = places.get(node.id);
|
|
899
|
-
return point ? [point] : [];
|
|
900
|
-
});
|
|
901
|
-
if (!points.length) return undefined;
|
|
902
|
-
|
|
903
|
-
const box = Graph.#svg.getBoundingClientRect();
|
|
904
|
-
const xs = points.map((point) => point.x);
|
|
905
|
-
const ys = points.map((point) => point.y);
|
|
906
|
-
const width = Math.max(...xs) - Math.min(...xs) + 190;
|
|
907
|
-
const height = Math.max(...ys) - Math.min(...ys) + 130;
|
|
908
|
-
const whole = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.8);
|
|
909
|
-
const k = readable ? Math.max(whole, Graph.#READABLE) : whole;
|
|
910
|
-
|
|
911
|
-
// When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
|
|
912
|
-
const edges = Graph.#visibleEdges();
|
|
913
|
-
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
914
|
-
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
915
|
-
const centre =
|
|
916
|
-
k > whole && hub && places.has(hub.id)
|
|
917
|
-
? /** @type {Point} */ (places.get(hub.id))
|
|
918
|
-
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
919
|
-
|
|
920
|
-
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
921
|
-
const shown = Graph.#presenting ? k * 0.86 : k;
|
|
922
|
-
|
|
923
|
-
return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
/** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
|
|
927
|
-
static #frameTour(glide = true) {
|
|
928
|
-
const lit = Graph.#touring;
|
|
929
|
-
if (!lit) return Graph.#fit(false, glide);
|
|
930
|
-
|
|
931
|
-
const near = new Set([
|
|
932
|
-
lit,
|
|
933
|
-
...Graph.#visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : [])),
|
|
934
|
-
]);
|
|
935
|
-
const target = Graph.#framing(
|
|
936
|
-
Graph.#visibleNodes().filter((node) => near.has(node.id)),
|
|
937
|
-
false
|
|
938
|
-
);
|
|
939
|
-
if (!target) return;
|
|
940
|
-
|
|
941
|
-
if (glide) return Graph.#glideTo(target);
|
|
942
|
-
Graph.#view = target;
|
|
943
|
-
Graph.#place();
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// Presenting.
|
|
947
|
-
|
|
948
|
-
/** Say what is shown while presenting: the device lit, or the whole layer. */
|
|
949
|
-
static #showCaption() {
|
|
950
|
-
Graph.#caption.hidden = !Graph.#presenting;
|
|
951
|
-
if (!Graph.#presenting) return;
|
|
952
|
-
|
|
953
|
-
const node = Graph.#touring ? Graph.#nodeById(Graph.#touring) : undefined;
|
|
954
|
-
const edges = Graph.#visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
|
|
955
|
-
const count = `${edges.length} ${edges.length === 1 ? Graph.#ONE[Graph.#layer] : Graph.#MANY[Graph.#layer]}`;
|
|
956
|
-
|
|
957
|
-
if (node)
|
|
958
|
-
Graph.#caption.replaceChildren(
|
|
959
|
-
Dom.h("strong", {}, node.label),
|
|
960
|
-
Dom.h("span", {}, [node.detail[0], count].filter(Boolean).join(", "))
|
|
961
|
-
);
|
|
962
|
-
else
|
|
963
|
-
Graph.#caption.replaceChildren(
|
|
964
|
-
Dom.h("strong", {}, Graph.LAYER_NAMES[Graph.#layer]),
|
|
965
|
-
Dom.h("span", {}, `${Graph.#visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
|
|
966
|
-
);
|
|
967
|
-
|
|
968
|
-
Dom.enter(Graph.#caption);
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
/** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
|
|
972
|
-
static #LIT_PER_LAYER = 4;
|
|
973
|
-
|
|
974
|
-
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
975
|
-
static #tourStep = 0;
|
|
976
|
-
|
|
977
|
-
/** The move to the next layer, waiting while the view zooms out to the whole of this one. @type {ReturnType<typeof setTimeout> | undefined} */
|
|
978
|
-
static #leaving;
|
|
979
|
-
|
|
980
|
-
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
981
|
-
static #nextLayer() {
|
|
982
|
-
const after = [
|
|
983
|
-
...Graph.LAYERS.slice(Graph.LAYERS.indexOf(Graph.#layer) + 1),
|
|
984
|
-
...Graph.LAYERS.slice(0, Graph.LAYERS.indexOf(Graph.#layer) + 1),
|
|
985
|
-
];
|
|
986
|
-
return after.find((name) => Graph.#data?.edges.some((edge) => edge.layer === name)) ?? Graph.#layer;
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
990
|
-
static #tourNext() {
|
|
991
|
-
if (Graph.#graphView.hidden || document.hidden || Graph.#hovered || Graph.#drag || Graph.#leaving) return;
|
|
992
|
-
|
|
993
|
-
const edges = Graph.#visibleEdges();
|
|
994
|
-
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
995
|
-
const devices = Graph.#visibleNodes()
|
|
996
|
-
.filter((node) => node.kind === "device" && degree(node.id))
|
|
997
|
-
.sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
|
|
998
|
-
.slice(0, Graph.#LIT_PER_LAYER);
|
|
999
|
-
|
|
1000
|
-
Graph.#tourStep += 1;
|
|
1001
|
-
Graph.#touring = devices[Graph.#tourStep - 1]?.id;
|
|
1002
|
-
|
|
1003
|
-
if (Graph.#touring || Graph.#nextLayer() === Graph.#layer) {
|
|
1004
|
-
if (!Graph.#touring) Graph.#tourStep = 0;
|
|
1005
|
-
Graph.#emphasize();
|
|
1006
|
-
Graph.#showCaption();
|
|
1007
|
-
Graph.#frameTour();
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Every lit device has had its turn: the view zooms out to the whole layer first, so the next layer is reached from the whole drawing.
|
|
1012
|
-
Graph.#tourStep = 0;
|
|
1013
|
-
Graph.#emphasize();
|
|
1014
|
-
Graph.#showCaption();
|
|
1015
|
-
Graph.#frameTour();
|
|
1016
|
-
|
|
1017
|
-
const next = Graph.#nextLayer();
|
|
1018
|
-
Graph.#leaving = setTimeout(() => {
|
|
1019
|
-
Graph.#leaving = undefined;
|
|
1020
|
-
|
|
1021
|
-
// Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
|
|
1022
|
-
if (Graph.#presenting && !Graph.#graphView.hidden) location.replace(`#graph/${next}`);
|
|
1023
|
-
}, 2600);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/** Start the turns over, so the layer shown now has its full time. */
|
|
1027
|
-
static #restartTour() {
|
|
1028
|
-
clearInterval(Graph.#tour);
|
|
1029
|
-
clearTimeout(Graph.#leaving);
|
|
1030
|
-
Graph.#leaving = undefined;
|
|
1031
|
-
if (Graph.#presenting) Graph.#tour = setInterval(Graph.#tourNext, 6000);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/** @param {boolean} on */
|
|
1035
|
-
static #setPresenting(on) {
|
|
1036
|
-
Graph.#presenting = on;
|
|
1037
|
-
Dom.store.set("present", on);
|
|
1038
|
-
|
|
1039
|
-
document.body.classList.toggle("presenting", on);
|
|
1040
|
-
Graph.#presentButton.setAttribute("aria-pressed", String(on));
|
|
1041
|
-
|
|
1042
|
-
Graph.#touring = undefined;
|
|
1043
|
-
if (on) Graph.#selection = undefined;
|
|
1044
|
-
Graph.#restartTour();
|
|
1045
|
-
|
|
1046
|
-
if (Graph.#graphView.hidden || !Graph.#data) return;
|
|
1047
|
-
|
|
1048
|
-
// The panel hides or returns, so the drawing is fitted once the page has its new size.
|
|
1049
|
-
Graph.#draw();
|
|
1050
|
-
Graph.#showPanel();
|
|
1051
|
-
Graph.#showCaption();
|
|
1052
|
-
requestAnimationFrame(() => (Graph.#presenting ? Graph.#frameTour(false) : Graph.#fit()));
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// The side panel.
|
|
1056
|
-
/** @param {Selection} next */
|
|
1057
|
-
static #select(next) {
|
|
1058
|
-
/** @param {Selection} one */
|
|
1059
|
-
const keyOf = (one) => (one && "node" in one ? one.node : one ? Graph.#edgeKey(one.edge) : "");
|
|
1060
|
-
const changed = keyOf(next) !== keyOf(Graph.#selection);
|
|
1061
|
-
|
|
1062
|
-
Graph.#selection = next;
|
|
1063
|
-
Graph.#pathFocus = undefined;
|
|
1064
|
-
Graph.#emphasize();
|
|
1065
|
-
Graph.#showPanel();
|
|
1066
|
-
|
|
1067
|
-
if (changed) Dom.enter(Graph.#panel);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/**
|
|
1071
|
-
* @param {Annotated["findings"]} findings
|
|
1072
|
-
* @param {boolean} withDevice
|
|
1073
|
-
*/
|
|
1074
|
-
static #findingList(findings, withDevice) {
|
|
1075
|
-
return Dom.h(
|
|
1076
|
-
"ul",
|
|
1077
|
-
{},
|
|
1078
|
-
...findings.map((finding) =>
|
|
1079
|
-
Dom.h(
|
|
1080
|
-
"li",
|
|
1081
|
-
{},
|
|
1082
|
-
Dom.h("span", { class: `badge ${finding.level}` }, finding.level),
|
|
1083
|
-
withDevice && finding.device
|
|
1084
|
-
? Dom.link(finding.device, () => Graph.#select({ node: `device:${finding.device}` }))
|
|
1085
|
-
: undefined,
|
|
1086
|
-
withDevice && finding.device ? ": " : undefined,
|
|
1087
|
-
finding.message
|
|
1088
|
-
)
|
|
1089
|
-
)
|
|
1090
|
-
);
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/** @param {CarriedVlan[]} vlans */
|
|
1094
|
-
static #vlanChips(vlans) {
|
|
1095
|
-
const chips = Dom.h("div", { class: "chips" });
|
|
1096
|
-
|
|
1097
|
-
for (const vlan of vlans) {
|
|
1098
|
-
const button = Dom.h("button", { "aria-pressed": String(vlan.id === Graph.#vlanFocus) }, `${vlan.name} ${vlan.id}`);
|
|
1099
|
-
button.addEventListener("click", () => {
|
|
1100
|
-
Graph.#vlanFocus = Graph.#vlanFocus === vlan.id ? undefined : vlan.id;
|
|
1101
|
-
Graph.#selection = undefined;
|
|
1102
|
-
Graph.#emphasize();
|
|
1103
|
-
Graph.#showPanel();
|
|
1104
|
-
});
|
|
1105
|
-
chips.append(button);
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
return chips;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
static #legend() {
|
|
1112
|
-
/**
|
|
1113
|
-
* @param {boolean} dashed
|
|
1114
|
-
* @param {string} color
|
|
1115
|
-
*/
|
|
1116
|
-
const sample = (dashed, color) => {
|
|
1117
|
-
const icon = Dom.s("svg", { width: 34, height: 10 });
|
|
1118
|
-
icon.append(
|
|
1119
|
-
Dom.s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" })
|
|
1120
|
-
);
|
|
1121
|
-
return icon;
|
|
1122
|
-
};
|
|
1123
|
-
|
|
1124
|
-
/** @param {string} style */
|
|
1125
|
-
const box = (style) => {
|
|
1126
|
-
const icon = Dom.s("svg", { width: 34, height: 16 });
|
|
1127
|
-
icon.append(Dom.s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
|
|
1128
|
-
return icon;
|
|
1129
|
-
};
|
|
1130
|
-
|
|
1131
|
-
return Dom.h(
|
|
1132
|
-
"ul",
|
|
1133
|
-
{ class: "legend" },
|
|
1134
|
-
Dom.h("li", {}, sample(false, "var(--edge)"), "both ends declare it"),
|
|
1135
|
-
Dom.h("li", {}, sample(true, "var(--warning)"), "only one end declares it"),
|
|
1136
|
-
Dom.h("li", {}, box("stroke: var(--error)"), "a device with an error"),
|
|
1137
|
-
Dom.h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"),
|
|
1138
|
-
Dom.h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network")
|
|
1139
|
-
);
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
static #showPanel() {
|
|
1143
|
-
Graph.#panel.replaceChildren();
|
|
1144
|
-
if (!Graph.#data) return;
|
|
1145
|
-
|
|
1146
|
-
if (Graph.#selection && "node" in Graph.#selection) {
|
|
1147
|
-
const node = Graph.#nodeById(Graph.#selection.node);
|
|
1148
|
-
if (node) return Graph.#showNode(node);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
if (Graph.#selection && "edge" in Graph.#selection) return Graph.#showEdge(Graph.#selection.edge);
|
|
1152
|
-
|
|
1153
|
-
const drift = Graph.#data.drift;
|
|
1154
|
-
const devices = Graph.#data.nodes.filter((node) => node.kind === "device");
|
|
1155
|
-
const drifted = devices.filter((node) => drift[node.label]?.status === "differs");
|
|
1156
|
-
const unsnapped = devices.filter((node) => drift[node.label]?.status === "no snapshot");
|
|
1157
|
-
|
|
1158
|
-
Graph.#panel.append(
|
|
1159
|
-
Dom.h("h2", {}, Graph.LAYER_NAMES[Graph.#layer]),
|
|
1160
|
-
Dom.h("p", { class: "muted" }, Graph.#LAYER_HELP[Graph.#layer])
|
|
1161
|
-
);
|
|
1162
|
-
|
|
1163
|
-
if (Graph.#comparison) {
|
|
1164
|
-
Graph.#panel.append(
|
|
1165
|
-
Dom.h("h3", {}, "Compared with ", Graph.#comparison.name),
|
|
1166
|
-
Dom.h(
|
|
1167
|
-
"p",
|
|
1168
|
-
{},
|
|
1169
|
-
Dom.h("span", { class: "added" }, "Green lines"),
|
|
1170
|
-
" are new, ",
|
|
1171
|
-
Dom.h("span", { class: "removed" }, "red dashed lines"),
|
|
1172
|
-
" are gone. ",
|
|
1173
|
-
Dom.link("Stop comparing", () => {
|
|
1174
|
-
Graph.#comparison = undefined;
|
|
1175
|
-
Graph.#draw();
|
|
1176
|
-
Graph.#showPanel();
|
|
1177
|
-
})
|
|
1178
|
-
)
|
|
1179
|
-
);
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
if (Graph.#layer === "l2") {
|
|
1183
|
-
/** @type {Map<number, CarriedVlan>} */
|
|
1184
|
-
const vlans = new Map();
|
|
1185
|
-
for (const edge of Graph.#visibleEdges()) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
1186
|
-
|
|
1187
|
-
const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
|
|
1188
|
-
if (sorted.length) Graph.#panel.append(Dom.h("h3", {}, "VLANs"), Graph.#vlanChips(sorted));
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
if (Graph.#data.findings.length)
|
|
1192
|
-
Graph.#panel.append(
|
|
1193
|
-
Dom.h("h3", {}, "Found by ", Dom.h("code", {}, "circuit validate")),
|
|
1194
|
-
Graph.#findingList(Graph.#data.findings, true)
|
|
1195
|
-
);
|
|
1196
|
-
|
|
1197
|
-
if (drifted.length) {
|
|
1198
|
-
Graph.#panel.append(
|
|
1199
|
-
Dom.h("h3", {}, "Unlike their last snapshot"),
|
|
1200
|
-
Dom.h(
|
|
1201
|
-
"ul",
|
|
1202
|
-
{},
|
|
1203
|
-
...drifted.map((node) =>
|
|
1204
|
-
Dom.h(
|
|
1205
|
-
"li",
|
|
1206
|
-
{},
|
|
1207
|
-
Dom.link(node.label, () => Graph.#select({ node: node.id }))
|
|
1208
|
-
)
|
|
1209
|
-
)
|
|
1210
|
-
)
|
|
1211
|
-
);
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
if (unsnapped.length) {
|
|
1215
|
-
Graph.#panel.append(
|
|
1216
|
-
Dom.h("h3", {}, "No snapshot yet"),
|
|
1217
|
-
Dom.h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", "))
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
Graph.#panel.append(Dom.h("h3", {}, "Legend"), Graph.#legend());
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
/** @param {GraphNode} node */
|
|
1225
|
-
static #showNode(node) {
|
|
1226
|
-
const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
|
|
1227
|
-
Graph.#panel.append(Dom.h("h2", {}, node.label), Dom.h("div", { class: "kind" }, kinds[node.kind]));
|
|
1228
|
-
|
|
1229
|
-
if (node.kind === "device")
|
|
1230
|
-
Graph.#panel.append(
|
|
1231
|
-
Dom.h(
|
|
1232
|
-
"p",
|
|
1233
|
-
{},
|
|
1234
|
-
Dom.link("Everything it holds", () => Dom.go(`devices/${encodeURIComponent(node.label)}`))
|
|
1235
|
-
)
|
|
1236
|
-
);
|
|
1237
|
-
if (node.detail.length) Graph.#panel.append(Dom.h("h3", {}, "Details"), Dom.h("pre", {}, node.detail.join("\n")));
|
|
1238
|
-
|
|
1239
|
-
if (node.kind === "device" && Graph.#data) {
|
|
1240
|
-
const findings = Graph.#data.findings.filter((finding) => finding.device === node.label);
|
|
1241
|
-
if (findings.length)
|
|
1242
|
-
Graph.#panel.append(
|
|
1243
|
-
Dom.h("h3", {}, "Found by ", Dom.h("code", {}, "circuit validate")),
|
|
1244
|
-
Graph.#findingList(findings, false)
|
|
1245
|
-
);
|
|
1246
|
-
|
|
1247
|
-
const drift = Graph.#data.drift[node.label];
|
|
1248
|
-
if (drift) {
|
|
1249
|
-
const notes = {
|
|
1250
|
-
matches: ["Matches its last snapshot."],
|
|
1251
|
-
differs: ["Differs from its last snapshot. This is what ", Dom.h("code", {}, "circuit diff"), " would send:"],
|
|
1252
|
-
"no snapshot": ["No snapshot yet: run ", Dom.h("code", {}, "circuit snapshot"), "."],
|
|
1253
|
-
"cannot plan": ["Could not be planned against its snapshot:"],
|
|
1254
|
-
};
|
|
1255
|
-
|
|
1256
|
-
Graph.#panel.append(Dom.h("h3", {}, "Against its snapshot"), Dom.h("p", {}, ...notes[drift.status]));
|
|
1257
|
-
if (drift.lines.length) Graph.#panel.append(Dom.h("pre", {}, drift.lines.join("\n")));
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
const edges = Graph.#visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
|
|
1262
|
-
|
|
1263
|
-
Graph.#panel.append(Dom.h("h3", {}, `${Graph.LAYER_NAMES[Graph.#layer]} here`));
|
|
1264
|
-
if (!edges.length) Graph.#panel.append(Dom.h("p", { class: "muted" }, "None on this layer."));
|
|
1265
|
-
|
|
1266
|
-
Graph.#panel.append(
|
|
1267
|
-
Dom.h(
|
|
1268
|
-
"ul",
|
|
1269
|
-
{},
|
|
1270
|
-
...edges.map((edge) => {
|
|
1271
|
-
const other = Graph.#nodeById(edge.from === node.id ? edge.to : edge.from);
|
|
1272
|
-
return Dom.h(
|
|
1273
|
-
"li",
|
|
1274
|
-
{},
|
|
1275
|
-
Dom.link(edge.label, () => Graph.#select({ edge })),
|
|
1276
|
-
Dom.h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`)
|
|
1277
|
-
);
|
|
1278
|
-
})
|
|
1279
|
-
)
|
|
1280
|
-
);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
/** @param {GraphEdge} edge */
|
|
1284
|
-
static #showEdge(edge) {
|
|
1285
|
-
const from = Graph.#nodeById(edge.from);
|
|
1286
|
-
const to = Graph.#nodeById(edge.to);
|
|
1287
|
-
|
|
1288
|
-
Graph.#panel.append(
|
|
1289
|
-
Dom.h("h2", {}, edge.label),
|
|
1290
|
-
Dom.h(
|
|
1291
|
-
"div",
|
|
1292
|
-
{ class: "kind" },
|
|
1293
|
-
`${Graph.LAYER_NAMES[edge.layer]}, between `,
|
|
1294
|
-
Dom.link(from?.label ?? edge.from, () => Graph.#select({ node: edge.from })),
|
|
1295
|
-
" and ",
|
|
1296
|
-
Dom.link(to?.label ?? edge.to, () => Graph.#select({ node: edge.to }))
|
|
1297
|
-
)
|
|
1298
|
-
);
|
|
1299
|
-
|
|
1300
|
-
if (edge.oneSided)
|
|
1301
|
-
Graph.#panel.append(Dom.h("p", {}, Dom.h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
|
|
1302
|
-
if (edge.detail.length) Graph.#panel.append(Dom.h("h3", {}, "Details"), Dom.h("pre", {}, edge.detail.join("\n")));
|
|
1303
|
-
if (edge.vlans?.length) Graph.#panel.append(Dom.h("h3", {}, "VLANs"), Graph.#vlanChips(edge.vlans));
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
static #showLayerButtons() {
|
|
1307
|
-
Graph.#layersNav.replaceChildren();
|
|
1308
|
-
|
|
1309
|
-
for (const [index, name] of Graph.LAYERS.entries()) {
|
|
1310
|
-
const count = Graph.#data?.edges.filter((edge) => edge.layer === name).length ?? 0;
|
|
1311
|
-
const button = Dom.h(
|
|
1312
|
-
"button",
|
|
1313
|
-
{ "aria-pressed": String(name === Graph.#layer), title: `${Graph.#LAYER_HELP[name]} Key ${index + 1}.` },
|
|
1314
|
-
Graph.LAYER_NAMES[name],
|
|
1315
|
-
Dom.h("span", { class: "count" }, String(count))
|
|
1316
|
-
);
|
|
1317
|
-
button.addEventListener("click", () => Dom.go(`graph/${name}`));
|
|
1318
|
-
Graph.#layersNav.append(button);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
/** The layer shown last, or the first. */
|
|
1323
|
-
static currentLayer() {
|
|
1324
|
-
return Graph.#layer;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
/**
|
|
1328
|
-
* Take a new graph, when the config changed. Nodes keep their places, and a selection whose node or edge is gone is dropped.
|
|
1329
|
-
*
|
|
1330
|
-
* @param {Annotated} next
|
|
1331
|
-
*/
|
|
1332
|
-
static set(next) {
|
|
1333
|
-
Graph.#data = next;
|
|
1334
|
-
|
|
1335
|
-
if (Graph.#selection && "node" in Graph.#selection && !Graph.#nodeById(Graph.#selection.node)) Graph.#selection = undefined;
|
|
1336
|
-
if (Graph.#selection && "edge" in Graph.#selection) {
|
|
1337
|
-
const { layer: was, from, to, label } = Graph.#selection.edge;
|
|
1338
|
-
const same = Graph.#data.edges.find(
|
|
1339
|
-
(edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label
|
|
1340
|
-
);
|
|
1341
|
-
Graph.#selection = same ? { edge: same } : undefined;
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
Graph.#layOut(Graph.#layer, false);
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1348
|
-
* Draw a layer, now that the graph is visible: its labels are measured as they are drawn.
|
|
1349
|
-
*
|
|
1350
|
-
* @param {Layer} next
|
|
1351
|
-
*/
|
|
1352
|
-
static show(next) {
|
|
1353
|
-
if (next !== Graph.#layer) {
|
|
1354
|
-
if (Graph.#selection && "edge" in Graph.#selection) Graph.#selection = undefined;
|
|
1355
|
-
Graph.#vlanFocus = undefined;
|
|
1356
|
-
Graph.#pathFocus = undefined;
|
|
1357
|
-
Graph.#touring = undefined;
|
|
1358
|
-
Graph.#tourStep = 0;
|
|
1359
|
-
Graph.#restartTour();
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
if (Graph.#pendingVlan !== undefined) {
|
|
1363
|
-
Graph.#vlanFocus = Graph.#pendingVlan;
|
|
1364
|
-
Graph.#pendingVlan = undefined;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
if (Graph.#pendingPath) {
|
|
1368
|
-
Graph.#pathFocus = new Set(Graph.#pendingPath.map(Graph.#edgeKey));
|
|
1369
|
-
Graph.#pendingPath = undefined;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
// The first view draws the whole network in; a new layer draws in its own lines while its devices glide to their places on it.
|
|
1373
|
-
const arrival = !Graph.#drawn && Graph.#data ? "network" : next !== Graph.#layer ? "cables" : undefined;
|
|
1374
|
-
const from = Graph.#positions;
|
|
1375
|
-
if (Graph.#drawn) Graph.#views.set(Graph.#layer, { ...Graph.#view });
|
|
1376
|
-
|
|
1377
|
-
Graph.#layer = next;
|
|
1378
|
-
Dom.store.set("layer", Graph.#layer);
|
|
1379
|
-
Graph.#layOut(Graph.#layer, false);
|
|
1380
|
-
|
|
1381
|
-
if (arrival === "cables") Graph.#glideNodes(from);
|
|
1382
|
-
else Graph.#settle();
|
|
1383
|
-
|
|
1384
|
-
Graph.#showLayerButtons();
|
|
1385
|
-
Graph.#draw(arrival);
|
|
1386
|
-
Graph.#showPanel();
|
|
1387
|
-
Graph.#showCaption();
|
|
1388
|
-
|
|
1389
|
-
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read. A layer shown before
|
|
1390
|
-
// comes back as the reader left it, and one shown for the first time glides into view.
|
|
1391
|
-
if (Graph.#presenting && arrival) Graph.#fit();
|
|
1392
|
-
else if (!Graph.#drawn && Graph.#data) Graph.#fit(true);
|
|
1393
|
-
else if (arrival === "cables") {
|
|
1394
|
-
const left = Graph.#views.get(Graph.#layer);
|
|
1395
|
-
if (left) Graph.#glideTo(left);
|
|
1396
|
-
else Graph.#fit(true, true);
|
|
1397
|
-
}
|
|
1398
|
-
Graph.#drawn = Graph.#data !== undefined;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
/**
|
|
1402
|
-
* Select a device, for when the graph is shown next.
|
|
1403
|
-
*
|
|
1404
|
-
* @param {string} name
|
|
1405
|
-
*/
|
|
1406
|
-
static selectDevice(name) {
|
|
1407
|
-
Graph.#selection = { node: `device:${name}` };
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
/**
|
|
1411
|
-
* Show a comparison with another commit on the graph: new edges lit, edges gone drawn dashed.
|
|
1412
|
-
*
|
|
1413
|
-
* @param {{ name: string, added: GraphEdge[], removed: GraphEdge[] }} next
|
|
1414
|
-
*/
|
|
1415
|
-
static setComparison(next) {
|
|
1416
|
-
Graph.#comparison = { name: next.name, added: new Set(next.added.map(Evaluate.changeKey)), removed: next.removed };
|
|
1417
|
-
Graph.#selection = undefined;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
/**
|
|
1421
|
-
* Light up these cables as one path, for when their layer is shown next.
|
|
1422
|
-
*
|
|
1423
|
-
* @param {GraphEdge[]} path
|
|
1424
|
-
*/
|
|
1425
|
-
static lightPath(path) {
|
|
1426
|
-
Graph.#pendingPath = path;
|
|
1427
|
-
Graph.#selection = undefined;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
/**
|
|
1431
|
-
* Light up every cable carrying a VLAN, for when the VLAN layer is shown next.
|
|
1432
|
-
*
|
|
1433
|
-
* @param {number} id
|
|
1434
|
-
*/
|
|
1435
|
-
static lightVlan(id) {
|
|
1436
|
-
Graph.#pendingVlan = id;
|
|
1437
|
-
Graph.#selection = undefined;
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
static {
|
|
1441
|
-
Graph.#svg.addEventListener("pointerdown", (event) => Graph.#startDrag(event));
|
|
1442
|
-
|
|
1443
|
-
Graph.#svg.addEventListener("pointermove", (event) => {
|
|
1444
|
-
if (!Graph.#drag) return;
|
|
1445
|
-
|
|
1446
|
-
const dx = event.clientX - Graph.#drag.start.x;
|
|
1447
|
-
const dy = event.clientY - Graph.#drag.start.y;
|
|
1448
|
-
if (Math.hypot(dx, dy) > 3) Graph.#drag.moved = true;
|
|
1449
|
-
if (!Graph.#drag.moved) return;
|
|
1450
|
-
|
|
1451
|
-
if (Graph.#drag.id)
|
|
1452
|
-
Graph.#positions.set(Graph.#drag.id, {
|
|
1453
|
-
x: Graph.#drag.origin.x + dx / Graph.#view.k,
|
|
1454
|
-
y: Graph.#drag.origin.y + dy / Graph.#view.k,
|
|
1455
|
-
});
|
|
1456
|
-
else Graph.#view = { ...Graph.#view, x: Graph.#drag.origin.x + dx, y: Graph.#drag.origin.y + dy };
|
|
1457
|
-
|
|
1458
|
-
Graph.#place();
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
Graph.#svg.addEventListener("pointerup", () => {
|
|
1462
|
-
if (!Graph.#drag) return;
|
|
1463
|
-
|
|
1464
|
-
const ended = Graph.#drag;
|
|
1465
|
-
Graph.#drag = undefined;
|
|
1466
|
-
Graph.#svg.classList.remove("panning");
|
|
1467
|
-
|
|
1468
|
-
if (ended.moved && ended.id) Graph.#savePositions();
|
|
1469
|
-
if (ended.moved) return;
|
|
1470
|
-
|
|
1471
|
-
// A press without a move is a click: on a node it selects the node, and on an empty spot it clears the selection.
|
|
1472
|
-
Graph.#select(ended.id ? { node: ended.id } : undefined);
|
|
1473
|
-
});
|
|
1474
|
-
|
|
1475
|
-
Graph.#svg.addEventListener(
|
|
1476
|
-
"wheel",
|
|
1477
|
-
(event) => {
|
|
1478
|
-
event.preventDefault();
|
|
1479
|
-
if (Graph.#gliding !== undefined) cancelAnimationFrame(Graph.#gliding);
|
|
1480
|
-
Graph.#gliding = undefined;
|
|
1481
|
-
|
|
1482
|
-
const point = Graph.#toWorld(event);
|
|
1483
|
-
const k = Math.min(Math.max(Graph.#view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
|
|
1484
|
-
const box = Graph.#svg.getBoundingClientRect();
|
|
1485
|
-
|
|
1486
|
-
Graph.#view = { k, x: event.clientX - box.left - point.x * k, y: event.clientY - box.top - point.y * k };
|
|
1487
|
-
Graph.#place();
|
|
1488
|
-
},
|
|
1489
|
-
{ passive: false }
|
|
1490
|
-
);
|
|
1491
|
-
|
|
1492
|
-
Dom.byId("fit").addEventListener("click", () => Graph.#fit());
|
|
1493
|
-
|
|
1494
|
-
Dom.byId("lay-out").addEventListener("click", () => {
|
|
1495
|
-
if (!Graph.#data) return;
|
|
1496
|
-
Graph.#layOut(Graph.#layer, true);
|
|
1497
|
-
Graph.#settle();
|
|
1498
|
-
Graph.#draw();
|
|
1499
|
-
Graph.#fit();
|
|
1500
|
-
});
|
|
1501
|
-
|
|
1502
|
-
// Presenting keeps the whole network in view as the screen changes size.
|
|
1503
|
-
window.addEventListener("resize", () => (Graph.#presenting ? Graph.#frameTour(false) : Graph.#place()));
|
|
1504
|
-
|
|
1505
|
-
Graph.#presentButton.addEventListener("click", () => Graph.#setPresenting(!Graph.#presenting));
|
|
1506
|
-
Graph.#setPresenting(Graph.#presenting);
|
|
1507
|
-
|
|
1508
|
-
// A hidden page's dots stop moving.
|
|
1509
|
-
document.addEventListener("visibilitychange", () => {
|
|
1510
|
-
if (!(Graph.#svg instanceof SVGSVGElement)) return;
|
|
1511
|
-
if (document.hidden) Graph.#svg.pauseAnimations();
|
|
1512
|
-
else Graph.#svg.unpauseAnimations();
|
|
1513
|
-
});
|
|
1514
|
-
|
|
1515
|
-
// Saving the layer shown, as it looks.
|
|
1516
|
-
Dom.byId("save-svg").addEventListener("click", () => {
|
|
1517
|
-
if (!(Graph.#viewport instanceof SVGGraphicsElement)) return;
|
|
1518
|
-
Export.save(`circuit-${Graph.#layer}.svg`, new Blob([Export.svg(Graph.#svg, Graph.#viewport).text], { type: "image/svg+xml" }));
|
|
1519
|
-
});
|
|
1520
|
-
|
|
1521
|
-
Dom.byId("save-png").addEventListener("click", async () => {
|
|
1522
|
-
if (!(Graph.#viewport instanceof SVGGraphicsElement)) return;
|
|
1523
|
-
Export.save(`circuit-${Graph.#layer}.png`, await Export.png(Export.svg(Graph.#svg, Graph.#viewport)));
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
document.addEventListener("keydown", (event) => {
|
|
1527
|
-
if (event.target instanceof HTMLInputElement || Graph.#graphView.hidden) return;
|
|
1528
|
-
|
|
1529
|
-
if (event.key === "Escape") {
|
|
1530
|
-
if (Graph.#presenting) Graph.#setPresenting(false);
|
|
1531
|
-
Graph.#select(undefined);
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) Graph.#setPresenting(!Graph.#presenting);
|
|
1535
|
-
|
|
1536
|
-
// Keys 1 to 6 pick a layer.
|
|
1537
|
-
const chosen = Graph.LAYERS[Number(event.key) - 1];
|
|
1538
|
-
if (chosen) Dom.go(`graph/${chosen}`);
|
|
1539
|
-
});
|
|
1540
|
-
}
|
|
1541
|
-
}
|