@takodotid/circuit 0.21.0 → 0.22.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/dist/chunks/{server-zapfzhv4.js → server-ckdy25w2.js} +7 -0
- package/dist/cli/index.js +1 -1
- package/package.json +1 -1
- package/src/studio/public/app.js +13 -3
- package/src/studio/public/dom.js +20 -0
- package/src/studio/public/export.js +6 -0
- package/src/studio/public/graph.js +318 -17
- package/src/studio/public/index.html +10 -0
- package/src/studio/public/style.css +133 -5
- package/src/studio/server.ts +8 -0
|
@@ -151,6 +151,7 @@ async function studio(options) {
|
|
|
151
151
|
`);
|
|
152
152
|
};
|
|
153
153
|
const server = createServer((request, response) => {
|
|
154
|
+
response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
|
|
154
155
|
if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
|
|
155
156
|
response.writeHead(403).end();
|
|
156
157
|
return;
|
|
@@ -186,6 +187,12 @@ async function studio(options) {
|
|
|
186
187
|
});
|
|
187
188
|
return;
|
|
188
189
|
}
|
|
190
|
+
if (path === "/robots.txt") {
|
|
191
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end(`User-agent: *
|
|
192
|
+
Disallow: /
|
|
193
|
+
`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
189
196
|
if (path === "/data") {
|
|
190
197
|
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
|
|
191
198
|
return;
|
package/dist/cli/index.js
CHANGED
|
@@ -4801,7 +4801,7 @@ async function studioCommand() {
|
|
|
4801
4801
|
const port = Number(flagValue("port") ?? 4600);
|
|
4802
4802
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
4803
4803
|
fail("--port takes a number from 1 to 65535");
|
|
4804
|
-
const { isLoopback, studio } = await import("../chunks/server-
|
|
4804
|
+
const { isLoopback, studio } = await import("../chunks/server-ckdy25w2.js");
|
|
4805
4805
|
const host = flagValue("host") ?? "127.0.0.1";
|
|
4806
4806
|
const allowHosts = (flagValue("allow-host") ?? "").split(",").filter(Boolean);
|
|
4807
4807
|
if (!isLoopback(host) && !allowHosts.length) {
|
package/package.json
CHANGED
package/src/studio/public/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/// <reference lib="dom" />
|
|
3
3
|
// Studio's page: loads what the server gathered from the config, shows the place the address names, and loads again whenever the server says the config changed.
|
|
4
4
|
|
|
5
|
-
import { byId, go } from "./dom.js";
|
|
5
|
+
import { byId, enter, go } from "./dom.js";
|
|
6
6
|
import { currentLayer, isLayer, setGraph, showLayer } from "./graph.js";
|
|
7
7
|
import { problemCount, showTables } from "./tables.js";
|
|
8
8
|
import { showTools } from "./tools.js";
|
|
@@ -99,6 +99,9 @@ function route() {
|
|
|
99
99
|
else if (view === "compare") showCompare(parts, tables, studio);
|
|
100
100
|
else showTables(parts, tables, studio);
|
|
101
101
|
tables.scrollTop = moved ? 0 : scrolled;
|
|
102
|
+
|
|
103
|
+
// A new place comes in; the same place drawn again, because the config changed, stays still.
|
|
104
|
+
if (moved) enter(tables);
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
window.addEventListener("hashchange", route);
|
|
@@ -159,7 +162,14 @@ events.addEventListener("error", () => {
|
|
|
159
162
|
live.classList.remove("on");
|
|
160
163
|
});
|
|
161
164
|
|
|
162
|
-
// The config changed: load it again. The page itself changed, while working on Circuit: load the page again.
|
|
163
|
-
events.addEventListener("message", (event) =>
|
|
165
|
+
// The config changed: load it again, and the live mark beats once to say so. The page itself changed, while working on Circuit: load the page again.
|
|
166
|
+
events.addEventListener("message", (event) => {
|
|
167
|
+
if (event.data === "page") return location.reload();
|
|
168
|
+
|
|
169
|
+
live.classList.remove("beat");
|
|
170
|
+
void live.offsetWidth;
|
|
171
|
+
live.classList.add("beat");
|
|
172
|
+
void load();
|
|
173
|
+
});
|
|
164
174
|
|
|
165
175
|
void load();
|
package/src/studio/public/dom.js
CHANGED
|
@@ -71,6 +71,26 @@ export function svgById(id) {
|
|
|
71
71
|
return element;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** The reader's system asks for less motion: nothing moves for show, and changes happen at once. */
|
|
75
|
+
export const still = matchMedia("(prefers-reduced-motion: reduce)");
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Bring a part of the page in as it changes: it fades in and rises a little.
|
|
79
|
+
*
|
|
80
|
+
* @param {Element} element
|
|
81
|
+
*/
|
|
82
|
+
export function enter(element) {
|
|
83
|
+
if (still.matches) return;
|
|
84
|
+
|
|
85
|
+
element.animate(
|
|
86
|
+
[
|
|
87
|
+
{ opacity: 0, transform: "translateY(6px)" },
|
|
88
|
+
{ opacity: 1, transform: "none" },
|
|
89
|
+
],
|
|
90
|
+
{ duration: 240, easing: "cubic-bezier(0.16, 1, 0.3, 1)" }
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
/** Browser storage can be missing or refuse, in a private window for example. The page works without it. */
|
|
75
95
|
export const store = {
|
|
76
96
|
/**
|
|
@@ -64,15 +64,21 @@ export function svgOf(svg, viewport) {
|
|
|
64
64
|
const originals = [svg, ...svg.querySelectorAll("*")];
|
|
65
65
|
const copies = [copy, ...copy.querySelectorAll("*")];
|
|
66
66
|
|
|
67
|
+
// The dots travelling the cables are left out: a still image would show each one stopped at the drawing's corner.
|
|
68
|
+
const moving = [];
|
|
69
|
+
|
|
67
70
|
for (const [index, original] of originals.entries()) {
|
|
68
71
|
const target = copies[index];
|
|
69
72
|
if (!(target instanceof SVGElement)) continue;
|
|
73
|
+
if (original.classList.contains("signal")) moving.push(target);
|
|
70
74
|
|
|
71
75
|
const look = getComputedStyle(original);
|
|
72
76
|
target.setAttribute("style", DRAWN.map((name) => `${name}:${look.getPropertyValue(name)}`).join(";"));
|
|
73
77
|
target.removeAttribute("class");
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
for (const dot of moving) dot.remove();
|
|
81
|
+
|
|
76
82
|
// The drawing's own place on screen is left out: the file is framed around what is drawn.
|
|
77
83
|
copy.querySelector("#viewport")?.removeAttribute("transform");
|
|
78
84
|
copy.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/// <reference lib="dom" />
|
|
3
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
4
|
|
|
5
|
-
import { byId, go, h, link, s, store, svgById } from "./dom.js";
|
|
5
|
+
import { byId, enter, go, h, link, s, still, store, svgById } from "./dom.js";
|
|
6
6
|
import { changeKey } from "./evaluate.js";
|
|
7
7
|
import { pngOf, save, svgOf } from "./export.js";
|
|
8
8
|
|
|
@@ -51,6 +51,8 @@ const viewport = svgById("viewport");
|
|
|
51
51
|
const edgeLayer = svgById("edges");
|
|
52
52
|
const nodeLayer = svgById("nodes");
|
|
53
53
|
const panel = byId("panel");
|
|
54
|
+
const presentButton = byId("present");
|
|
55
|
+
const caption = byId("present-caption");
|
|
54
56
|
|
|
55
57
|
// State.
|
|
56
58
|
/** @type {Annotated | undefined} */
|
|
@@ -73,6 +75,12 @@ let pendingPath;
|
|
|
73
75
|
let pathFocus;
|
|
74
76
|
/** A comparison with another commit: its name, the edges it adds, and those gone. @type {{ name: string, added: Set<string>, removed: GraphEdge[] } | undefined} */
|
|
75
77
|
let comparison;
|
|
78
|
+
/** 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. */
|
|
79
|
+
let presenting = store.get("present") === true;
|
|
80
|
+
/** The device lit while presenting; none while the whole layer is shown. @type {string | undefined} */
|
|
81
|
+
let touring;
|
|
82
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
83
|
+
let tour;
|
|
76
84
|
|
|
77
85
|
/** @param {GraphEdge} edge */
|
|
78
86
|
const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
@@ -325,6 +333,9 @@ const MOST_APART = 3;
|
|
|
325
333
|
/** @type {Record<Layer, string>} */
|
|
326
334
|
const MANY = { l1: "cables", l2: "cables", l3: "shared prefixes", bgp: "sessions", ospf: "adjacencies", tunnels: "tunnels" };
|
|
327
335
|
|
|
336
|
+
/** @type {Record<Layer, string>} */
|
|
337
|
+
const ONE = { l1: "cable", l2: "cable", l3: "shared prefix", bgp: "session", ospf: "adjacency", tunnels: "tunnel" };
|
|
338
|
+
|
|
328
339
|
/**
|
|
329
340
|
* The edges to draw: each on its own, or, past a few between the same two nodes, one for all of them.
|
|
330
341
|
*
|
|
@@ -363,7 +374,101 @@ function bundled(edges) {
|
|
|
363
374
|
});
|
|
364
375
|
}
|
|
365
376
|
|
|
366
|
-
|
|
377
|
+
/**
|
|
378
|
+
* 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.
|
|
379
|
+
*
|
|
380
|
+
* @param {DrawnEdge} edge
|
|
381
|
+
* @param {string} id the id of the cable's drawn path, which the dots follow as it moves
|
|
382
|
+
*/
|
|
383
|
+
function signals(edge, id) {
|
|
384
|
+
if (still.matches || edge.oneSided || edge.gone) return [];
|
|
385
|
+
|
|
386
|
+
const a = positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
387
|
+
const b = positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
388
|
+
const seconds = Math.min(Math.max(Math.hypot(b.x - a.x, b.y - a.y) / (presenting ? 110 : 45), 1.6), 12);
|
|
389
|
+
const phase = hash(`${edge.from}${edge.to}${edge.label}`) * seconds;
|
|
390
|
+
const backwards = presenting ? [false, true] : [hash(edge.label) < 0.5];
|
|
391
|
+
|
|
392
|
+
return backwards.map((back, index) => {
|
|
393
|
+
const dot = s("circle", { class: "signal", r: 3.5 });
|
|
394
|
+
const motion = s("animateMotion", {
|
|
395
|
+
dur: `${seconds.toFixed(2)}s`,
|
|
396
|
+
begin: `-${(phase + (index * seconds) / 2).toFixed(2)}s`,
|
|
397
|
+
repeatCount: "indefinite",
|
|
398
|
+
...(back ? { keyPoints: "1;0", keyTimes: "0;1", calcMode: "linear" } : {}),
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
motion.append(s("mpath", { href: `#${id}` }));
|
|
402
|
+
dot.append(motion);
|
|
403
|
+
return dot;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A confident arrival: fast at first, settling at the end. */
|
|
408
|
+
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Draw the network in from its busiest device outward, a ring of neighbours at a time, each cable drawn from its nearer end.
|
|
412
|
+
*
|
|
413
|
+
* @param {Set<string>} fresh the nodes this drawing adds, which fade in; the others are already on screen
|
|
414
|
+
*/
|
|
415
|
+
function arrive(fresh) {
|
|
416
|
+
if (still.matches || !edgeElements.length) return;
|
|
417
|
+
|
|
418
|
+
/** @param {string} id */
|
|
419
|
+
const degree = (id) => edgeElements.filter(({ edge }) => edge.from === id || edge.to === id).length;
|
|
420
|
+
const hub = [...nodeElements.keys()].sort((a, b) => degree(b) - degree(a))[0];
|
|
421
|
+
|
|
422
|
+
/** @type {Map<string, number>} */
|
|
423
|
+
const ring = new Map(hub ? [[hub, 0]] : []);
|
|
424
|
+
for (let queue = hub ? [hub] : []; queue.length;) {
|
|
425
|
+
const id = /** @type {string} */ (queue.shift());
|
|
426
|
+
for (const { edge } of edgeElements) {
|
|
427
|
+
const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined;
|
|
428
|
+
if (other === undefined || ring.has(other)) continue;
|
|
429
|
+
ring.set(other, (ring.get(id) ?? 0) + 1);
|
|
430
|
+
queue.push(other);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The whole drawing arrives within about a second, however many rings it has.
|
|
435
|
+
const farthest = Math.max(0, ...ring.values()) + 1;
|
|
436
|
+
const step = Math.min(110, 800 / farthest);
|
|
437
|
+
/** @param {string} id */
|
|
438
|
+
const ringOf = (id) => ring.get(id) ?? farthest;
|
|
439
|
+
|
|
440
|
+
for (const [id, group] of nodeElements) {
|
|
441
|
+
if (!fresh.has(id)) continue;
|
|
442
|
+
group.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 360, delay: ringOf(id) * step, easing: EASE, fill: "backwards" });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
for (const { edge, group, path, label } of edgeElements) {
|
|
446
|
+
const forward = ringOf(edge.from) <= ringOf(edge.to);
|
|
447
|
+
const delay = (Math.min(ringOf(edge.from), ringOf(edge.to)) + 0.5) * step;
|
|
448
|
+
|
|
449
|
+
if (edge.oneSided || edge.gone) {
|
|
450
|
+
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
|
|
451
|
+
} else {
|
|
452
|
+
const length = path.getTotalLength();
|
|
453
|
+
const start = { strokeDasharray: `${length} ${length}`, strokeDashoffset: forward ? length : -length };
|
|
454
|
+
const end = { strokeDasharray: `${length} ${length}`, strokeDashoffset: 0 };
|
|
455
|
+
path.animate([start, end], { duration: 560, delay, easing: EASE, fill: "backwards" });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
459
|
+
part.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, delay: delay + 420, fill: "backwards" });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 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.
|
|
466
|
+
*
|
|
467
|
+
* @param {"network" | "cables"} [arrival]
|
|
468
|
+
*/
|
|
469
|
+
function draw(arrival) {
|
|
470
|
+
const before = new Set(nodeElements.keys());
|
|
471
|
+
|
|
367
472
|
edgeLayer.replaceChildren();
|
|
368
473
|
nodeLayer.replaceChildren();
|
|
369
474
|
nodeElements.clear();
|
|
@@ -380,7 +485,7 @@ function draw() {
|
|
|
380
485
|
/** @type {Map<string, number>} */
|
|
381
486
|
const seen = new Map();
|
|
382
487
|
|
|
383
|
-
for (const edge of edges) {
|
|
488
|
+
for (const [number, edge] of edges.entries()) {
|
|
384
489
|
const pair = pairOf(edge);
|
|
385
490
|
const index = seen.get(pair) ?? 0;
|
|
386
491
|
seen.set(pair, index + 1);
|
|
@@ -393,11 +498,11 @@ function draw() {
|
|
|
393
498
|
const change = edge.gone ? " gone" : added ? " added" : "";
|
|
394
499
|
const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}${change}` });
|
|
395
500
|
const hit = s("path", { class: "hit" });
|
|
396
|
-
const path = s("path");
|
|
501
|
+
const path = s("path", { id: `cable-${number}` });
|
|
397
502
|
const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
398
503
|
label.textContent = edge.label;
|
|
399
504
|
|
|
400
|
-
group.append(path, hit, label);
|
|
505
|
+
group.append(path, hit, ...signals(edge, `cable-${number}`), label);
|
|
401
506
|
|
|
402
507
|
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
403
508
|
group.addEventListener("pointerdown", (event) => {
|
|
@@ -466,11 +571,13 @@ function draw() {
|
|
|
466
571
|
|
|
467
572
|
place();
|
|
468
573
|
emphasize();
|
|
574
|
+
|
|
575
|
+
if (arrival) arrive(new Set([...nodeElements.keys()].filter((id) => arrival === "network" || !before.has(id))));
|
|
469
576
|
}
|
|
470
577
|
|
|
471
|
-
/** Light up what the pointer is on, or what is selected, and dim the rest. */
|
|
578
|
+
/** Light up what the pointer is on, the device lit while presenting, or what is selected, and dim the rest. */
|
|
472
579
|
function emphasize() {
|
|
473
|
-
const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
|
|
580
|
+
const focus = hovered ?? touring ?? (selection && "node" in selection ? selection.node : undefined);
|
|
474
581
|
const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
|
|
475
582
|
|
|
476
583
|
/** @param {GraphEdge} edge */
|
|
@@ -525,6 +632,8 @@ const toWorld = (event) => {
|
|
|
525
632
|
*/
|
|
526
633
|
function startDrag(event, id) {
|
|
527
634
|
if (event.button !== 0) return;
|
|
635
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
636
|
+
gliding = undefined;
|
|
528
637
|
event.stopPropagation();
|
|
529
638
|
|
|
530
639
|
svg.setPointerCapture(event.pointerId);
|
|
@@ -568,6 +677,8 @@ svg.addEventListener(
|
|
|
568
677
|
"wheel",
|
|
569
678
|
(event) => {
|
|
570
679
|
event.preventDefault();
|
|
680
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
681
|
+
gliding = undefined;
|
|
571
682
|
|
|
572
683
|
const point = toWorld(event);
|
|
573
684
|
const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
|
|
@@ -582,17 +693,75 @@ svg.addEventListener(
|
|
|
582
693
|
/** 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. */
|
|
583
694
|
const READABLE = 0.6;
|
|
584
695
|
|
|
696
|
+
/** The view moving to a new place, which a press or a scroll stops. @type {number | undefined} */
|
|
697
|
+
let gliding;
|
|
698
|
+
|
|
585
699
|
/**
|
|
586
|
-
*
|
|
700
|
+
* 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.
|
|
701
|
+
*
|
|
702
|
+
* @param {typeof view} target
|
|
703
|
+
*/
|
|
704
|
+
function glideTo(target) {
|
|
705
|
+
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
706
|
+
gliding = undefined;
|
|
707
|
+
|
|
708
|
+
if (still.matches) {
|
|
709
|
+
view = target;
|
|
710
|
+
place();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const box = svg.getBoundingClientRect();
|
|
715
|
+
const from = { ...view };
|
|
716
|
+
/** @param {typeof view} one */
|
|
717
|
+
const centreOf = (one) => ({ x: (box.width / 2 - one.x) / one.k, y: (box.height / 2 - one.y) / one.k });
|
|
718
|
+
const start = centreOf(from);
|
|
719
|
+
const end = centreOf(target);
|
|
720
|
+
const began = performance.now();
|
|
721
|
+
|
|
722
|
+
/** @param {number} now */
|
|
723
|
+
const step = (now) => {
|
|
724
|
+
const t = Math.min((now - began) / 1100, 1);
|
|
725
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
726
|
+
const k = from.k * (target.k / from.k) ** eased;
|
|
727
|
+
const centre = { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased };
|
|
728
|
+
|
|
729
|
+
view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
730
|
+
place();
|
|
731
|
+
gliding = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
gliding = requestAnimationFrame(step);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* 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.
|
|
587
739
|
*
|
|
588
740
|
* @param {boolean} [readable]
|
|
741
|
+
* @param {boolean} [glide]
|
|
589
742
|
*/
|
|
590
|
-
function fit(readable = false) {
|
|
591
|
-
const
|
|
743
|
+
function fit(readable = false, glide = false) {
|
|
744
|
+
const target = framing(visibleNodes(), readable);
|
|
745
|
+
if (!target) return;
|
|
746
|
+
|
|
747
|
+
if (glide) return glideTo(target);
|
|
748
|
+
view = target;
|
|
749
|
+
place();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The view that frames these nodes.
|
|
754
|
+
*
|
|
755
|
+
* @param {GraphNode[]} nodes
|
|
756
|
+
* @param {boolean} readable
|
|
757
|
+
* @returns {typeof view | undefined}
|
|
758
|
+
*/
|
|
759
|
+
function framing(nodes, readable) {
|
|
760
|
+
const points = nodes.flatMap((node) => {
|
|
592
761
|
const point = positions.get(node.id);
|
|
593
762
|
return point ? [point] : [];
|
|
594
763
|
});
|
|
595
|
-
if (!points.length) return;
|
|
764
|
+
if (!points.length) return undefined;
|
|
596
765
|
|
|
597
766
|
const box = svg.getBoundingClientRect();
|
|
598
767
|
const xs = points.map((point) => point.x);
|
|
@@ -605,13 +774,32 @@ function fit(readable = false) {
|
|
|
605
774
|
// When the whole drawing does not fit readably, the view starts on the device with the most lines, the heart of the network.
|
|
606
775
|
const edges = visibleEdges();
|
|
607
776
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
608
|
-
const hub = [...
|
|
777
|
+
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
609
778
|
const centre =
|
|
610
779
|
k > whole && hub && positions.has(hub.id)
|
|
611
780
|
? /** @type {Point} */ (positions.get(hub.id))
|
|
612
781
|
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
613
782
|
|
|
614
|
-
|
|
783
|
+
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
784
|
+
const shown = presenting ? k * 0.86 : k;
|
|
785
|
+
|
|
786
|
+
return { k: shown, x: box.width / 2 - centre.x * shown, y: box.height / 2 - centre.y * shown };
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** While presenting, frame what is shown: the lit device with everything it has a line to, or the whole layer. */
|
|
790
|
+
function frameTour(glide = true) {
|
|
791
|
+
const lit = touring;
|
|
792
|
+
if (!lit) return fit(false, glide);
|
|
793
|
+
|
|
794
|
+
const near = new Set([lit, ...visibleEdges().flatMap((edge) => (edge.from === lit ? [edge.to] : edge.to === lit ? [edge.from] : []))]);
|
|
795
|
+
const target = framing(
|
|
796
|
+
visibleNodes().filter((node) => near.has(node.id)),
|
|
797
|
+
false
|
|
798
|
+
);
|
|
799
|
+
if (!target) return;
|
|
800
|
+
|
|
801
|
+
if (glide) return glideTo(target);
|
|
802
|
+
view = target;
|
|
615
803
|
place();
|
|
616
804
|
}
|
|
617
805
|
|
|
@@ -624,7 +812,101 @@ byId("lay-out").addEventListener("click", () => {
|
|
|
624
812
|
fit();
|
|
625
813
|
});
|
|
626
814
|
|
|
627
|
-
|
|
815
|
+
// Presenting keeps the whole network in view as the screen changes size.
|
|
816
|
+
window.addEventListener("resize", () => (presenting ? frameTour(false) : place()));
|
|
817
|
+
|
|
818
|
+
// Presenting.
|
|
819
|
+
|
|
820
|
+
/** Say what is shown while presenting: the device lit, or the whole layer. */
|
|
821
|
+
function showCaption() {
|
|
822
|
+
caption.hidden = !presenting;
|
|
823
|
+
if (!presenting) return;
|
|
824
|
+
|
|
825
|
+
const node = touring ? nodeById(touring) : undefined;
|
|
826
|
+
const edges = visibleEdges().filter((edge) => !node || edge.from === node.id || edge.to === node.id);
|
|
827
|
+
const count = `${edges.length} ${edges.length === 1 ? ONE[layer] : MANY[layer]}`;
|
|
828
|
+
|
|
829
|
+
if (node) caption.replaceChildren(h("strong", {}, node.label), h("span", {}, [node.detail[0], count].filter(Boolean).join(", ")));
|
|
830
|
+
else
|
|
831
|
+
caption.replaceChildren(
|
|
832
|
+
h("strong", {}, LAYER_NAMES[layer]),
|
|
833
|
+
h("span", {}, `${visibleNodes().filter((one) => one.kind === "device").length} devices, ${count}`)
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
enter(caption);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** How many devices are lit on each layer while presenting, busiest first, before it moves on to the next layer. */
|
|
840
|
+
const LIT_PER_LAYER = 4;
|
|
841
|
+
|
|
842
|
+
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
843
|
+
let tourStep = 0;
|
|
844
|
+
|
|
845
|
+
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
846
|
+
function nextLayer() {
|
|
847
|
+
const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
|
|
848
|
+
return after.find((name) => data?.edges.some((edge) => edge.layer === name)) ?? layer;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
852
|
+
function tourNext() {
|
|
853
|
+
if (graphView.hidden || document.hidden || hovered || drag) return;
|
|
854
|
+
|
|
855
|
+
const edges = visibleEdges();
|
|
856
|
+
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
857
|
+
const devices = visibleNodes()
|
|
858
|
+
.filter((node) => node.kind === "device" && degree(node.id))
|
|
859
|
+
.sort((a, b) => degree(b.id) - degree(a.id) || a.label.localeCompare(b.label))
|
|
860
|
+
.slice(0, LIT_PER_LAYER);
|
|
861
|
+
|
|
862
|
+
tourStep += 1;
|
|
863
|
+
touring = devices[tourStep - 1]?.id;
|
|
864
|
+
|
|
865
|
+
if (touring || nextLayer() === layer) {
|
|
866
|
+
if (!touring) tourStep = 0;
|
|
867
|
+
emphasize();
|
|
868
|
+
showCaption();
|
|
869
|
+
frameTour();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Every lit device has had its turn. Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
|
|
874
|
+
location.replace(`#graph/${nextLayer()}`);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** @param {boolean} on */
|
|
878
|
+
function setPresenting(on) {
|
|
879
|
+
presenting = on;
|
|
880
|
+
store.set("present", on);
|
|
881
|
+
|
|
882
|
+
document.body.classList.toggle("presenting", on);
|
|
883
|
+
presentButton.setAttribute("aria-pressed", String(on));
|
|
884
|
+
|
|
885
|
+
clearInterval(tour);
|
|
886
|
+
touring = undefined;
|
|
887
|
+
if (on) {
|
|
888
|
+
selection = undefined;
|
|
889
|
+
tour = setInterval(tourNext, 6000);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
if (graphView.hidden || !data) return;
|
|
893
|
+
|
|
894
|
+
// The panel hides or returns, so the drawing is fitted once the page has its new size.
|
|
895
|
+
draw();
|
|
896
|
+
showPanel();
|
|
897
|
+
showCaption();
|
|
898
|
+
requestAnimationFrame(() => (presenting ? frameTour(false) : fit()));
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
presentButton.addEventListener("click", () => setPresenting(!presenting));
|
|
902
|
+
setPresenting(presenting);
|
|
903
|
+
|
|
904
|
+
// A hidden page's dots stop moving.
|
|
905
|
+
document.addEventListener("visibilitychange", () => {
|
|
906
|
+
if (!(svg instanceof SVGSVGElement)) return;
|
|
907
|
+
if (document.hidden) svg.pauseAnimations();
|
|
908
|
+
else svg.unpauseAnimations();
|
|
909
|
+
});
|
|
628
910
|
|
|
629
911
|
// Saving the layer shown, as it looks.
|
|
630
912
|
byId("save-svg").addEventListener("click", () => {
|
|
@@ -640,10 +922,16 @@ byId("save-png").addEventListener("click", async () => {
|
|
|
640
922
|
// The side panel.
|
|
641
923
|
/** @param {Selection} next */
|
|
642
924
|
function select(next) {
|
|
925
|
+
/** @param {Selection} one */
|
|
926
|
+
const keyOf = (one) => (one && "node" in one ? one.node : one ? edgeKey(one.edge) : "");
|
|
927
|
+
const changed = keyOf(next) !== keyOf(selection);
|
|
928
|
+
|
|
643
929
|
selection = next;
|
|
644
930
|
pathFocus = undefined;
|
|
645
931
|
emphasize();
|
|
646
932
|
showPanel();
|
|
933
|
+
|
|
934
|
+
if (changed) enter(panel);
|
|
647
935
|
}
|
|
648
936
|
|
|
649
937
|
/**
|
|
@@ -882,7 +1170,12 @@ function showLayerButtons() {
|
|
|
882
1170
|
document.addEventListener("keydown", (event) => {
|
|
883
1171
|
if (event.target instanceof HTMLInputElement || graphView.hidden) return;
|
|
884
1172
|
|
|
885
|
-
if (event.key === "Escape")
|
|
1173
|
+
if (event.key === "Escape") {
|
|
1174
|
+
if (presenting) setPresenting(false);
|
|
1175
|
+
select(undefined);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (event.key === "p" && !event.ctrlKey && !event.metaKey && !event.altKey) setPresenting(!presenting);
|
|
886
1179
|
|
|
887
1180
|
// Keys 1 to 6 pick a layer.
|
|
888
1181
|
const chosen = LAYERS[Number(event.key) - 1];
|
|
@@ -920,6 +1213,8 @@ export function showLayer(next) {
|
|
|
920
1213
|
if (selection && "edge" in selection) selection = undefined;
|
|
921
1214
|
vlanFocus = undefined;
|
|
922
1215
|
pathFocus = undefined;
|
|
1216
|
+
touring = undefined;
|
|
1217
|
+
tourStep = 0;
|
|
923
1218
|
}
|
|
924
1219
|
|
|
925
1220
|
if (pendingVlan !== undefined) {
|
|
@@ -932,14 +1227,20 @@ export function showLayer(next) {
|
|
|
932
1227
|
pendingPath = undefined;
|
|
933
1228
|
}
|
|
934
1229
|
|
|
1230
|
+
// The first view draws the whole network in; a new layer draws in its own lines.
|
|
1231
|
+
const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
|
|
1232
|
+
|
|
935
1233
|
layer = next;
|
|
936
1234
|
store.set("layer", layer);
|
|
937
1235
|
|
|
938
1236
|
showLayerButtons();
|
|
939
|
-
draw();
|
|
1237
|
+
draw(arrival);
|
|
940
1238
|
showPanel();
|
|
1239
|
+
showCaption();
|
|
941
1240
|
|
|
942
|
-
|
|
1241
|
+
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read.
|
|
1242
|
+
if (presenting && arrival) fit();
|
|
1243
|
+
else if (!drawn && data) fit(true);
|
|
943
1244
|
drawn = data !== undefined;
|
|
944
1245
|
}
|
|
945
1246
|
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
<meta charset="utf-8" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
7
|
<title>Circuit Studio</title>
|
|
8
|
+
<!-- Studio shows one team's network, so search engines are asked to leave it out. -->
|
|
9
|
+
<meta name="robots" content="noindex, nofollow, noarchive" />
|
|
8
10
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
9
11
|
<!-- Special Gothic is shared with the documentation site, and served from beside this page so Studio works offline. -->
|
|
10
12
|
<link rel="preload" href="special-gothic-latin.woff2" as="font" type="font/woff2" crossorigin />
|
|
@@ -43,6 +45,13 @@
|
|
|
43
45
|
<div class="actions">
|
|
44
46
|
<button id="fit" title="Fit the drawing to the window">Fit</button>
|
|
45
47
|
<button id="lay-out" title="Forget where nodes were moved, and lay them out again">Lay out again</button>
|
|
48
|
+
<button
|
|
49
|
+
id="present"
|
|
50
|
+
aria-pressed="false"
|
|
51
|
+
title="Show the network on a big screen: the panel hides, and the drawing goes through every layer, lighting its busiest devices in turn. Key P."
|
|
52
|
+
>
|
|
53
|
+
Present
|
|
54
|
+
</button>
|
|
46
55
|
<details class="menu">
|
|
47
56
|
<summary>Save image</summary>
|
|
48
57
|
<div class="menu-items">
|
|
@@ -59,6 +68,7 @@
|
|
|
59
68
|
<g id="nodes"></g>
|
|
60
69
|
</g>
|
|
61
70
|
</svg>
|
|
71
|
+
<p id="present-caption" class="present-caption" aria-live="polite" hidden></p>
|
|
62
72
|
<aside id="panel"></aside>
|
|
63
73
|
</div>
|
|
64
74
|
</section>
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
--surface: #ffffff;
|
|
40
40
|
--surface-raised: #f4f6fb;
|
|
41
41
|
--line: #d3d9e7;
|
|
42
|
+
--head: #e1e7f3;
|
|
43
|
+
--hover: rgba(10, 71, 184, 0.045);
|
|
42
44
|
--text: #11141b;
|
|
43
45
|
--muted: #49536a;
|
|
44
46
|
--edge: #7b86a0;
|
|
@@ -67,6 +69,8 @@
|
|
|
67
69
|
--surface: #121a30;
|
|
68
70
|
--surface-raised: #182241;
|
|
69
71
|
--line: #26314d;
|
|
72
|
+
--head: #26325a;
|
|
73
|
+
--hover: rgba(156, 185, 255, 0.05);
|
|
70
74
|
--text: #f1f4fb;
|
|
71
75
|
--muted: #a9b4cc;
|
|
72
76
|
--edge: #7c89a8;
|
|
@@ -258,6 +262,14 @@ header > nav button[aria-pressed="true"]::after {
|
|
|
258
262
|
left: 9px;
|
|
259
263
|
height: 4px;
|
|
260
264
|
background: var(--yellow);
|
|
265
|
+
animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* The bar under the view or tab just chosen grows out from its middle. */
|
|
269
|
+
@keyframes bar {
|
|
270
|
+
from {
|
|
271
|
+
transform: scaleX(0);
|
|
272
|
+
}
|
|
261
273
|
}
|
|
262
274
|
|
|
263
275
|
.count {
|
|
@@ -393,6 +405,18 @@ header input[type="search"]:focus {
|
|
|
393
405
|
background: var(--ok);
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
/* When a changed config arrives, the live mark beats once in yellow. */
|
|
409
|
+
.status-rail .live.beat::before {
|
|
410
|
+
animation: beat 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
@keyframes beat {
|
|
414
|
+
30% {
|
|
415
|
+
background: var(--yellow);
|
|
416
|
+
transform: scale(1.9);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
396
420
|
.banner {
|
|
397
421
|
margin: 0;
|
|
398
422
|
padding: 10px 18px;
|
|
@@ -537,9 +561,11 @@ main {
|
|
|
537
561
|
/* The drawing board. */
|
|
538
562
|
|
|
539
563
|
.stage {
|
|
564
|
+
position: relative;
|
|
540
565
|
flex: 1;
|
|
541
566
|
display: flex;
|
|
542
567
|
min-height: 0;
|
|
568
|
+
overflow: hidden;
|
|
543
569
|
background-color: var(--bg);
|
|
544
570
|
background-image: radial-gradient(circle, var(--dots) 1.2px, transparent 1.6px);
|
|
545
571
|
background-size: 24px 24px;
|
|
@@ -562,6 +588,33 @@ svg.canvas.panning {
|
|
|
562
588
|
fill: none;
|
|
563
589
|
stroke: var(--edge);
|
|
564
590
|
stroke-width: 2;
|
|
591
|
+
transition:
|
|
592
|
+
stroke 0.2s,
|
|
593
|
+
stroke-width 0.2s;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/* Lighting one part dims the rest gently, so the eye follows what changed. */
|
|
597
|
+
.edge,
|
|
598
|
+
.node {
|
|
599
|
+
transition: opacity 0.25s ease-out;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/* Dots travelling a cable both ends declare: traffic where the link works. On a lit cable they turn to the highlighter. */
|
|
603
|
+
.edge .signal {
|
|
604
|
+
fill: var(--accent);
|
|
605
|
+
pointer-events: none;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
.edge.lit .signal,
|
|
609
|
+
.edge.selected .signal {
|
|
610
|
+
r: 4.5px;
|
|
611
|
+
fill: var(--yellow);
|
|
612
|
+
stroke: var(--navy);
|
|
613
|
+
stroke-width: 1.5;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.edge.added .signal {
|
|
617
|
+
fill: var(--ok);
|
|
565
618
|
}
|
|
566
619
|
|
|
567
620
|
.edge text {
|
|
@@ -695,6 +748,78 @@ svg.far .edge text {
|
|
|
695
748
|
display: none;
|
|
696
749
|
}
|
|
697
750
|
|
|
751
|
+
/* Presenting: the network for a big screen. The panel steps aside, lines and dots are heavier, and the drawing drifts slowly. */
|
|
752
|
+
|
|
753
|
+
.presenting #panel {
|
|
754
|
+
display: none;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.presenting .edge path:not(.hit) {
|
|
758
|
+
stroke-width: 3;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.presenting .edge.lit path:not(.hit) {
|
|
762
|
+
stroke-width: 5;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.presenting .edge .signal {
|
|
766
|
+
r: 4.5px;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
.presenting .edge.lit .signal {
|
|
770
|
+
r: 6px;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.present-caption {
|
|
774
|
+
position: absolute;
|
|
775
|
+
bottom: 22px;
|
|
776
|
+
left: 22px;
|
|
777
|
+
display: grid;
|
|
778
|
+
gap: 2px;
|
|
779
|
+
margin: 0;
|
|
780
|
+
padding: 12px 18px 14px;
|
|
781
|
+
border: 2px solid var(--text);
|
|
782
|
+
border-radius: 2px;
|
|
783
|
+
background: var(--surface);
|
|
784
|
+
pointer-events: none;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
.present-caption[hidden] {
|
|
788
|
+
display: none;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.present-caption strong {
|
|
792
|
+
font-size: 26px;
|
|
793
|
+
letter-spacing: -0.015em;
|
|
794
|
+
line-height: 1.15;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
.present-caption span {
|
|
798
|
+
color: var(--muted);
|
|
799
|
+
font-size: 14px;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
803
|
+
.presenting svg.canvas {
|
|
804
|
+
animation: drift 48s ease-in-out infinite alternate;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
.presenting svg.canvas:hover,
|
|
808
|
+
.presenting svg.canvas.panning {
|
|
809
|
+
animation-play-state: paused;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
@keyframes drift {
|
|
814
|
+
from {
|
|
815
|
+
transform: scale(1) translate(-1%, -0.6%);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
to {
|
|
819
|
+
transform: scale(1.05) translate(1%, 0.6%);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
698
823
|
/* The side panel: what is selected, or the layer's legend. */
|
|
699
824
|
|
|
700
825
|
aside {
|
|
@@ -898,6 +1023,7 @@ aside pre {
|
|
|
898
1023
|
left: 8px;
|
|
899
1024
|
height: 4px;
|
|
900
1025
|
background: var(--cobalt);
|
|
1026
|
+
animation: bar 0.28s cubic-bezier(0.16, 1, 0.3, 1);
|
|
901
1027
|
}
|
|
902
1028
|
|
|
903
1029
|
.table-block {
|
|
@@ -934,9 +1060,10 @@ table.data td {
|
|
|
934
1060
|
vertical-align: top;
|
|
935
1061
|
}
|
|
936
1062
|
|
|
1063
|
+
/* The head is a tinted band rather than a rule, so it is never read as a divider between rows. */
|
|
937
1064
|
table.data thead th {
|
|
938
|
-
border-bottom:
|
|
939
|
-
background: var(--
|
|
1065
|
+
border-bottom: 0;
|
|
1066
|
+
background: var(--head);
|
|
940
1067
|
color: var(--text);
|
|
941
1068
|
font-size: 11px;
|
|
942
1069
|
font-weight: 700;
|
|
@@ -952,9 +1079,10 @@ table.data th[title] {
|
|
|
952
1079
|
text-underline-offset: 3px;
|
|
953
1080
|
}
|
|
954
1081
|
|
|
955
|
-
|
|
956
|
-
table.data tbody tr:hover >
|
|
957
|
-
|
|
1082
|
+
/* Only the row under the pointer is washed, not every row that holds it, and the wash is a faint link tint well apart from the head's band. */
|
|
1083
|
+
table.data tbody tr:hover:not(:has(tr:hover)) > td,
|
|
1084
|
+
table.data tbody tr:hover:not(:has(tr:hover)) > th {
|
|
1085
|
+
background: var(--hover);
|
|
958
1086
|
}
|
|
959
1087
|
|
|
960
1088
|
table.data tr.muted td {
|
package/src/studio/server.ts
CHANGED
|
@@ -110,6 +110,9 @@ export async function studio(options: StudioOptions): Promise<void> {
|
|
|
110
110
|
};
|
|
111
111
|
|
|
112
112
|
const server = createServer((request, response) => {
|
|
113
|
+
// Studio shows one team's network: every answer asks search engines to leave it out, and robots.txt says so for the whole site.
|
|
114
|
+
response.setHeader("x-robots-tag", "noindex, nofollow, noarchive");
|
|
115
|
+
|
|
113
116
|
if (!hostAllowed(request.headers.host, port, options.allowHosts)) {
|
|
114
117
|
response.writeHead(403).end();
|
|
115
118
|
return;
|
|
@@ -154,6 +157,11 @@ export async function studio(options: StudioOptions): Promise<void> {
|
|
|
154
157
|
return;
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
if (path === "/robots.txt") {
|
|
161
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("User-agent: *\nDisallow: /\n");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
if (path === "/data") {
|
|
158
166
|
response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(data);
|
|
159
167
|
return;
|