xo-umbrella2/xo-websock/example/introspect/mount-origin/introspect.js
Roland Conybeare f4965945be
Some checks failed
cmake-docker / cmake-build (clang, clang++) (push) Failing after 42m55s
cmake-docker / cmake-build (gcc, g++) (push) Successful in 49m1s
CI / smoke-test (push) Has been cancelled
xo-websock: introspect .js relies entirely on _members_ [REFACTOR]
2026-10-11 14:48:01 -04:00

2271 lines
96 KiB
JavaScript

// introspect.js -- page side of xo-websock/example/introspect
//
// Protocol (xo-websock, .xo-backlog/xo-websock/issues/04, 06):
// -> {"cmd": "subscribe", "stream": "/introspect"}
// <- {"cmd": "subscribed", "stream": "/introspect", "sub_id": N}
// -> {"cmd": "send", "sub_id": N, "msg": "refresh"}
// <- {"stream": "/introspect", "sub_id": N, "seq": k,
// "event": {"server": <Webserver json>}}
//
// Webserver json: a reflected struct, {_id_, _canonical_type_, _members_:
// [{_name_, _value_, ..}, ..]}. The page reads "_members_" alone
// (.xo-backlog/xo-websock/issues/17) -- what it draws, and from where:
// server listen_port_, state_
// endpoints url_router_ -> http_map_, stream_map_ (key: stem);
// each: kind_, uri_pattern_, receiver_
// sessions session_table_ -> session_map_ (key: session id);
// each: sender_ (-> open_), router_ -> subscription_v_
// subscriptions sub_id_, stream_name_, sink_ (-> sender_)
// Other keys beside "_members_" (endpoints, sessions, ..: the websock
// printers' view model, until .xo-backlog/xo-websock/issues/16) it ignores.
//
// Each object is printed in full once, with "_id_": n; elsewhere as
// {"_ref_": n}. Ids are numbers, 1, 2, .. within one snapshot
// (.xo-backlog/xo-printjson/issues/02). The page joins refs to objects by
// id, wherever printjson placed each object: in a member, or in a list
// beside them.
//
// Source links (.xo-backlog/xo-websock/issues/12): every object carries
// _canonical_type_, its C++ type's canonical name (and _short_type_, for
// display: xo-reflect's short name). /dyn/types maps a type -- template
// arguments stripped -- to {file, line}, and says how to link one ("link":
// a url template with {file}, {line}; null for none). Hover a box for its
// type and location; click to open the source.
//
// Context menu (.xo-backlog/xo-websock/issues/13): right-click a box -- or
// focus it (Tab) and press the Menu key / Shift+F10 -- for: open source, show
// its json, copy its type or id. Only boxes take over right-click; elsewhere
// the browser's menu is untouched (in Firefox, Shift+right-click always gets
// the browser's).
"use strict";
const status_el = document.getElementById("status");
const raw_el = document.getElementById("raw");
const refresh_btn = document.getElementById("refresh");
const srcinfo_el = document.getElementById("srcinfo");
let sub_id = null;
let last_event = null;
// the type -> source map and link template, from /dyn/types
let src = {types: {}, link: null, n_maps: 0};
function load_types() {
fetch("/dyn/types")
.then(r => r.json())
.then(j => {
src = {types: j.types || {}, link: j.link || null,
n_maps: (j.subsystems || []).length};
const n = Object.keys(src.types).length;
srcinfo_el.textContent =
(n === 0)
? "source links: none -- no type maps (configure with --enable-source-map)"
: (src.link === null)
? `source links: off -- ${n} types mapped; start with --src-tree=ROOT or --src-link=TEMPLATE`
: `source links: ${src.link} -- ${n} types from ${src.n_maps} subsystems`;
if (last_event)
draw(last_event);
})
.catch(e => { srcinfo_el.textContent = `source links: /dyn/types failed: ${e}`; });
}
/** {file, line, href} for canonical type name @p t, or null if unmapped **/
function source_of(t) {
if (!t)
return null;
// a template maps under its bare name
const loc = src.types[t.replace(/<.*$/, "")];
if (!loc)
return null;
const href = src.link && src.link
.replace("{file}", loc.file.split("/").map(encodeURIComponent).join("/"))
.replace("{line}", loc.line);
return {file: loc.file, line: loc.line, href: href};
}
const ws = new WebSocket(`ws://${location.host}/`, "lws-minimal");
ws.onopen = () => {
status_el.textContent = "connected; subscribing...";
ws.send(JSON.stringify({cmd: "subscribe", stream: "/introspect"}));
};
ws.onclose = () => {
status_el.textContent = "disconnected";
refresh_btn.disabled = true;
};
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.cmd === "subscribed") {
sub_id = msg.sub_id;
status_el.textContent = `subscribed (sub_id ${sub_id})`;
refresh_btn.disabled = false;
load_types();
refresh();
} else if (msg.error) {
status_el.textContent = `error: ${msg.error}`;
} else if ("event" in msg) {
raw_el.textContent = JSON.stringify(msg, null, 2);
last_event = msg.event;
draw(msg.event);
}
};
function refresh() {
// the maps too: a rebuild changes them
load_types();
if (sub_id !== null)
ws.send(JSON.stringify({cmd: "send", sub_id: sub_id, msg: "refresh"}));
}
refresh_btn.onclick = refresh;
document.getElementById("show-all").onclick = () => show_all_children(true);
document.getElementById("hide-all").onclick = () => show_all_children(false);
/** ids of the boxes whose member rows show their types inline -- name: Type
* [metatype] = value -- set from each box's menu ("Show types"); other
* boxes show name = value, the type on the name's tooltip and row menu.
* Kept across refreshes and collapse/expand, like `expanded`
**/
const typed_boxes = new Set();
/** the legend is drawn (checkbox "legend"); hidden, it reserves no column
* for Fit and the like
**/
let show_legend = true;
document.getElementById("show-legend").onchange = (ev) => {
show_legend = ev.target.checked;
if (last_event)
draw(last_event);
};
// layout: automatic, by ELK (elkjs, its layered algorithm) -- the page builds
// the object graph (boxes and edges) from the snapshot; ELK places the boxes,
// sized to their content, and routes the edges. Hand-placed columns could
// not grow a box; an expanded box (issue 13) and showing/hiding parts of the
// graph both need this. .xo-backlog/xo-websock/issues/13, step 2.
const elk = new ELK();
/** the value of member @p name of reflected struct @p obj, as printjson
* writes it: its "_members_" entry (.xo-backlog/xo-printjson/issues/07);
* undefined if there is none
**/
function member_value(obj, name) {
const m = (obj && obj._members_ || []).find(x => x._name_ === name);
return m ? m._value_ : undefined;
}
/** every object printed in full in @p v (one with "_id_"), wherever it
* landed, as a map id -> object
**/
function objects_by_id(v, out = new Map()) {
if (Array.isArray(v)) {
v.forEach(x => objects_by_id(x, out));
} else if (v && typeof v === "object" && !is_ref(v)) {
if (typeof v._id_ === "number")
out.set(v._id_, v);
for (const [k, x] of Object.entries(v))
if (k === "_members_")
x.forEach(m => objects_by_id(m._value_, out));
else if (!k.startsWith("_"))
objects_by_id(x, out);
}
return out;
}
/** replace, in the members of every object in @p roots, each object whose
* id is in @p hoist by a ref to it -- so a row refers to the object's own
* box, wherever the object landed. Recurses through member values,
* arrays and maps; not into a replaced object (a root itself)
**/
function hoist_to_refs(roots, hoist) {
const fix = (v) => {
if (Array.isArray(v)) {
v.forEach((x, i) => { v[i] = fix(x); });
} else if (v && typeof v === "object" && !is_ref(v)) {
if (hoist.has(v._id_))
return {_ref_: v._id_};
if (Array.isArray(v._members_))
v._members_.forEach(m => { if ("_value_" in m) m._value_ = fix(m._value_); });
else if (!("_name_" in v))
for (const k of Object.keys(v))
v[k] = fix(v[k]);
}
return v;
};
for (const r of roots)
(r._members_ || []).forEach(m => { if ("_value_" in m) m._value_ = fix(m._value_); });
}
/** the object graph for snapshot @p event: {nodes, edges}. Edge kinds:
* "link" the server's endpoints and sessions
* "owns" a session's sender and subscriptions; an endpoint's receiver;
* a subscription's sink
* (a subscription's endpoint is no edge here: its endpoint_ member's
* ref edge shows it)
*
* Reads "_members_" only (.xo-backlog/xo-websock/issues/17): structure
* from where each object is held -- endpoints in the url router's maps,
* sessions in the session table's, a session's subscriptions in its
* router's subscription_v_ -- and each object, by id, wherever printjson
* placed it (first encounter: in a member, or in a list beside them).
* Works on a copy of @p event: a boxed object found in place inside
* another's members becomes a ref there (hoist_to_refs)
**/
function layout(event) {
event = structuredClone(event);
const snap = member_value(event, "server"); // an IntrospectSnapshot
const by_id = objects_by_id(event);
/* the object @p v names: itself, or its ref's target; null if none */
const deref = (v) => is_ref(v) ? (by_id.get(v._ref_) || null) : (v || null);
/* @p v's id: a ref's target id, or an object's own */
const id_of = (v) => is_ref(v) ? v._ref_ : (v ? v._id_ : undefined);
/* member @p name of the object @p v names */
const mv = (v, name) => member_value(deref(v), name);
/* the [key, object] entries of map member @p name of @p obj, key order */
const map_entries = (obj, name) =>
Object.entries(mv(obj, name) || {}).map(([k, v]) => [k, deref(v)]).filter(([, v]) => v);
const server = deref(snap);
const nodes = [];
const edges = [];
const edge = (source, target, kind) => edges.push({source, target, kind});
nodes.push({id: "server", kind: "server",
label: `Webserver :${mv(server, "listen_port_")} (${mv(server, "state_")})`,
type: server._canonical_type_, obj: server});
const router = mv(server, "url_router_");
for (const [stem, ep] of [...map_entries(router, "http_map_"), ...map_entries(router, "stream_map_")]) {
const id = `${mv(ep, "kind_")}:${stem}`;
nodes.push({id: id, kind: mv(ep, "kind_"), label: mv(ep, "uri_pattern_"),
type: ep._canonical_type_, obj: ep});
edge("server", id, "link");
// its receiver (a stream endpoint's, if any): owned by the endpoint,
// named by its most-derived type; the receiver_ member refers to it
const receiver = deref(mv(ep, "receiver_"));
if (receiver) {
const rid = `${id}:receiver`;
nodes.push({id: rid, kind: "receiver", label: receiver._short_type_ || "receiver",
type: receiver._canonical_type_, obj: receiver, small: true});
edge(id, rid, "owns");
}
}
for (const [session_id, s] of map_entries(mv(server, "session_table_"), "session_map_")) {
const id = `session:${session_id}`;
const sender = deref(mv(s, "sender_"));
const open = sender && mv(sender, "open_");
nodes.push({id: id, kind: open ? "session" : "session closed",
label: `session ${session_id}`, type: s._canonical_type_, obj: s});
edge("server", id, "link");
if (sender) {
const snd = `${id}:sender`;
nodes.push({id: snd, kind: "sender", label: "sender",
type: sender._canonical_type_, obj: sender, small: true});
edge(id, snd, "owns");
}
for (const sub of (mv(mv(s, "router_"), "subscription_v_") || []).map(deref).filter(x => x)) {
const sid = `${id}:sub:${mv(sub, "sub_id_")}`;
const sink = deref(mv(sub, "sink_"));
// the sink's sender should be this session's: flag it if not
const astray = sender && sink && mv(sink, "sender_")
&& id_of(mv(sink, "sender_")) !== sender._id_;
nodes.push({id: sid, kind: astray ? "subscription astray" : "subscription",
label: `sub ${mv(sub, "sub_id_")} · ${mv(sub, "stream_name_")}`,
type: sub._canonical_type_, obj: sub, small: true});
edge(id, sid, "owns");
// its sink, owned by the subscription (the router's slot holds
// it); the subscription's sink_ member refers to it
if (sink) {
const kid = `${sid}:sink`;
nodes.push({id: kid, kind: "sink", label: "sink",
type: sink._canonical_type_, obj: sink, small: true});
edge(sid, kid, "owns");
}
}
}
// a boxed object printed in place in another's members: a ref there,
// so its row is an edge to its box (the server is no one's member)
const boxed = nodes.filter(d => d.obj !== server);
hoist_to_refs(nodes.map(d => d.obj), new Set(boxed.map(d => d.obj._id_)));
// a struct-valued member gets its own box, nested in a group with its
// holder -- see "nested boxes" below
for (const d of [...nodes])
if (d.obj)
add_nested_boxes(d.id, d.obj, d.id, nodes, edge);
return {nodes, edges};
}
// ----- expand (issue 13, step 3) -------------------------------------------
//
// A box whose object has "_members_" (the C++ members its printer chose to
// show) toggles open on left-click / Enter: a row per member,
// name: Type [metatype] = value
// A member whose value is an object with members of its own toggles open in
// place, indented. A {"_ref_": id} value is an edge from its row to that
// object's box. Clicking a row's type opens its source.
/** box ids, and member paths ("<box>/<member>/<member>.."), shown open;
* kept across refreshes
**/
const expanded = new Set();
// ----- which boxes are shown (issue 13: showing / hiding parts of the graph) -
//
// The state is a set of WANTED EDGES; the boxes drawn follow from it.
//
// A box's REF EDGES: one per {"_ref_": id} anywhere in its members -- open or
// not -- to the box drawing that object; keyed by the ref's row key
// ("<box>/<member>/.."). A box's CHILDREN are the boxes it owns ("link" /
// "owns": server -> endpoints, sessions; session -> sender, subscriptions;
// endpoint -> receiver; subscription -> sink);
// the edge to a child is the owner's ref edge to it (e.g. session_map_["1"]
// -> session 1), or failing one, the ownership edge itself, drawn grey.
//
// DRAWN: the Webserver, and every box reachable from it through wanted edges
// -- so a box goes only when no wanted path reaches it. Every wanted edge
// between drawn boxes is drawn, from its box's bottom edge, whether or not
// its row is open; so is a visible ref row's edge to a box drawn anyway.
// Ownership edges order the layers, and are otherwise not drawn.
//
// ▸ / ▾ on a ref row ▾ while its target is drawn: hide the target (as
// menu "Hide ▸"); ▸: want this row's edge
// ▸n / ▾ beside a box want its edges to all its children (n undrawn) /
// hide those children; ArrowRight / ArrowLeft too
// menu "Show children (+k)" want its edges to its k children not drawn
// menu "Hide children (-m)" hide its m children drawn
// menu "Show ▸ <child>" want the edge to that child (a child not drawn)
// menu "Hide ▸ <child>" hide that child, as its own "Hide <label>" (a child drawn)
// menu "Hide <label>" unwant every edge into this box
// collapse a box changes nothing drawn: its edges still leave it
// (from its bottom edge, rows open or not)
// Show all / Hide all every edge (but into the Webserver) / none
//
// Wanted edges out of a box no longer drawn are kept: show the box again
// and what hung off it comes back.
/** keys of the wanted edges; kept across refreshes **/
const wanted = new Set();
/** this draw's showable edges, every box's: {key, source, target, kind,
* label}; kind "member" (a ref edge) or the ownership kind (a fallback) **/
let showable = [];
/** this draw's ownership tree: {kids, parent} **/
let tree = {kids: new Map(), parent: new Map()};
// ----- nested boxes (issue 13) ---------------------------------------------
//
// A declared member whose value is a struct with members of its own -- the
// Webserver's url_router_, session_table_, ws_config_; a session's router_
// -- is drawn as a box of its own, not opened in place: its row is a ref
// row (▸ / ▾ (→)) to that box, which is the holder's child (a "nests"
// edge). The holder and its drawn nested boxes sit inside a group outline
// (an ELK compound node), so they stay together; an edge from elsewhere to
// the nested object arrives at its own box. A nested box's id is the
// holder's row key for the member, so expanded / wanted keys carry over.
// Declared members only (they carry a _canonical_type_): a struct that is
// an array or map ELEMENT still opens in place.
/** member entry @p m gets a nested box: a declared member, its value a
* struct with members (not a ref, an array, a map of refs)
**/
function nests(m) {
const v = m._value_;
return ("_canonical_type_" in m) && has_members(v)
&& !is_ref(v) && !Array.isArray(v) && !is_ref_map(v);
}
/** for box @p owner holding @p obj (row keys under @p path), a nested box
* per struct-valued member -- recursively: a nested box's own nested
* structs nest in it. Each joins the group of the top box, @p group
**/
function add_nested_boxes(owner, obj, group, nodes, edge, path = owner) {
for (const m of (obj._members_ || [])) {
if ("_error_" in m || !nests(m))
continue;
const v = m._value_;
const id = `${path}/${m._name_}`;
nodes.push({id, kind: "nested", label: v._short_type_ || v._name_ || m._name_,
type: v._canonical_type_, obj: v, small: true, group});
edge(owner, id, "nests");
add_nested_boxes(id, v, group, nodes, edge, id);
}
}
/** @p v's short type, for display **/
function short_type_of(v) {
return v._short_type_ || v._name_ || "struct";
}
/** ELK's children for drawn boxes @p nodes, each made by @p elk_node: a
* box with drawn nested boxes becomes a group -- a
* compound node holding it and them, drawn as an outline
**/
function elk_children(nodes, elk_node) {
const in_group = new Map(); // group (its top box) -> [nested box]
for (const d of nodes)
if (d.group !== undefined) {
if (!in_group.has(d.group))
in_group.set(d.group, []);
in_group.get(d.group).push(d);
}
const out = [];
for (const d of nodes) {
if (d.group !== undefined)
continue; // inside its group, below
const nested = in_group.get(d.id);
if (nested)
out.push({id: `grp:${d.id}`,
layoutOptions: {"elk.padding": "[top=12,left=12,bottom=12,right=12]"},
children: [elk_node(d), ...nested.map(elk_node)]});
else
out.push(elk_node(d));
}
return out;
}
/** the group outlines, @p groups ({id, x, y, w, h}), under everything else
* in @p camera: one moving with its boxes, a new one fading in
**/
function draw_groups(camera, groups) {
const layer = camera.selectAll(":scope > g.groups").data([0])
.join(enter => enter.insert("g", ":first-child").attr("class", "groups"));
layer.selectAll("rect.group")
.data(groups, g => g.id)
.join(enter => enter.append("rect").attr("class", "group").attr("rx", 10)
.attr("x", g => g.x).attr("y", g => g.y)
.attr("width", g => g.w).attr("height", g => g.h)
.style("opacity", 0)
.call(e => e.transition("fade").delay(t_move).duration(t_show).style("opacity", 1)),
update => update.call(u => u.transition("move").duration(t_move).ease(d3.easeCubicInOut)
.attr("x", g => g.x).attr("y", g => g.y)
.attr("width", g => g.w).attr("height", g => g.h)),
exit => exit.transition("fade").duration(t_fade).style("opacity", 0).remove());
}
/** an ownership edge kind: orders the layers, and makes a child **/
function is_ownership(kind) {
return kind === "link" || kind === "owns" || kind === "nests";
}
/** the ownership tree of @p edges: {kids: owner -> [child], parent: child -> owner} **/
function ownership(edges) {
const kids = new Map();
const parent = new Map();
for (const e of edges)
if (is_ownership(e.kind)) {
if (!kids.has(e.source))
kids.set(e.source, []);
kids.get(e.source).push(e.target);
parent.set(e.target, e.source);
}
return {kids, parent};
}
/** how a holder relates to what a ref names, from the ref's declared
* (canonical) type -- for an element, its container's: the outermost
* smart pointer wins. "owns": std::unique_ptr; "shares": an intrusive
* (rp) or std::shared_ptr; "refers": anything else (T*, T&, unknown). A
* nested struct is "includes" -- by value, not through here
**/
function ref_kind(type) {
if (!type)
return "refers";
let best = null;
for (const [pat, kind] of [["std::unique_ptr<", "owns"], ["intrusive_ptr<", "shares"],
["std::shared_ptr<", "shares"]]) {
const i = type.indexOf(pat);
if (i >= 0 && (best === null || i < best.i))
best = {i, kind};
}
return best ? best.kind : "refers";
}
/** the strongest of ref kinds @p kinds: includes > owns > shares > refers **/
function strongest_ref_kind(kinds) {
for (const k of ["includes", "owns", "shares"])
if (kinds.includes(k))
return k;
return "refers";
}
/** every ref in member values @p members, open or not, appended to @p out
* as {key, label, ref, ref_kind}: key as member_rows() makes it (under
* @p path), label the member path for display (e.g. session_map_["1"]),
* ref_kind by ref_kind() from the declared type -- @p type, the
* container's, for elements (which declare none)
**/
function all_refs(members, path, label, out, type = null) {
for (const m of (members || [])) {
if ("_error_" in m)
continue;
const key = `${path}/${m._name_}`;
const lab = m._name_.startsWith("[") ? `${label}${m._name_}`
: label ? `${label}.${m._name_}` : m._name_;
const v = m._value_;
const t = m._canonical_type_ || type;
if (is_ref(v)) {
out.push({key, label: lab, ref: v._ref_, ref_kind: ref_kind(t)});
} else if (Array.isArray(v)) {
all_refs(v.map((x, i) => ({_name_: `[${i}]`, _value_: x})), key, lab, out, t);
} else if (is_ref_map(v)) {
all_refs(Object.entries(v).map(([k, x]) => ({_name_: `[${JSON.stringify(k)}]`, _value_: x})),
key, lab, out, t);
} else if (has_members(v)) {
if (nests(m)) // its own box: refs inside are its
out.push({key, label: lab, nested: key, ref_kind: "includes"});
else
all_refs(v._members_, key, lab, out);
}
}
}
/** the showable edges of boxes @p nodes, ownership edges @p own_edges --
* needs box_of_id
**/
function showable_edges(nodes, own_edges) {
const out = [];
for (const d of nodes) {
const refs = [];
if (d.obj)
all_refs(d.obj._members_, d.id, "", refs);
for (const r of refs) {
const target = r.nested !== undefined ? r.nested : box_of_id.get(r.ref);
if (target !== undefined && target !== d.id)
out.push({key: r.key, source: d.id, target, kind: "member", label: r.label,
ref_kind: r.ref_kind});
}
}
// an owned box no ref of its owner (or of the owner's nested boxes)
// reaches: the ownership edge stands in
const nested = new Set(nodes.filter(d => d.kind === "nested").map(d => d.id));
const within_ = (s, owner) => s === owner || (nested.has(s) && s.startsWith(owner + "/"));
for (const e of own_edges)
if (!out.some(x => within_(x.source, e.source) && x.target === e.target))
out.push({key: `own:${e.source}>${e.target}`, source: e.source, target: e.target,
kind: e.kind, label: e.kind});
return out;
}
/** ids of the boxes drawn: the server, and what wanted edges reach from it **/
function drawn_ids() {
const drawn = new Set(["server"]);
for (let grew = true; grew; ) {
grew = false;
for (const e of showable)
if (wanted.has(e.key) && drawn.has(e.source) && !drawn.has(e.target)) {
drawn.add(e.target);
grew = true;
}
}
return drawn;
}
/** ownership tree @p tree, re-parented through nested boxes: a child its
* owner reaches only by a ref from one of the owner's nested boxes (the
* server's endpoints, from its url_router_) becomes that nested box's
* child, so the tree matches the boxes drawn. A child the owner refs
* directly stays the owner's. @p edges: showable edges
**/
function reparent_via_nested(tree, edges) {
for (const [kid, owner] of [...tree.parent]) {
if (edges.some(e => e.kind === "member" && e.source === owner && e.target === kid))
continue;
const e = edges.find(e => e.kind === "member" && e.target === kid
&& e.source !== owner && within(e.source, owner));
if (!e)
continue;
tree.parent.set(kid, e.source);
tree.kids.set(owner, tree.kids.get(owner).filter(k => k !== kid));
if (!tree.kids.has(e.source))
tree.kids.set(e.source, []);
tree.kids.get(e.source).push(kid);
}
return tree;
}
/** the key of the edge from owner @p owner to its child @p kid **/
function child_edge(owner, kid) {
const keys = child_edges(owner, kid);
return keys.length ? keys[0] : null;
}
/** box @p s is @p owner, or one of its nested boxes **/
function within(s, owner) {
return s === owner || (nested_ids.has(s) && s.startsWith(owner + "/"));
}
/** keys of the edges that show child @p kid of @p owner: the owner's ref to
* it -- or a ref from one of the owner's nested boxes
* (e.g. the server's endpoints are reached from its url_router_), with the
* edges from the owner down to that nested box. Else the ownership edge
**/
function child_edges(owner, kid) {
const e = showable.find(x => x.target === kid && x.kind === "member" && within(x.source, owner))
|| showable.find(x => x.source === owner && x.target === kid);
if (!e)
return [];
const keys = [e.key];
for (let x = e.source; x !== owner && tree.parent.has(x); x = tree.parent.get(x)) {
const up = showable.find(y => y.source === tree.parent.get(x) && y.target === x && y.kind === "member");
if (!up)
break;
keys.push(up.key);
}
return keys;
}
/** ids of nested boxes this draw **/
let nested_ids = new Set();
function redraw() {
if (last_event)
draw(last_event);
}
/** want the edges down the ownership path from the server to box @p id **/
function show_box(id) {
for (let x = id; tree.parent.has(x); x = tree.parent.get(x))
child_edges(tree.parent.get(x), x).forEach(k => wanted.add(k));
redraw();
}
/** unwant every edge into box @p id -- never the Webserver, always drawn **/
function hide_box(id) {
for (const e of showable)
if (e.target === id)
wanted.delete(e.key);
}
/** the triangle beside box @p id: any child undrawn -> want the edges to
* them all; else hide them all
**/
function toggle_children(id) {
const kids = tree.kids.get(id) || [];
if (kids.some(k => !shown_box_ids.has(k)))
show_children(id);
else
hide_children(id);
}
/** want the edges from box @p id to its children not drawn **/
function show_children(id) {
for (const k of (tree.kids.get(id) || []))
if (!shown_box_ids.has(k))
child_edges(id, k).forEach(key => wanted.add(key));
redraw();
}
/** hide the children of box @p id that are drawn **/
function hide_children(id) {
for (const k of (tree.kids.get(id) || []))
if (shown_box_ids.has(k))
hide_box(k);
redraw();
}
/** box @p d's menu items for its children as a group: "Show children (+k)"
* for k not drawn, "Hide children (-m)" for m drawn -- each only when its
* count is non-zero, so a partly shown box has both
**/
function children_items(d) {
if (!d.n_children)
return [["Show children", null, "owns no boxes"]];
const n_shown = d.n_children - d.n_hidden;
const items = [];
if (d.n_hidden > 0)
items.push([`Show children (+${d.n_hidden})`, () => show_children(d.id), null]);
if (n_shown > 0)
items.push([`Hide children (-${n_shown})`, () => hide_children(d.id), null]);
return items;
}
/** "Show all" / "Hide all". Not edges into the Webserver: it is always
* drawn, so wanting one keeps nothing shown (its ref row has no ▸ / ▾
* either); each still draws while its row is open
**/
function show_all_children(on) {
camera_reset = true;
if (on)
showable.filter(e => e.target !== "server").forEach(e => wanted.add(e.key));
else
wanted.clear();
redraw();
}
const row_h = 18; // a member row
const sub_h = 16; // a box's title line, below its type
const sub_h_small = 14; // ... in a small box
const mbtn_w = 21; // the menu button after a box's label: square
const mbtn_h = 21;
const mbtn_gap = 8; // between label and menu button
const tri_button = 16; // the square behind a row's triangle
const row_indent = 14; // a nested member row, further right per level
const row_sep = ": "; // between a row's name and its value
const in_port_x = 24; // a member edge enters a box this far from its left
const out_port_gap = 10; // between member edges leaving one box
/** ELK ports for member edges @p out leaving box @p d: on its bottom edge,
* out_port_gap apart in row order, the group centred on the edge's midpoint
**/
function out_ports(d, out) {
const x0 = d.w / 2 - out_port_gap * (out.length - 1) / 2;
return out.map((e, k) => ({id: e.port, width: 1, height: 1,
x: Math.round(x0 + out_port_gap * k), y: d.h,
layoutOptions: {"elk.port.side": "SOUTH"}}));
}
/** object id -> the box drawing it this draw: a box's own object, or an
* object printed nested inside a box (a member's value). A ref to any
* other object has no edge to draw, and says so
**/
let box_of_id = new Map();
/** ids of the objects that have a box of their own this draw (as opposed to
* objects printed nested inside one)
**/
let own_box_ids = new Set();
/** ids of the boxes drawn this draw **/
let shown_box_ids = new Set();
/** box id -> its label, every box this draw (for menu entries) **/
let box_label = new Map();
/** a json object that is a ref: exactly {"_ref_": id} **/
function is_ref(v) {
return !!v && typeof v === "object" && !Array.isArray(v)
&& Object.keys(v).length === 1 && "_ref_" in v;
}
/** a json object that is a map of refs: no _name_, every value a ref or
* null (JsonMembers::member_ref_map)
**/
function is_ref_map(v) {
return !!v && typeof v === "object" && !Array.isArray(v) && !("_name_" in v)
&& !is_ref(v) && Object.keys(v).length > 0
&& Object.values(v).every(x => x === null || is_ref(x));
}
/** record, as drawn by box @p box_id, every object with an id printed
* nested in member values @p members -- not overriding an object that has
* a box of its own
**/
function note_nested(members, box_id) {
const walk = (v) => {
if (Array.isArray(v)) {
v.forEach(walk);
} else if (v && typeof v === "object" && !is_ref(v)) {
if (typeof v._id_ === "number" && "_name_" in v && !box_of_id.has(v._id_))
box_of_id.set(v._id_, box_id);
if (Array.isArray(v._members_))
v._members_.forEach(m => walk(m._value_));
else if (!("_name_" in v))
Object.values(v).forEach(walk);
}
};
(members || []).forEach(m => walk(m._value_));
}
const row_pad = 8; // below the last row
/** true iff canonical type name @p t is a string type, whose values json
* prints quoted for good reason: std::basic_string, std::basic_string_view,
* char* (const or not), xo's flatstring
**/
function is_string_type(t) {
// the type itself, not one taking a string argument (deque<string>)
return /^(\w+::)*basic_string(_view)?</.test(t)
|| /^(const\s+)?char\s*(const\s*)?\*$/.test(t)
|| /^(\w+::)*flatstring</.test(t);
}
function has_members(obj) {
return !!obj && Array.isArray(obj._members_) && obj._members_.length > 0;
}
/** the rows for @p members (at @p depth, under @p path), and the open
* members' rows beneath them, appended to @p out
**/
function member_rows(members, depth, path, out) {
for (const m of (members || [])) {
const key = `${path}/${m._name_}`;
const row = {key, depth, m, cls: "", ref: null, expandable: false, open: false};
let val;
if ("_error_" in m) {
row.cls = "error";
val = `⚠ ${m._error_}`;
} else {
const v = m._value_;
if (v === null || v === undefined) {
val = "null";
} else if (Array.isArray(v)) {
// an array of objects or refs opens into a row per element
row.expandable = v.length > 0
&& v.every(x => x === null || (typeof x === "object" && !Array.isArray(x)));
row.open = row.expandable && expanded.has(key);
if (row.expandable) {
row.tri = row.open ? "▾" : "▸";
val = ` [${v.length}]`;
} else if (v.every(x => x === null || typeof x !== "object")) {
// scalars: the contents themselves, cut like a scalar
val = JSON.stringify(v);
if (val.length > 40)
val = val.slice(0, 39) + "…";
} else {
val = `[${v.length}]`;
}
} else if (is_ref(v)) {
row.cls = "ref";
row.ref = v._ref_;
// a box it refers to, which can be shown / hidden from here
const tb = box_of_id.get(v._ref_);
row.ref_box = (tb !== undefined && tb !== "server") ? tb : null;
// "(→)" after its ▸ / ▾: this triangle shows another box,
// not rows in place. What it refers to is in the tooltip
val = !box_of_id.has(v._ref_) ? "(→ not drawn)"
: row.ref_box ? " (→)" : "(→)";
row.ref_tip = ref_tooltip(v._ref_);
} else if (is_ref_map(v)) {
// a map to objects printed elsewhere: a row per key
row.expandable = true;
row.open = expanded.has(key);
row.tri = row.open ? "▾" : "▸";
val = ` {${Object.keys(v).length}}`;
} else if (nests(m)) {
// its own box (nested boxes): a ref row to it
row.cls = "ref";
row.ref_box = key;
row.ref_tip = `its own box, nested: ${short_type_of(v)}`;
val = " (→)";
} else if (typeof v === "object") {
row.expandable = has_members(v);
row.open = row.expandable && expanded.has(key);
// opens in place: just the triangle (its type is on the
// name's tooltip); one that cannot open shows its name
if (row.expandable)
row.tri = row.open ? "▾" : "▸";
val = row.expandable ? "" : (v._name_ || "{…}");
} else {
// a string whose declared type is not a string type -- an
// enum, or a printer's summary ("0 queued") -- shows bare:
// quotes would claim a C++ string that isn't there
val = (typeof v === "string" && m._canonical_type_ && !is_string_type(m._canonical_type_))
? v : JSON.stringify(v);
if (val.length > 40)
val = val.slice(0, 39) + "…";
}
}
row.val = val;
out.push(row);
if (row.open) {
if (Array.isArray(m._value_)) {
// element rows: no declared type of their own
member_rows(m._value_.map((x, i) => ({_name_: `[${i}]`, _value_: x})),
depth + 1, key, out);
} else if (is_ref_map(m._value_)) {
member_rows(Object.entries(m._value_).map(([k, x]) => ({_name_: `[${JSON.stringify(k)}]`, _value_: x})),
depth + 1, key, out);
} else {
member_rows(m._value_._members_, depth + 1, key, out);
}
}
}
}
function toggle(key) {
if (expanded.has(key))
expanded.delete(key);
else
expanded.add(key);
if (last_event)
draw(last_event);
}
/** ref row @p r's target: drawn -> hide it (as menu "Hide ▸ <child>":
* unwant every edge into it); not drawn -> want this row's edge. It
* follows the target box, not the row's own edge -- another edge into the
* target (a parallel one, e.g. a session's sender_ and router_.sender_, or
* another box's) would otherwise keep it drawn while the row said hidden
**/
function toggle_ref(r) {
if (shown_box_ids.has(r.ref_box))
hide_box(r.ref_box);
else
wanted.add(r.key);
redraw();
}
/** on a ref row whose target is a box (not the Webserver): a ▸ / ▾ before
* the arrow -- ▾ while the target is drawn -- showing / hiding it
**/
function append_ref_toggle(t, r) {
if (!r.ref_box)
return;
const on = shown_box_ids.has(r.ref_box);
t.append("tspan").attr("class", "tri rtoggle").text(on ? "▾" : "▸")
.on("click", (ev) => {
ev.stopPropagation();
toggle_ref(r);
})
.append("title").text(on ? "hide the box it refers to"
: "show the box it refers to");
}
/** the member edges to draw, of drawn boxes @p nodes: each wanted edge
* between drawn boxes; and each visible ref row's edge to a box drawn
* anyway. In each box's member order
**/
function member_edges(nodes) {
const visible = new Set();
for (const d of nodes)
for (const r of d.rows)
visible.add(r.key);
return showable.filter(e => e.kind === "member"
&& shown_box_ids.has(e.source) && shown_box_ids.has(e.target)
&& (wanted.has(e.key) || visible.has(e.key)));
}
// a draw is asynchronous (ELK); a newer one supersedes an older one still
// laying out
let draw_seq = 0;
// ----- the camera: the graph's own viewport -------------------------------
//
// The svg is a fixed window onto the graph (full width, filling the browser
// window below the controls); the rest of the page never moves for its
// sake. One group, g.camera, holds the drawing and carries a pan / zoom
// transform (d3.zoom): Shift + drag the background to pan, Shift + wheel to
// zoom, "Fit" to see it all. Without Shift the browser has the events: the
// wheel scrolls the page.
//
// Anchoring: a click inside a box (or on its triangle, or one of its menu
// items) names it the ANCHOR of the redraw that follows; after layout the
// camera moves -- in step with the boxes' own move -- so that box keeps its
// place in the viewport. Browsers cannot move the mouse pointer, so the
// drawing moves instead. No anchor (Refresh): the camera stays; Show all /
// Hide all (and the first draw) centre the drawing at 100%, as Center does.
// ----- transitions (issue 13, step 3) ------------------------------------
//
// A redraw animates: leaving boxes, rows and edges fade out (t_fade, while
// ELK lays out); boxes slide to their new places and outlines resize
// (t_move); then arriving boxes, rows and the edges' new routes fade in
// (t_show). A new draw interrupts the last one's transitions and goes on
// from where things are. Edges fade rather than morph: old and new routes
// have different bends, and morphing polylines makes spaghetti.
const t_fade = 120;
const t_move = 350;
const t_show = 150;
/** when the latest draw's transitions end (performance.now() ms) **/
let settled_at = 0;
/** a promise that resolves once the latest draw's transitions have ended --
* for tests, which would otherwise read positions mid-move
**/
function settled() {
return new Promise(function wait(resolve) {
const left = settled_at - performance.now();
if (left <= 0 && pending_draws === 0)
resolve(true);
else
setTimeout(() => wait(resolve), Math.max(left, 20));
});
}
/** draws started and not yet placed (ELK still laying out) **/
let pending_draws = 0;
/** the box the next draw keeps in place; consumed by that draw **/
let pending_anchor = null;
/** box id -> where it was last drawn, and its size (drawing coordinates) **/
let drawn_at = new Map();
/** the next draw centres the drawing again, scale 1 (Show all / Hide all) **/
let camera_reset = true;
/** the first draw: the camera centres the drawing, at once **/
let first_draw = true;
/** the drawing's size, last draw -- for "Fit" **/
let drawing_size = {w: 0, h: 0};
const graph_svg = d3.select("#graph");
/** pan / zoom only with Shift held: otherwise the wheel scrolls the page
* and a drag is the browser's. Dragging on a box is the box's, not a pan
**/
const zoom = d3.zoom()
.scaleExtent([0.2, 3])
.constrain(keep_a_box_in_view)
.filter((ev) => ev.shiftKey
&& (ev.type === "wheel"
|| (!ev.button && !(ev.target.closest && ev.target.closest("g.node")))))
// Shift+wheel is a sideways scroll to browsers: some report it in
// deltaX, not deltaY -- take whichever moved
.wheelDelta((ev) => -(ev.deltaY || ev.deltaX) * (ev.deltaMode === 1 ? 0.05 : ev.deltaMode ? 1 : 0.002))
.on("zoom", (ev) => {
graph_svg.select(":scope > g.camera").attr("transform", ev.transform);
show_zoom_level(ev.transform.k);
});
graph_svg.call(zoom).on("dblclick.zoom", null);
// Shift + press on the background starts a pan -- to the browser it would
// extend any text selection (d3.zoom only stops a new one starting), and
// dragging that toward the window's edge scrolls the page. Cancel it, and
// drop the selection. Capture, on the document: d3.zoom's own handler
// stops the event from reaching any other on the svg
document.addEventListener("mousedown", (ev) => {
const t = ev.target;
if (ev.shiftKey && t.closest && t.closest("#graph") && !t.closest("g.node")) {
ev.preventDefault();
window.getSelection().removeAllRanges();
}
}, true);
// the hand cursor only while Shift is held: it advertises the gesture
for (const type of ["keydown", "keyup"])
window.addEventListener(type, (ev) => graph_svg.classed("panning", ev.shiftKey));
window.addEventListener("blur", () => graph_svg.classed("panning", false));
/** d3.zoom's constrain: a pan or zoom may not take every box out of view --
* the centre of at least one must stay inside the viewport @p extent. If
* transform @p t would leave none, move it just enough to put the centre
* NEAREST the viewport on its edge: the drawing sticks there, and dragging
* back moves at once. Only gestures pass through here, not the page's own
* camera moves (anchoring, Fit, reset)
**/
function keep_a_box_in_view(t, extent) {
const [[x0, y0], [x1, y1]] = extent;
let best = null;
for (const b of drawn_at.values()) {
const sx = t.applyX(b.x + b.w / 2), sy = t.applyY(b.y + b.h / 2);
if (sx >= x0 && sx <= x1 && sy >= y0 && sy <= y1)
return t; // one in view: fine
const cx = Math.min(Math.max(sx, x0), x1), cy = Math.min(Math.max(sy, y0), y1);
const d2 = (sx - cx) ** 2 + (sy - cy) ** 2;
if (best === null || d2 < best.d2)
best = {d2, dx: cx - sx, dy: cy - sy};
}
// translate() works in drawing units: screen pixels / k
return best === null ? t : t.translate(best.dx / t.k, best.dy / t.k);
}
/** the svg fills the browser window below the controls **/
function size_view() {
const top = graph_svg.node().getBoundingClientRect().top + window.scrollY;
graph_svg.style("height", `${Math.max(240, window.innerHeight - top - 12)}px`);
}
size_view();
window.addEventListener("resize", size_view);
/** box @p d's header line: its short type **/
function box_type_label(d) {
return (d.obj && d.obj._short_type_) || d.label;
}
/** box @p d's title, the header's second line -- what tells it apart from
* other boxes of its type -- or null where the type says it all: a
* sender, sink or receiver (one per holder), a nested struct
**/
function box_subtitle(d) {
if (d.kind === "server")
return `:${d.obj.listen_port} (${d.obj.state})`;
if (["sender", "sink", "receiver", "nested"].includes(d.kind))
return null;
return d.label !== box_type_label(d) ? d.label : null;
}
/** box kinds, by colour -- one colour per C++ type; the kinds that share
* one (an endpoint is http or stream) share a legend entry. Order: as
* the graph reads, top down. Nested boxes come after, one entry per type
**/
const legend_groups = [
{kinds: ["server"], sample: "server"},
{kinds: ["http", "stream"], sample: "http"},
{kinds: ["receiver"], sample: "receiver"},
{kinds: ["session", "session closed"], sample: "session"},
{kinds: ["sender"], sample: "sender"},
{kinds: ["subscription", "subscription astray"], sample: "subscription"},
{kinds: ["sink"], sample: "sink"},
];
/** colours for nested boxes, {fill, stroke}: pale fills no other kind uses **/
const nested_palette = [
{fill: "#e9f5e1", stroke: "#4f8a3a"}, // pale green
{fill: "#fdebe0", stroke: "#b5653a"}, // peach
{fill: "#e1f3fa", stroke: "#2b7fa0"}, // pale cyan
{fill: "#f1f2dc", stroke: "#7d7f2e"}, // olive
{fill: "#e6e9fb", stroke: "#5560a8"}, // periwinkle
{fill: "#f6e6f0", stroke: "#a0527f"}, // mauve
{fill: "#e4f1ec", stroke: "#3f7d68"}, // sage
{fill: "#f5efe1", stroke: "#8f6f3a"}, // sand
];
/** canonical type -> its nested boxes' colour, assigned as types first
* appear and kept for the page's life, so a refresh never repaints
**/
const nested_colours = new Map();
/** the colour of nested boxes of canonical type @p type **/
function nested_colour(type) {
if (!nested_colours.has(type))
nested_colours.set(type, nested_palette[nested_colours.size % nested_palette.length]);
return nested_colours.get(type);
}
const legend_margin = 8; // between the viewport's corner and the legend
const legend_pad = 6; // inside the legend's panel
const legend_row = 18; // one entry
const swatch_w = 20;
const swatch_h = 12;
const swatch_gap = 8; // between swatch and type
const legend_gap = 8; // between the colours and the edge kinds
/** the legend's width, with its margins (0 while hidden): Fit keeps the
* drawing clear of it
**/
let legend_w = 0;
let legend_h = 0; // ... and height
/** the legend, fixed in the viewport's top-left corner (not panned or
* zoomed): for each colour a box in snapshot @p all has (drawn or not --
* so it doesn't flicker as boxes show and hide), a swatch and the short
* type, top down. A kind's swatch is styled by the same CSS rules as its
* boxes (.swatch.<kind> -- not .node: it is no box); a nested type's
* carries that type's colour, as its boxes do
**/
function draw_legend(all) {
const entries = [];
for (const grp of legend_groups) {
const d = all.nodes.find(n => grp.kinds.includes(n.kind));
if (d)
entries.push({key: grp.sample, cls: grp.sample, colour: null,
type: (d.obj && d.obj._short_type_) || d.kind, canonical: d.type || ""});
}
for (const d of all.nodes)
if (d.kind === "nested" && !entries.some(x => x.key === `nested:${d.type}`))
entries.push({key: `nested:${d.type}`, cls: "nested", colour: nested_colour(d.type),
type: d.label, canonical: d.type || ""});
const lg = graph_svg.selectAll(":scope > g.legend").data([0])
.join(enter => {
const g = enter.append("g").attr("class", "legend")
.attr("transform", `translate(${legend_margin},${legend_margin})`);
g.append("rect").attr("class", "panel");
return g;
})
.raise(); // over the drawing
const e = lg.selectAll(":scope > g.entry")
.data(entries, x => x.key)
.join(enter => {
const g = enter.append("g").attr("class", "entry");
g.append("title");
g.append("g").append("rect").attr("width", swatch_w).attr("height", swatch_h);
g.append("text").attr("x", swatch_w + swatch_gap).attr("y", swatch_h - 2);
return g;
});
e.attr("transform", (x, i) => `translate(${legend_pad},${legend_pad + i * legend_row})`);
e.select(":scope > title").text(x => x.canonical);
e.select(":scope > g").attr("class", x => `swatch ${x.cls}`);
e.select(":scope > g > rect")
.style("fill", x => x.colour ? x.colour.fill : null)
.style("stroke", x => x.colour ? x.colour.stroke : null);
e.select(":scope > text").text(x => x.type);
// below the colours: how an edge leaves its holder, by its marker
const edge_kinds = entries.length === 0 ? []
: [{kind: "includes", text: "includes (by value)"},
{kind: "owns", text: "owns (unique_ptr)"},
{kind: "shares", text: "shares (rp, shared_ptr)"},
{kind: "refers", text: "refers (T*, T&)"}];
const top = legend_pad + entries.length * legend_row + legend_gap;
const ek = lg.selectAll(":scope > g.edge-entry")
.data(edge_kinds, x => x.kind)
.join(enter => {
const g = enter.append("g").attr("class", "edge-entry");
g.append("path").attr("d", `M2,${swatch_h / 2} L${swatch_w + 4},${swatch_h / 2}`);
g.append("text").attr("x", swatch_w + swatch_gap).attr("y", swatch_h - 2);
return g;
});
ek.attr("transform", (x, i) => `translate(${legend_pad},${top + i * legend_row})`);
ek.select(":scope > path").attr("class", x => `sample from-${x.kind}`); // not .edge: no edge's data
ek.select(":scope > text").text(x => x.text);
const text_w = d3.max([...e.select(":scope > text").nodes(), ...ek.select(":scope > text").nodes()],
t => t.getComputedTextLength()) || 0;
const w = swatch_w + swatch_gap + text_w + 2 * legend_pad;
const h = edge_kinds.length === 0 ? (entries.length - 1) * legend_row + swatch_h + 2 * legend_pad
: top + (edge_kinds.length - 1) * legend_row + swatch_h + legend_pad;
lg.select(":scope > rect.panel").attr("width", w).attr("height", h);
const shown = show_legend && entries.length > 0;
lg.attr("display", shown ? null : "none");
legend_w = shown ? w + 2 * legend_margin : 0;
legend_h = shown ? h + 2 * legend_margin : 0;
}
/** the magnification, beside the controls (Shift + wheel zooms) **/
function show_zoom_level(k) {
const el = document.getElementById("zoom-level");
if (el)
el.textContent = `zoom ${Math.round(k * 100)}%`;
}
/** the viewport's inner size, {width, height}: inside the svg's border,
* where the camera's coordinates start
**/
function view_size() {
const el = graph_svg.node();
return {width: el.clientWidth, height: el.clientHeight};
}
/** the drawing's extent -- what Fit and Center place -- as a sheet under
* everything in camera @p camera, size @p size; resized in step with the
* boxes, or at once (@p instant)
**/
function draw_extent(camera, size, instant) {
const layer = camera.selectAll(":scope > g.extent").data([0])
.join(enter => enter.insert("g", ":first-child").attr("class", "extent")); // under the groups
const r = layer.selectAll(":scope > rect").data([size])
.join(enter => enter.append("rect").attr("width", size.w).attr("height", size.h));
(instant ? r : r.transition("size").duration(t_move).ease(d3.easeCubicInOut))
.attr("width", size.w).attr("height", size.h);
}
/** the camera putting a drawing of size @p size, at zoom @p k, centred in
* the viewport -- the legend, over the drawing, not counted
**/
function centred(size, k) {
const r = view_size();
return d3.zoomIdentity.translate(r.width / 2 - k * size.w / 2, r.height / 2 - k * size.h / 2).scale(k);
}
/** "Center": the drawing centred, at the current zoom **/
const center_btn = document.getElementById("center");
if (center_btn) center_btn.onclick = () => {
graph_svg.transition("move").duration(t_move).ease(d3.easeCubicInOut)
.call(zoom.transform, centred(drawing_size, d3.zoomTransform(graph_svg.node()).k));
};
/** the camera for "Fit": drawing @p size as large as fits in the viewport
* (at most the zoom's maximum), centred -- as Center would, unless that
* would overlap the legend. Then slid clear of it (right, or down) if
* the slack allows; else as large as fits right of the legend, or below
* it -- whichever is larger -- centred there
**/
function fit_camera(size) {
const r = view_size();
const w = Math.max(1, size.w), h = Math.max(1, size.h);
const k_max = zoom.scaleExtent()[1];
// as large as fits in area [x0, x1] x [y0, y1], centred there
const fit_in = (x0, y0, x1, y1) => {
const k = Math.min(k_max, (x1 - x0) / w, (y1 - y0) / h);
return {k, x: x0 + (x1 - x0 - k * w) / 2, y: y0 + (y1 - y0 - k * h) / 2};
};
const at = (f) => d3.zoomIdentity.translate(f.x, f.y).scale(f.k);
const f = fit_in(0, 0, r.width, r.height);
if (legend_w === 0 || f.x >= legend_w || f.y >= legend_h)
return at(f); // clear of the legend
// slide clear, along whichever axis has the slack
if (legend_w + f.k * w <= r.width)
return at({...f, x: legend_w});
if (legend_h + f.k * h <= r.height)
return at({...f, y: legend_h});
const right = fit_in(legend_w, 0, r.width, r.height);
const below = fit_in(0, legend_h, r.width, r.height);
return at(right.k >= below.k ? right : below);
}
/** "Fit": the whole drawing as large as fits; see fit_camera() **/
const fit_btn = document.getElementById("fit");
if (fit_btn) fit_btn.onclick = () => {
graph_svg.transition("move").duration(t_move).ease(d3.easeCubicInOut)
.call(zoom.transform, fit_camera(drawing_size));
};
for (const type of ["click", "keydown"]) // keydown: Enter / arrows on a focused box
document.getElementById("graph").addEventListener(type, (ev) => {
const g = ev.target.closest && ev.target.closest("g.node");
pending_anchor = g ? g.__data__.id : null;
}, true); // capture: before the handler that redraws
async function draw(event) {
++pending_draws;
try {
await draw_aux(event);
} finally {
--pending_draws;
}
}
async function draw_aux(event) {
const seq = ++draw_seq;
const anchor = pending_anchor;
pending_anchor = null;
const all = layout(event);
draw_legend(all);
// every box -- shown or not -- for joining refs; only shown ones drawn
box_of_id = new Map(all.nodes.filter(d => d.obj && d.obj._id_ !== undefined)
.map(d => [d.obj._id_, d.id]));
own_box_ids = new Set(box_of_id.keys());
for (const d of all.nodes)
if (d.obj)
note_nested(d.obj._members_, d.id);
nested_ids = new Set(all.nodes.filter(d => d.kind === "nested").map(d => d.id));
showable = showable_edges(all.nodes, all.edges.filter(e => is_ownership(e.kind)));
tree = reparent_via_nested(ownership(all.edges), showable);
shown_box_ids = drawn_ids();
box_label = new Map(all.nodes.map(d => [d.id, d.label]));
const nodes = all.nodes.filter(d => shown_box_ids.has(d.id));
const edges = all.edges.filter(e => shown_box_ids.has(e.source) && shown_box_ids.has(e.target));
for (const d of nodes) {
d.children = tree.kids.get(d.id) || [];
d.n_children = d.children.length;
d.n_hidden = d.children.filter(k => !shown_box_ids.has(k)).length;
d.kids_open = (d.n_children > 0 && d.n_hidden === 0);
}
const box_h = 40;
const pad = 20; // around the whole graph
const svg = d3.select("#graph");
// fixed layers, edges under boxes
const camera = svg.selectAll(":scope > g.camera").data([0]).join("g").attr("class", "camera");
define_arrowheads(svg);
const edge_layer = camera.selectAll("g.edges").data([0]).join("g").attr("class", "edges");
const node_layer = camera.selectAll("g.nodes").data([0]).join("g").attr("class", "nodes");
svg.select(":scope > g.legend").raise(); // the legend over the drawing
// 1. the boxes, so their text can be measured
const node = node_layer.selectAll("g.node")
.data(nodes, d => d.id)
.join(enter => {
// invisible until placed, then faded in (see 3.). Marked by a
// property, not a class: the class attribute is rewritten below
const g = enter.append("g").property("__arriving", true).style("opacity", 0);
g.append("title"); // the type and its source; see below
g.append("rect");
g.append("text").attr("class", "label").attr("x", 12).attr("y", 25);
g.append("text").attr("class", "sub").attr("x", 12); // its title, below the type
// menu button, just after the label: left-click opens the box menu
const mb = g.append("g").attr("class", "mbtn");
mb.append("rect").attr("width", mbtn_w).attr("height", mbtn_h).attr("rx", 3);
// three dots, drawn: the "⋯" glyph is tiny in the monospace font
for (const dx of [-5, 0, 5])
mb.append("circle").attr("cx", mbtn_w / 2 + dx).attr("cy", mbtn_h / 2).attr("r", 1.6);
mb.append("title").text("menu");
g.append("g").attr("class", "rows");
// children toggle, left of the box (only where it owns any)
g.append("text").attr("class", "kids").attr("text-anchor", "end").attr("x", -4);
return g;
},
update => update,
// leaving: fade out, then gone; inert meanwhile
exit => exit.classed("leaving", true).interrupt("move")
.transition("fade").duration(t_fade).style("opacity", 0).remove());
// one that was leaving and is back: stop its fade
node.filter(function () { return this.classList.contains("leaving"); })
.classed("leaving", false).interrupt("fade").style("opacity", null);
// edges fade out while ELK lays out; the new routes fade in after (4.)
edge_layer.selectAll(":scope > g.edge-g").interrupt("fade")
.transition("fade").duration(t_fade).style("opacity", 0);
// which boxes can open, and their member rows
for (const d of nodes) {
d.expandable = has_members(d.obj);
d.open = d.expandable && expanded.has(d.id);
d.rows = [];
if (d.open)
member_rows(d.obj._members_, 0, d.id, d.rows);
}
node.attr("class", d => `node ${d.kind}` + (d.expandable ? " expandable" : "")
+ (d.open ? " open" : ""));
node.select(":scope > rect") // a nested box: its type's colour
.style("fill", d => d.kind === "nested" ? nested_colour(d.type).fill : null)
.style("stroke", d => d.kind === "nested" ? nested_colour(d.type).stroke : null);
// the header: the short type, then -- where it says more -- the box's
// own title (box_subtitle()); menus and tooltips still name a box by
// its label
node.select(":scope > text.label").text(d => box_type_label(d));
node.select(":scope > text.sub").text(d => box_subtitle(d) ?? "");
// the menu button: opens the box menu below it; not a click on the box
node.select(":scope > g.mbtn").on("click", function (ev, d) {
ev.stopPropagation();
const r = this.getBoundingClientRect();
show_menu(r.left + window.scrollX, r.bottom + window.scrollY + 2,
d.type || d.label, menu_items(d), this.parentNode);
});
node.select(":scope > text.kids")
.attr("display", d => d.n_children ? null : "none")
.text(d => d.kids_open ? "▾" : `▸${d.n_hidden}`)
.on("click", (ev, d) => {
ev.stopPropagation();
toggle_children(d.id);
})
.selectAll("title").data(d => [d]).join("title")
.text(d => d.kids_open ? "hide its children"
: `show its children (${d.n_hidden} of ${d.n_children} hidden)`);
// the member rows: name = value; with types shown, name: Type [metatype] = value
node.select(":scope > g.rows").each(function (d) {
const box_el = this.parentNode;
const show_types = typed_boxes.has(d.id);
const rows = d3.select(this).selectAll("text.row")
.data(d.rows, r => r.key)
.join(enter => enter.append("text").property("__arriving", true).style("opacity", 0));
// where each row already on screen was -- its text x, y and its
// separator's column -- before this draw re-renders it: a row that
// moves will travel there from here, with its box's outline
rows.each(function () {
const meq = this.querySelector(":scope > tspan.meq");
this.__was = this.__arriving ? null
: {x: +this.getAttribute("x"), y: +this.getAttribute("y"),
col: meq && meq.hasAttribute("x") ? +meq.getAttribute("x") : null};
});
rows.attr("class", r => `row ${r.cls}` + (r.expandable ? " expandable" : ""))
.attr("x", r => 12 + row_indent * r.depth)
.on("mouseenter", (ev, r) => highlight_ref([r.key], true))
.on("mouseleave", (ev, r) => highlight_ref([r.key], false));
rows.each(function (r) {
const t = d3.select(this);
t.selectAll("*").remove();
const typed = !!r.m._canonical_type_; // an array element has no declared type
const s = typed ? source_of(r.m._canonical_type_) : null;
// types replace the value: name: Type [metatype], a ref row
// keeping its arrow after; an element row has no declared type,
// so keeps its value
const as_type = typed && show_types;
const tip = row_tooltip(r, as_type);
// the name: hover for its type; ctrl/cmd-click to open its source
t.append("tspan")
.attr("class", "mname" + (s && s.href ? " linked" : ""))
.text(r.m._name_)
.on("click", (ev) => {
if ((ev.ctrlKey || ev.metaKey) && s && s.href) {
ev.stopPropagation();
window.open(s.href, "_blank");
}
})
.append("title").text(tip);
// the separator before the value (or type), its x the row's
// alignment column
t.append("tspan").attr("class", "mval meq").text(row_sep);
// opens in place: its triangle, a button of its own (see tri_buttons)
if (r.tri)
t.append("tspan").attr("class", "tri xtoggle").text(r.tri);
append_ref_toggle(t, r);
if (as_type) {
t.append("tspan")
.attr("class", "mtype" + (s && s.href ? " linked" : ""))
.text((r.tri || r.ref_box ? " " : "") + (r.m._short_type_ || r.m._canonical_type_))
.on("click", (ev) => {
ev.stopPropagation();
if (s && s.href)
window.open(s.href, "_blank");
})
.append("title").text(tip);
t.append("tspan").attr("class", "mtag").text(` [${r.m._metatype_ || "?"}]`);
}
const val = !as_type ? r.val
: r.cls === "ref" ? " " + r.val.trimStart() : "";
if (val !== "") { // a struct that opens: its triangle is all
const mv = t.append("tspan").attr("class", "mval").text(val);
if (r.ref_tip)
mv.append("title").text(r.ref_tip);
}
});
rows.on("click", (ev, r) => {
ev.stopPropagation();
if (r.expandable && !ev.ctrlKey && !ev.metaKey)
toggle(r.key);
});
// right-click a row: its own menu, not the box's
rows.on("contextmenu", (ev, r) => {
ev.preventDefault();
ev.stopPropagation();
show_menu(ev.pageX, ev.pageY, row_menu_head(r), row_menu_items(r), box_el);
});
});
// source: hover for the type and where it is defined; click to open
node.each(function (d) {
const s = source_of(d.type);
const g = d3.select(this);
g.select(":scope > title").text(
!d.type ? "(no _canonical_type_ reported)"
: !s ? `${d.type}\n(no source location)`
: `${d.type}\n${s.file}:${s.line}` + (s.href ? "" : "\n(no link provider)"));
// left-click / Enter: open or close its members. Its source is in
// the context menu
g.on("click", d.expandable ? () => toggle(d.id) : null)
.on("keydown", (ev) => {
if (ev.key === "Enter" && d.expandable) {
ev.preventDefault();
toggle(d.id);
} else if (ev.key === "ArrowRight" && d.n_hidden > 0) {
ev.preventDefault();
toggle_children(d.id);
} else if (ev.key === "ArrowLeft" && d.kids_open) {
ev.preventDefault();
toggle_children(d.id);
}
});
// focusable, so the keyboard can reach the context menu
g.attr("tabindex", 0)
.on("contextmenu", (ev) => {
ev.preventDefault();
// from the keyboard the event has no pointer position: use the box
const r = this.getBoundingClientRect();
const at_box = (ev.clientX === 0 && ev.clientY === 0);
show_menu(at_box ? r.left + window.scrollX + 10 : ev.pageX,
at_box ? r.bottom + window.scrollY : ev.pageY,
d.type || d.label, menu_items(d), this);
});
});
// size each box to its header: the type's line (with the menu button
// and, outside, the children triangle), and the title's below it
node.each(function (d) {
const g = d3.select(this);
const has_sub = box_subtitle(d) !== null;
const type_h = d.small ? 30 : box_h; // the type's line
const head_h = type_h + (has_sub ? (d.small ? sub_h_small : sub_h) : 0);
g.select(":scope > text.label").attr("y", d.small ? 20 : 25);
g.select(":scope > text.sub").attr("y", d.small ? 20 + sub_h_small : 25 + sub_h)
.attr("display", has_sub ? null : "none");
const label_w = g.select(":scope > text.label").node().getComputedTextLength();
const sub_w = has_sub ? g.select(":scope > text.sub").node().getComputedTextLength() : 0;
g.select(":scope > g.mbtn")
.attr("transform", `translate(${12 + label_w + mbtn_gap},${(type_h - mbtn_h) / 2})`);
d.w = Math.max(12 + label_w + mbtn_gap + mbtn_w + 12, 12 + sub_w + 12);
const texts = g.selectAll(":scope > g.rows > text.row");
texts.each(function (r, i) {
d3.select(this).attr("y", head_h + i * row_h + 13);
});
// rows align on their separator (row_sep, before the value),
// stepping in with nesting: a row at depth n puts it at
// x0 + row_indent * n, x0 the least that clears every row's name.
// Names are right-justified against it: a row's text starts at its
// column less the width of what precedes the separator (its name;
// with types shown, name: Type [metatype])
let x0 = null;
texts.each(function (r) {
r.eq_natural = eq_x(this); // where the separator falls, unaligned
if (r.eq_natural !== null)
x0 = Math.max(x0 ?? r.eq_natural, r.eq_natural - row_indent * r.depth);
});
texts.each(function (r) {
if (x0 !== null && r.eq_natural !== null) {
const col = x0 + row_indent * r.depth;
const lead = r.eq_natural - (12 + row_indent * r.depth); // name's width
d3.select(this).attr("x", col - lead);
d3.select(this).select(":scope > tspan.meq").attr("x", col);
}
const b = this.getBBox();
d.w = Math.max(d.w, b.x + b.width + 16);
});
d.head_h = head_h;
g.select(":scope > text.kids").attr("y", type_h / 2 + 5);
tri_buttons(g.select(":scope > g.rows"));
d.h = head_h + (d.rows.length ? d.rows.length * row_h + row_pad : 0);
// resize: animated, but for a box not yet on screen
const animate = (sel) => this.__arriving ? sel
: sel.transition("size").duration(t_move).ease(d3.easeCubicInOut);
animate(g.select("rect")).attr("width", d.w).attr("height", d.h);
// rows already on screen that moved (a nested row opened or closed
// above them, or the name column widened): back to where they were,
// then along with the outline -- same duration and easing, so a row
// inside the old outline and inside the new stays inside throughout.
// Their triangle squares move with them
if (!this.__arriving)
slide_rows(g.select(":scope > g.rows"));
});
// a ref member's edge: from its box to the box drawing the referenced
// object -- its own box, or the box it is printed nested in. Parallel
// ones merge: one line per pair of boxes, carrying every member it
// stands for (e.g. a session's sender_ and router_.sender_)
const drawn_members = merge_parallel(member_edges(nodes));
for (const e of drawn_members)
edges.push({source: e.port, target: e.target, kind: "member",
from: e.source, row_keys: e.keys, labels: e.labels, ref_kind: e.ref_kind});
// a wanted fallback (an owned box no ref reaches): its ownership edge drawn
const drawn_own = new Set(showable.filter(e => e.kind !== "member" && wanted.has(e.key))
.map(e => `${e.source}>${e.target}`));
for (const e of edges)
if (is_ownership(e.kind) && drawn_own.has(`${e.source}>${e.target}`))
e.drawn = true;
// boxes a member edge arrives at: each gets one entry port
const member_in = new Set(edges.filter(e => e.kind === "member").map(e => e.target));
// 2. ELK: layered, top to bottom
const graph = {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.edgeRouting": "ORTHOGONAL",
"elk.spacing.nodeNode": "40",
"elk.layered.spacing.nodeNodeBetweenLayers": "50",
"elk.spacing.edgeNode": "20",
// network simplex, not Brandes-Koepf: BK keeps the narrowest of
// four candidate placements, and a near-tie between them can flip
// when one box changes width -- e.g. opening a subscription moved
// the Webserver box 200px right. Network simplex keeps boxes put
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
// a cycle (a member edge back up the ownership tree -- a sink's
// sender_, a sender's target_) is broken by reversing the edges
// that point from a later box to an earlier one in the input
// order -- and layout() lists parents before children. So
// ownership decides the layering by construction: the default
// (greedy) strategy put session 1 above the Webserver once sinks
// added sink -> sender back-edges, priority notwithstanding
"elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
// nested boxes: groups are compound nodes, and edges cross into
// them -- laid out together
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
},
children: elk_children(nodes, d => ({
id: d.id, width: d.w, height: d.h,
// a ref member's edge leaves the box's bottom edge, in row order,
// the group centred on the edge's midpoint; and enters the box it
// refers to on its top edge, near the left
layoutOptions: {"elk.portConstraints": "FIXED_POS"},
ports: [
...out_ports(d, drawn_members.filter(e => e.source === d.id)),
...(member_in.has(d.id)
? [{id: `${d.id}#in`, width: 1, height: 1, x: in_port_x, y: -1,
layoutOptions: {"elk.port.side": "NORTH"}}]
: []),
],
})),
// ownership (link, owns) decides top-to-bottom; member edges
// follow it. Without this an expanded box's ref edge could
// invert the layering -- e.g. a session's router's url_router_ (in
// the server box) put the session above the server
edges: edges.map((e, i) => ({
id: `e${i}`, sources: [e.source],
targets: [e.kind === "member" ? `${e.target}#in` : e.target],
layoutOptions: {"elk.layered.priority.direction":
is_ownership(e.kind) ? "10" : "0"},
})),
};
const laid = await elk.layout(graph);
if (seq !== draw_seq)
return; // superseded while laying out
// 3. place the boxes; keep the anchor box where it was on screen. A
// group's children come back relative to the group: made absolute here
const at = new Map(); // box / group id -> {x, y}, drawing coordinates
const groups = []; // the group outlines to draw
for (const c of laid.children) {
at.set(c.id, {x: c.x, y: c.y});
if (c.children) {
groups.push({id: c.id, x: c.x + pad, y: c.y + pad, w: c.width, h: c.height});
for (const k of c.children)
at.set(k.id, {x: c.x + k.x, y: c.y + k.y});
}
}
const camera_to = camera_target(anchor, at, pad,
{w: laid.width + 2 * pad, h: laid.height + 2 * pad});
for (const d of nodes) {
const c = at.get(d.id);
d.x = c.x + pad;
d.y = c.y + pad;
}
// boxes already on screen slide there; new ones appear there, then fade in
const arriving = node.filter(function () { return this.__arriving; });
const staying = node.filter(function () { return !this.__arriving; });
arriving.interrupt("move").attr("transform", d => `translate(${d.x},${d.y})`);
staying.transition("move").duration(t_move).ease(d3.easeCubicInOut)
.attr("transform", d => `translate(${d.x},${d.y})`);
arriving.property("__arriving", false)
.transition("fade").delay(t_move).duration(t_show).style("opacity", 1);
// rows that appeared: fade in once their box has grown
node.selectAll(":scope > g.rows > text.row").filter(function () { return this.__arriving; })
.property("__arriving", false)
.transition("fade").delay(t_move).duration(t_show).style("opacity", 1);
// + slack: d3 starts the transitions on its next timer tick
settled_at = performance.now() + t_move + t_show + 50;
drawn_at = new Map(nodes.map(d => [d.id, {x: d.x, y: d.y, w: d.w, h: d.h}]));
// the camera moves in step with the boxes: the anchor stays put. The
// first draw: at once -- nothing on screen yet to move from
if (camera_to && camera_to.instant)
svg.interrupt("move").call(zoom.transform, camera_to.t);
else if (camera_to)
svg.transition("move").duration(t_move).ease(d3.easeCubicInOut)
.call(zoom.transform, camera_to.t);
drawing_size = {w: laid.width + 2 * pad, h: laid.height + 2 * pad};
draw_groups(camera, groups);
draw_extent(camera, drawing_size, camera_to && camera_to.instant);
// 4. the edges, as ELK routed them -- in the frame of their `container`
// (an edge inside one group comes back relative to that group)
const laid_edge = new Map(laid.edges.map(le => [le.id, le]));
const routed = edges.map((e, i) => {
const le = laid_edge.get(`e${i}`);
const off = (le.container && le.container !== "root" && at.get(le.container)) || {x: 0, y: 0};
const pts = [];
for (const sec of (le.sections || [])) {
pts.push(sec.startPoint, ...(sec.bendPoints || []), sec.endPoint);
}
for (const i in pts)
pts[i] = {x: pts[i].x + off.x, y: pts[i].y + off.y};
return {...e, key: `${e.kind}:${e.source}>${e.target}`, pts};
});
// ownership edges (link, owns) only order the layers: not drawn, but
// for a wanted fallback
// each edge a group: its casing, then the edge. A later edge's casing
// cuts a gap in the earlier edges it crosses -- a crossing reads as one
// line passing under another, not as a junction
const edge_gs = edge_layer.selectAll(":scope > g.edge-g")
.data(routed.filter(d => !is_ownership(d.kind) || d.drawn), d => d.key)
.join(enter => {
const g = enter.append("g").attr("class", "edge-g");
g.append("path").attr("class", "casing");
g.append("path");
return g;
});
// the new routes fade in once the boxes have moved
edge_gs.interrupt("fade").style("opacity", 0)
.transition("fade").delay(t_move).duration(t_show).style("opacity", 1);
const route = d => rounded_path(d.pts.map(p => ({x: p.x + pad, y: p.y + pad})), edge_corner_r);
edge_gs.select(":scope > path.casing").attr("d", route);
const paths = edge_gs.select(":scope > path:not(.casing)");
paths
.attr("class", d => `edge ${d.kind}` + (d.ref_kind ? ` from-${d.ref_kind}` : ""))
.attr("d", route)
// a member edge: hovering it lights its row too
.on("mouseenter", (ev, d) => d.kind === "member" && highlight_ref(d.row_keys, true))
.on("mouseleave", (ev, d) => d.kind === "member" && highlight_ref(d.row_keys, false))
// a member edge says which members it stands for: their rows may not be open
.each(function (d) {
d3.select(this).selectAll("title").data(d.kind === "member" ? [d] : [])
.join("title").text(e => `${box_label.get(e.from) || e.from} · ${e.labels.join(", ")}`
+ ` (${e.ref_kind})`);
});
order_edges(edge_gs);
}
/** arrowhead markers, one per edge colour (index.html picks one per edge
* class with marker-end), and the member edges' exit markers: a filled triangle, its tip at the edge's end,
* sized in drawing units -- the hot edge's wider stroke doesn't grow it
**/
const start_scale = 0.75; // exit markers: drawn at this fraction of their 12x10 viewBox
function define_arrowheads(svg) {
// a member edge's colour is its ref kind's (index.html: --edge-<kind>)
const kind_colour = k => `var(--edge-${k})`;
const ref_kinds = ["includes", "owns", "shares", "refers"];
const arrows = [["hot", "#e0730b"], ["own", "#999"],
...ref_kinds.map(k => [k, kind_colour(k)])];
const defs = svg.selectAll(":scope > defs.arrows").data([0]).join("defs").attr("class", "arrows");
defs.selectAll("marker.arrow").data(arrows, k => k[0])
.join(enter => enter.append("marker").attr("class", "arrow")
.attr("id", k => `arrow-${k[0]}`)
.attr("viewBox", "0 0 10 10").attr("refX", 10).attr("refY", 5)
.attr("markerWidth", 9).attr("markerHeight", 9)
.attr("markerUnits", "userSpaceOnUse").attr("orient", "auto")
.call(m => m.append("path").attr("d", "M0,0 L10,5 L0,10 Z").style("fill", k => k[1])));
// where a member edge leaves its holder: how the holder relates to the
// target -- ■ includes (by value), ◆ owns, ○ shares; refers: none.
// Drawn from the exit point outward, along the edge, in the edge's
// colour (orange when hot)
const shapes = [["includes", "M0,1 L8,1 L8,9 L0,9 Z", true],
["owns", "M0,5 L6,1.5 L12,5 L6,8.5 Z", true],
["shares", "M1,5 A4,4 0 1 1 9,5 A4,4 0 1 1 1,5 Z", false]];
const starts = [];
for (const [kind, d, filled] of shapes) {
starts.push({id: `start-${kind}`, d, colour: kind_colour(kind), filled});
starts.push({id: `start-${kind}-hot`, d, colour: "#e0730b", filled});
}
defs.selectAll("marker.start").data(starts, x => x.id)
.join(enter => enter.append("marker").attr("class", "start")
.attr("id", x => x.id)
.attr("viewBox", "0 0 12 10").attr("refX", 0).attr("refY", 5)
.attr("markerWidth", 12 * start_scale).attr("markerHeight", 10 * start_scale)
.attr("markerUnits", "userSpaceOnUse").attr("orient", "auto")
// the outline as wide as the edge's, whatever the scale
.call(m => m.append("path").attr("d", x => x.d)
.style("fill", x => x.filled ? x.colour : "#fff")
.style("stroke", x => x.colour).attr("stroke-width", 1.4 / start_scale)));
}
const edge_corner_r = 6; // an edge's bends: rounded, this radius at most
/** svg path through points @p pts, straight segments, each bend rounded
* (radius @p r, less where a segment is short): a turn then reads apart
* from a crossing. The last segment stays straight, for the arrowhead
**/
function rounded_path(pts, r) {
if (pts.length === 0)
return "";
let d = `M${pts[0].x},${pts[0].y}`;
for (let i = 1; i < pts.length - 1; i++) {
const a = pts[i - 1], b = pts[i], c = pts[i + 1];
const l1 = Math.hypot(b.x - a.x, b.y - a.y), l2 = Math.hypot(c.x - b.x, c.y - b.y);
const k = Math.min(r, l1 / 2, l2 / 2);
if (k < 0.5) {
d += ` L${b.x},${b.y}`;
continue;
}
const p = {x: b.x + (a.x - b.x) * k / l1, y: b.y + (a.y - b.y) * k / l1};
const q = {x: b.x + (c.x - b.x) * k / l2, y: b.y + (c.y - b.y) * k / l2};
d += ` L${p.x},${p.y} Q${b.x},${b.y} ${q.x},${q.y}`;
}
if (pts.length > 1) {
const z = pts[pts.length - 1];
d += ` L${z.x},${z.y}`;
}
return d;
}
/** @p member_edges merged per (source box, target box): {source, target,
* port, keys, labels, ref_kind -- the strongest}, in order of each pair's
* first member
**/
function merge_parallel(member_edges) {
const by_pair = new Map();
for (const e of member_edges) {
const pair = `${e.source}>${e.target}`;
if (!by_pair.has(pair))
by_pair.set(pair, {source: e.source, target: e.target, port: `${pair}#port`,
keys: [], labels: [], ref_kinds: []});
const m = by_pair.get(pair);
m.keys.push(e.key);
m.labels.push(e.label);
m.ref_kinds.push(e.ref_kind);
}
for (const m of by_pair.values())
m.ref_kind = strongest_ref_kind(m.ref_kinds);
return [...by_pair.values()];
}
/** light up (@p on) or restore the ref rows with keys @p row_keys and the
* member edges standing for them. Member edges leave the bottom of the
* box, so this is how a row and its edge are seen to belong together:
* hovering a row lights its edge; hovering an edge, every row it stands for
**/
function highlight_ref(row_keys, on) {
d3.selectAll("text.row").filter(r => r && row_keys.includes(r.key)).classed("hot", on);
d3.selectAll("path.edge.member").filter(e => e && e.row_keys.some(k => row_keys.includes(k)))
.classed("hot", on)
.each(function () { if (on) this.parentNode.parentNode.appendChild(this.parentNode); }); // its group on top
if (!on)
order_edges(d3.selectAll("g.edges > g.edge-g")); // back in rank order
}
/** an edge's stacking rank: member edges over the rest, and among them the
* strongest ref kind on top -- where edges share a box's entry port, the
* shared stretch and its arrowhead show the closest relation
**/
const edge_rank = {refers: 1, shares: 2, owns: 3, includes: 4};
/** put edge groups @p edge_gs in stacking order, by edge_rank (stable:
* equal ranks keep their order)
**/
function order_edges(edge_gs) {
edge_gs.sort((a, b) => (edge_rank[a.ref_kind] || 0) - (edge_rank[b.ref_kind] || 0));
}
// ----- context menu -----------------------------------------------------
const menu_el = document.getElementById("ctxmenu");
let menu_owner = null; // the box element the menu is for, to refocus
/** copy @p text; the clipboard api needs a secure context (https or
* localhost), so fall back to a hidden textarea when viewed from another host
**/
function copy_text(text) {
if (navigator.clipboard && window.isSecureContext)
return navigator.clipboard.writeText(text);
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
return Promise.resolve();
}
/** box @p d's menu item to show or hide its member rows' types; disabled
* while it draws no member rows (collapsed, say), where the change would
* not show
**/
function types_item(d) {
const on = typed_boxes.has(d.id);
return [on ? "Hide types" : "Show types",
() => {
if (on)
typed_boxes.delete(d.id);
else
typed_boxes.add(d.id);
if (last_event)
draw(last_event);
},
(d.rows && d.rows.length) ? null : "no member rows shown"];
}
/** the menu's items for box @p d: [label, action, reason-if-disabled] **/
function menu_items(d) {
const s = source_of(d.type);
const no_source = !d.type ? "no _canonical_type_ reported"
: !s ? "no source location"
: !s.href ? "no link provider" : null;
return [
[expanded.has(d.id) && d.expandable ? "Collapse" : "Expand",
() => toggle(d.id), d.expandable ? null : "no members shown by its printer"],
...children_items(d),
types_item(d),
[`Hide ${d.label}`, () => { hide_box(d.id); redraw(); },
d.id === "server" ? "the Webserver box is always shown" : null],
// one entry per child, in ownership order: Hide if drawn, else Show
...(d.children || []).map(k => shown_box_ids.has(k)
? [`Hide ▸ ${box_label.get(k) || k}`, () => { hide_box(k); redraw(); }, null]
: [`Show ▸ ${box_label.get(k) || k}`, () => { child_edges(d.id, k).forEach(key => wanted.add(key));
redraw(); }, null]),
["Open source", () => window.open(s.href, "_blank"), no_source],
["Show JSON", () => show_detail(d), d.obj ? null : "no object"],
["Copy type name", () => copy_text(d.type), d.type ? null : "no _canonical_type_ reported"],
["Copy id", () => copy_text(String(d.obj._id_)),
(d.obj && d.obj._id_ !== undefined) ? null : "no id"],
];
}
/** rows group @p rows_g: each row with a recorded old place (`__was`) that
* has moved -- y, x, or separator column -- goes back there and transitions
* to its new place; so do the squares behind its triangles (which sit just
* after the separator, so move by its column's and the row's y's change)
**/
function slide_rows(rows_g) {
const g = rows_g.node();
const ease = d3.easeCubicInOut;
for (const text of g.querySelectorAll(":scope > text.row")) {
const was = text.__was;
text.__was = null;
if (!was)
continue;
const meq = text.querySelector(":scope > tspan.meq");
const now = {x: +text.getAttribute("x"), y: +text.getAttribute("y"),
col: meq && meq.hasAttribute("x") ? +meq.getAttribute("x") : null};
const dcol = (was.col !== null && now.col !== null) ? now.col - was.col : now.x - was.x;
const dy = now.y - was.y;
if (now.x === was.x && dy === 0 && dcol === 0)
continue;
d3.select(text).attr("x", was.x).attr("y", was.y)
.transition("move").duration(t_move).ease(ease)
.attr("x", now.x).attr("y", now.y);
if (meq && was.col !== null && now.col !== null)
d3.select(meq).attr("x", was.col)
.transition("move").duration(t_move).ease(ease).attr("x", now.col);
for (const sq of g.querySelectorAll(":scope > rect.tbtn"))
if (sq.__text === text) {
const x = +sq.getAttribute("x"), y = +sq.getAttribute("y");
d3.select(sq).attr("x", x - dcol).attr("y", y - dy)
.transition("move").duration(t_move).ease(ease)
.attr("x", x).attr("y", y);
}
}
}
/** behind each triangle in rows group @p rows_g, a rounded square: plain
* until hovered, then white, part-transparent -- as the menu button.
* Clicking it does what clicking its triangle does. SVG text takes no
* background, so the square is a rect placed by the triangle's measured box
**/
function tri_buttons(rows_g) {
const g = rows_g.node();
rows_g.selectAll(":scope > rect.tbtn").remove();
for (const ts of g.querySelectorAll(":scope > text.row > tspan.tri")) {
const b = ts.getBBox();
const z = tri_button;
const rect = d3.select(g).insert("rect", ":first-child")
.property("__text", ts.parentNode)
.attr("class", "tbtn").attr("rx", 3)
.attr("x", b.x + b.width / 2 - z / 2).attr("y", b.y + b.height / 2 - z / 2)
.attr("width", z).attr("height", z);
const hot = (on) => rect.classed("hot", on);
d3.select(ts).on("mouseenter.tbtn", () => hot(true)).on("mouseleave.tbtn", () => hot(false));
rect.on("mouseenter", () => hot(true))
.on("mouseleave", () => hot(false))
.on("click", (ev) => {
ev.stopPropagation();
ts.dispatchEvent(new MouseEvent("click", {bubbles: true}));
});
}
}
/** the x at which row text @p text's separator (before the value) starts, as laid out without
* alignment; null if it has none
**/
function eq_x(text) {
let n = 0; // characters before the separator
for (const ts of text.querySelectorAll(":scope > tspan")) {
if (ts.classList.contains("meq"))
return text.getStartPositionOfChar(n).x;
// its own text, not a <title> tooltip's
for (const c of ts.childNodes)
if (c.nodeType === Node.TEXT_NODE)
n += c.nodeValue.length;
}
return null;
}
/** where the camera should go this draw, {t, instant}: the first draw,
* the drawing (size @p size) centred in the viewport (as Center) at scale
* 1, at once; after a reset (Show all / Hide all), the same, animated; for
* anchor box @p anchor, laid out at
* @p at (ELK positions), moved by as much as the box moved, scaled -- so
* on screen (k * x + t) the box stays put; else null, the camera stays
**/
function camera_target(anchor, at, pad, size) {
if (first_draw) {
first_draw = false;
camera_reset = false;
return {t: centred(size, 1), instant: true};
}
if (camera_reset) {
camera_reset = false;
return {t: centred(size, 1)};
}
const was = anchor !== null && drawn_at.get(anchor);
const c = anchor !== null && at.get(anchor);
if (!was || !c)
return null;
const t = d3.zoomTransform(graph_svg.node());
const dx = was.x - (c.x + pad), dy = was.y - (c.y + pad);
return {t: d3.zoomIdentity.translate(t.x + t.k * dx, t.y + t.k * dy).scale(t.k)};
}
/** what object @p id (a ref's target) is, for its row's tooltip: the box
* drawing it, or the box it is printed inside; drawn or not
**/
function ref_tooltip(id) {
const b = box_of_id.get(id);
if (b === undefined)
return `refers to ${id}: no box draws it`;
const where = own_box_ids.has(id) ? `refers to ${box_label.get(b) || b}`
: `refers to an object printed inside ${box_label.get(b) || b}`;
return `${where}${shown_box_ids.has(b) ? "" : " (hidden)"}\n${id}`;
}
/** member row @p r's tooltip: its type, metatype, canonical type and
* source location -- what the row does not show inline; with
* @p as_type (the row shows its type in place of its value), its value
* first -- not for a ref row, which keeps its arrow, or a struct that opens
**/
function row_tooltip(r, as_type) {
if (!r.m._canonical_type_)
return r.m._name_;
const s = source_of(r.m._canonical_type_);
return (as_type && r.cls !== "ref" && r.val.trim() ? `= ${r.val.trim()}\n` : "")
+ `${r.m._name_}: ${r.m._short_type_ || r.m._canonical_type_} [${r.m._metatype_ || "?"}]`
+ `\n${r.m._canonical_type_}`
+ (s ? `\n${s.file}:${s.line}` : "\n(no source location)")
+ (s && s.href ? "\nctrl-click: open source" : "");
}
/** member row @p r's menu heading: name: Type **/
function row_menu_head(r) {
return r.m._canonical_type_ ? `${r.m._name_}: ${r.m._short_type_ || r.m._canonical_type_}`
: r.m._name_;
}
/** member row @p r's menu items: [label, action, reason-if-disabled] **/
function row_menu_items(r) {
const t = r.m._canonical_type_;
const s = source_of(t);
const no_source = !t ? "no declared type"
: !s ? "no source location"
: !s.href ? "no link provider" : null;
const items = [
["Open source", () => window.open(s.href, "_blank"), no_source],
["Copy type name", () => copy_text(t), t ? null : "no declared type"],
];
if (r.expandable)
items.push([r.open ? "Collapse" : "Expand", () => toggle(r.key), null]);
if (r.ref_box) {
const label = box_label.get(r.ref_box) || r.ref_box;
items.push(shown_box_ids.has(r.ref_box)
? [`Hide ▸ ${label}`, () => toggle_ref(r), null]
: [`Show ▸ ${label}`, () => toggle_ref(r), null]);
}
return items;
}
/** open the context menu at page position @p x, @p y: heading @p head,
* items @p items ([label, action, reason-if-disabled]); focus returns to
* @p owner on close
**/
function show_menu(x, y, head_text, items, owner) {
menu_owner = owner;
menu_el.replaceChildren();
const head = document.createElement("div");
head.className = "ctxhead";
head.textContent = head_text;
menu_el.appendChild(head);
for (const [label, action, disabled] of items) {
const b = document.createElement("button");
b.textContent = label;
b.disabled = !!disabled;
if (disabled)
b.title = disabled;
b.onclick = () => {
// a box's menu item: that box is the redraw's anchor
const g = menu_owner && menu_owner.closest && menu_owner.closest("g.node");
hide_menu();
pending_anchor = g ? g.__data__.id : null;
action();
};
menu_el.appendChild(b);
}
menu_el.style.left = `${x}px`;
menu_el.style.top = `${y}px`;
menu_el.hidden = false;
const first = menu_el.querySelector("button:not(:disabled)");
if (first)
first.focus();
}
function hide_menu() {
if (menu_el.hidden)
return;
menu_el.hidden = true;
if (menu_owner)
menu_owner.focus();
menu_owner = null;
}
// arrows move between items; Escape closes
menu_el.addEventListener("keydown", (ev) => {
const items = [...menu_el.querySelectorAll("button:not(:disabled)")];
const i = items.indexOf(document.activeElement);
if (ev.key === "Escape") {
ev.preventDefault();
hide_menu();
} else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
ev.preventDefault();
const n = items.length;
if (n > 0)
items[(i + (ev.key === "ArrowDown" ? 1 : n - 1)) % n].focus();
}
});
// a click outside, resizing or leaving the window closes it. NOT scrolling:
// the menu sits in page coordinates, so it scrolls along with its box -- and
// closing on scroll closed menus opened while "Show JSON" (or focusing a box)
// scrolled the page
document.addEventListener("mousedown", (ev) => {
if (!menu_el.hidden && !menu_el.contains(ev.target))
hide_menu();
});
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") hide_menu(); });
window.addEventListener("resize", hide_menu);
window.addEventListener("blur", hide_menu);
/** "Show JSON": the box's object alone, below the graph **/
function show_detail(d) {
const detail_h = document.getElementById("detail-h");
const detail = document.getElementById("detail");
detail_h.textContent = `object: ${d.label}` + (d.type ? ` (${d.type})` : "");
detail.textContent = JSON.stringify(d.obj, null, 2);
detail_h.hidden = false;
detail.hidden = false;
detail_h.scrollIntoView({behavior: "smooth", block: "start"});
}