code-node-graph 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
code_node/report/graph.py CHANGED
@@ -17,7 +17,9 @@ from ..kit.highlight import _python_html, _python_lines
17
17
  from ..kit.render import _CALL_RE, _prose
18
18
  from ..kit.text import _esc
19
19
 
20
- from ..analysis.graph import CodeGraph, GraphEdge, GraphNode
20
+ from ..analysis.findings import _short_name
21
+ from ..analysis.graph import CodeGraph, GraphEdge, GraphNode, GraphView
22
+ from .changes import history_html
21
23
  from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
22
24
  from .editor import editor_name, open_at
23
25
  from .style import _ROOT
@@ -36,6 +38,9 @@ class _Graph:
36
38
  empty: str = "(nothing to draw)"
37
39
  sources: dict[str, str] = field(default_factory=dict)
38
40
  editor: str = "" # a link to open a line in an editor, from editor_link(): each place in a panel links to it
41
+ views: list[GraphView] = field(default_factory=list) # with JavaScript, a box's line of calls not drawn opens the
42
+ # graph from that function, from these (graph_views())
43
+ everything: CodeGraph | None = None # ...and a button in the bar draws every function, this graph
39
44
 
40
45
 
41
46
  _EDGE_WORDS = {
@@ -999,12 +1004,32 @@ def _g_calls(count: int) -> str:
999
1004
  return f"{count} call" if count == 1 else f"{count} calls"
1000
1005
 
1001
1006
 
1007
+ def _g_prose_top(card: _GCard) -> float:
1008
+ """
1009
+ Where a box's prose starts: under its header, its route's pill and its when pill.
1010
+ """
1011
+ return card.head + (_GRAPH_ROUTE if card.route else 0) + (_GRAPH_ROUTE if card.node.when else 0)
1012
+
1013
+
1014
+ def _g_more_y(card: _GCard) -> float:
1015
+ """
1016
+ The baseline of a box's line of calls not drawn.
1017
+ """
1018
+ return _g_prose_top(card) + 13 + _GRAPH_PROSE * len(card.prose)
1019
+
1020
+
1002
1021
  def _node_svg(
1003
- card: _GCard, box: tuple[float, float, float, float], neighbours: list[str], access: dict[str, str]
1022
+ card: _GCard,
1023
+ box: tuple[float, float, float, float],
1024
+ neighbours: list[str],
1025
+ access: dict[str, str],
1026
+ panel: str = "",
1027
+ opens: bool = False,
1004
1028
  ) -> str:
1005
1029
  """
1006
1030
  A box: its badge and name, what's under it, a route's pill, its listed lines (an operation's dot in its arrows'
1007
- colour, a line a warning is about with its flag) or its docstring, and a warning badge.
1031
+ colour, a line a warning is about with its flag) or its docstring, and a warning badge. panel is the id of its
1032
+ code's panel, and opens says its line of calls not drawn opens a graph of its own (a link drawn over it).
1008
1033
  """
1009
1034
  n = card.node
1010
1035
  x, y, w, h = box
@@ -1071,15 +1096,18 @@ def _node_svg(
1071
1096
  if _g_sans(f"{said}: {names}", 12.5) <= w - 28:
1072
1097
  said = f"{said}: {names}"
1073
1098
  break
1074
- parts.append(
1075
- f'<text class="pr mo" x="14" y="{top + 13 + _GRAPH_PROSE * len(card.prose):.1f}">{_esc(said)}</text>'
1076
- )
1099
+ parts.append(f'<text class="pr mo md" x="14" y="{_g_more_y(card):.1f}">{_esc(said)}</text>')
1077
1100
  if warn:
1078
1101
  parts.append(
1079
1102
  f'<g class="wb" transform="translate({w - 21:.0f},{badge_y + _G_BADGE / 2:.0f})"><circle r="8.5"/>'
1080
1103
  '<path d="M0,-4.2V.6M0,3.9V4"/></g>'
1081
1104
  )
1082
- classes = " ".join(["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]) + (" warn" if warn else "")
1105
+ classes = " ".join(
1106
+ ["cn", f"k-{n.kind}", f"n-{n.id}", *(f"a-{m}" for m in neighbours)]
1107
+ + ([f"p-{panel}"] if panel else [])
1108
+ + (["hd"] if opens else [])
1109
+ + (["warn"] if warn else [])
1110
+ )
1083
1111
  return (
1084
1112
  f'<g class="{_esc(classes)}" transform="translate({x:.1f},{y:.1f})">'
1085
1113
  f'<rect class="bg" width="{w:.0f}" height="{h:.0f}" rx="11"/>'
@@ -1114,11 +1142,11 @@ def _g_filled(line: str) -> bool:
1114
1142
  return bool(re.sub(r"<[^>]*>", "", line).strip())
1115
1143
 
1116
1144
 
1117
- def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "") -> str:
1145
+ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "", panel: str = "") -> str:
1118
1146
  """
1119
- A box's code, in a panel that slides in from the right when the box is clicked (its id is the link's target, so
1120
- it's CSS's :target that opens it): what the box is, then each place it's about with its lines numbered, the lines
1121
- worth pointing at lit. '' when none of its code is in the files read.
1147
+ A box's code, in a panel that slides in from the right when the box is clicked (its id, panel or the box's own, is
1148
+ the link's target, so it's CSS's :target that opens it): what the box is, then each place it's about with its
1149
+ lines numbered, the lines worth pointing at lit. '' when none of its code is in the files read.
1122
1150
  """
1123
1151
  shown, lit_any = [], False
1124
1152
  for span in n.code[:_G_PANEL_SPANS]:
@@ -1157,16 +1185,21 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1157
1185
  rest = len(n.code) - _G_PANEL_SPANS
1158
1186
  if rest > 0:
1159
1187
  shown.append(f'<div class="csm">+{_plural(rest, "more place")}: the tables below list them all</div>')
1160
- said = "".join(f'<p class="csd">{_prose(line)}</p>' for line in (n.tip or "").split("\n") if line)
1188
+ said = "".join( # not the calls this graph leaves out: a function's panel is the same in every graph it's in
1189
+ f'<p class="csd">{_prose(line)}</p>'
1190
+ for line in (n.tip or "").split("\n")
1191
+ if line and not (n.more and line.startswith("It calls ") and "the graph leaves out" in line)
1192
+ )
1161
1193
  if lit_any:
1162
1194
  said += (
1163
1195
  '<p class="csd csl">The lit lines are where it calls, reads or sends what its arrows show.</p>'
1164
1196
  if n.kind in ("handler", "function", "start")
1165
1197
  else '<p class="csd csl">The lit lines are where the code uses it.</p>'
1166
1198
  )
1199
+ said += history_html(n.history)
1167
1200
  mono = " mono" if n.kind in ("handler", "function") else ""
1168
1201
  return (
1169
- f'<aside class="cside" id="{_esc(_g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1202
+ f'<aside class="cside" id="{_esc(panel or _g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
1170
1203
  f'<div class="csh"><div class="cst"><span class="csk">{_esc(_G_PANEL_KINDS.get(n.kind, n.kind))}</span>'
1171
1204
  f'<b class="csn{mono}">{_esc(n.label)}</b>'
1172
1205
  + (f'<span class="css">{_esc(n.sub)}</span>' if n.sub else "")
@@ -1269,6 +1302,10 @@ def _g_tip(n: GraphNode, lines_of: Callable[[str], list[str]], opens: bool) -> t
1269
1302
  else:
1270
1303
  body.append(f'<p class="ctp">{_g_tip_text(line)}</p>')
1271
1304
  tall += 23 * math.ceil(len(line) / 42) + 10
1305
+ changed = history_html(n.history, short=True)
1306
+ if changed:
1307
+ body.append(changed)
1308
+ tall += 23 * math.ceil(len(re.sub(r"<[^>]*>", "", changed)) / 42) + 14
1272
1309
  if warnings:
1273
1310
  body.append(
1274
1311
  '<div class="ctw"><b>Warning</b><ul>'
@@ -1368,12 +1405,178 @@ def _g_swatch(kind: str) -> str:
1368
1405
  )
1369
1406
 
1370
1407
 
1371
- @_draws(_Graph, "html")
1372
- def _graph_html(block: _Graph, report: _Report) -> str:
1373
- title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
1374
- graph = block.graph
1375
- if not graph.nodes:
1376
- return title + f'<div class="more">{_esc(block.empty)}</div>'
1408
+ _G_SCRIPT = """<script>
1409
+ (() => {
1410
+ // A graph's views: a box's line of calls not drawn, the path over a view and the bar's All button link to
1411
+ // #<group>-v<k> (or -all, or -main), and each is a <template> the graph's box, path and key are swapped from.
1412
+ // Any other address (a box's code open, or closed) leaves the view as it is.
1413
+ const parts = ["cgc", "cg", "cgk"];
1414
+ const show = (wrap) => {
1415
+ const group = wrap.dataset.group, id = decodeURIComponent(location.hash.slice(1));
1416
+ const named = id === group + "-main" || id === group + "-all" || id.startsWith(group + "-v");
1417
+ if (id && !named) return;
1418
+ const view = named && id !== group + "-main" ? id : "";
1419
+ if ((wrap.dataset.view || "") === view) return;
1420
+ const from = view ? document.getElementById(view)?.content : wrap.main;
1421
+ if (!from) return;
1422
+ for (const part of parts) {
1423
+ wrap.querySelector(".cgb ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1424
+ }
1425
+ wrap.dataset.view = view;
1426
+ wrap.querySelector(".cgb").scrollIntoView({block: "nearest"});
1427
+ };
1428
+ for (const wrap of document.querySelectorAll(".cgw[data-group]")) {
1429
+ const main = document.createElement("template");
1430
+ for (const part of parts) {
1431
+ const kept = document.createElement("div");
1432
+ kept.className = "x-" + part;
1433
+ kept.innerHTML = wrap.querySelector(".cgb ." + part).innerHTML;
1434
+ main.content.append(kept);
1435
+ }
1436
+ wrap.main = main.content;
1437
+ wrap.classList.add("js");
1438
+ show(wrap);
1439
+ }
1440
+ addEventListener("hashchange", () => document.querySelectorAll(".cgw.js").forEach(show));
1441
+ // A box's code: a click anywhere outside its panel closes it, as its × does, and so does Escape. A link (another
1442
+ // box, a view) and the graph's bar do what they do with the panel left to them.
1443
+ const close = () => { if (document.querySelector(".cside:target")) location.hash = "cn-closed"; };
1444
+ document.addEventListener("click", (e) => {
1445
+ const open = document.querySelector(".cside:target");
1446
+ if (open && !open.contains(e.target) && !e.target.closest("a[href], label, input, .cgt")) close();
1447
+ });
1448
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
1449
+ // Moving about a graph as on a canvas: drag it to pan, Ctrl or ⌘ with the wheel (or a trackpad's pinch) to zoom
1450
+ // around the pointer, and + - 0 when it has focus. A zoom of its own unchecks the bar's buttons, so the plain rule
1451
+ // (the SVG's width times --z) draws it; picking a button again takes it back. Its box keeps one size, and Fit
1452
+ // (--fit) puts all of it in that box, both ways.
1453
+ for (const wrap of document.querySelectorAll(".cgw")) {
1454
+ const box = wrap.querySelector(".cgb .cg"), radios = wrap.querySelectorAll(":scope > .cgs[type=radio]");
1455
+ const said = wrap.querySelector(".cgtp"), svg = () => box.querySelector("svg");
1456
+ const scale = () => {
1457
+ const s = svg();
1458
+ return s && s.viewBox.baseVal.width ? s.getBoundingClientRect().width / s.viewBox.baseVal.width : 1;
1459
+ };
1460
+ const fit = () => {
1461
+ const s = svg(), w = s && s.viewBox.baseVal.width, h = s && s.viewBox.baseVal.height;
1462
+ if (w && h && box.clientWidth) {
1463
+ box.style.setProperty("--fit", Math.min((box.clientWidth - 24) / w, (box.clientHeight - 24) / h, 1.5));
1464
+ }
1465
+ };
1466
+ const tell = () => { fit(); if (said) said.textContent = Math.round(scale() * 100) + "%"; };
1467
+ const pan = (dx, dy) => { // the graph's box first, then the page for what it can't scroll
1468
+ const top = box.scrollTop;
1469
+ box.scrollLeft += dx;
1470
+ box.scrollTop += dy;
1471
+ const left = dy - (box.scrollTop - top);
1472
+ if (Math.abs(left) > 0.5) scrollBy(0, left);
1473
+ };
1474
+ const zoom = (by, x, y) => { // keeps the point at (x, y) where it is
1475
+ const s = svg();
1476
+ if (!s) return;
1477
+ const from = scale(), to = Math.min(4, Math.max(0.1, from * by)), was = s.getBoundingClientRect();
1478
+ const px = (x - was.left) / from, py = (y - was.top) / from;
1479
+ radios.forEach((r) => { r.checked = false; });
1480
+ box.style.setProperty("--z", to);
1481
+ const now = s.getBoundingClientRect();
1482
+ pan(now.left + px * to - x, now.top + py * to - y);
1483
+ tell();
1484
+ };
1485
+ const auto = () => { // back to the Auto button
1486
+ const radio = wrap.querySelector(":scope > .cgz-auto");
1487
+ radio.checked = true;
1488
+ radio.dispatchEvent(new Event("change", {bubbles: true}));
1489
+ };
1490
+ const step = (by) => { // around the middle of what's in sight
1491
+ const r = box.getBoundingClientRect();
1492
+ zoom(by, r.left + r.width / 2, (Math.max(r.top, 0) + Math.min(r.bottom, innerHeight)) / 2);
1493
+ };
1494
+ wrap.querySelector(".cgb .zi")?.addEventListener("click", () => step(1.25));
1495
+ wrap.querySelector(".cgb .zo")?.addEventListener("click", () => step(0.8));
1496
+ let drag = null, moved = false;
1497
+ box.addEventListener("pointerdown", (e) => {
1498
+ moved = false;
1499
+ if (e.button === 0 && e.pointerType !== "touch") drag = {x: e.clientX, y: e.clientY, id: e.pointerId};
1500
+ });
1501
+ box.addEventListener("pointermove", (e) => {
1502
+ if (!drag || e.pointerId !== drag.id) return;
1503
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
1504
+ if (!moved && Math.hypot(dx, dy) < 4) return; // a click on a box stays a click
1505
+ if (!moved) { moved = true; box.setPointerCapture(e.pointerId); box.classList.add("pan"); }
1506
+ pan(-dx, -dy);
1507
+ drag.x = e.clientX;
1508
+ drag.y = e.clientY;
1509
+ });
1510
+ const end = () => { drag = null; box.classList.remove("pan"); };
1511
+ box.addEventListener("pointerup", end);
1512
+ box.addEventListener("pointercancel", end);
1513
+ box.addEventListener("click", (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); } }, true);
1514
+ box.addEventListener("dragstart", (e) => e.preventDefault());
1515
+ box.addEventListener("wheel", (e) => {
1516
+ if (!e.ctrlKey && !e.metaKey) return;
1517
+ e.preventDefault();
1518
+ const d = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
1519
+ zoom(Math.exp(-Math.max(-100, Math.min(100, d)) * 0.0015), e.clientX, e.clientY);
1520
+ }, {passive: false});
1521
+ box.tabIndex = 0;
1522
+ box.addEventListener("keydown", (e) => {
1523
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1524
+ if (e.key === "+" || e.key === "=") step(1.25);
1525
+ else if (e.key === "-" || e.key === "_") step(0.8);
1526
+ else if (e.key === "0") auto();
1527
+ else return;
1528
+ e.preventDefault();
1529
+ });
1530
+ wrap.addEventListener("change", (e) => {
1531
+ if (!e.target.matches(".cgs")) return; // a zoom button, or full screen: its box's size changes
1532
+ if (e.target.type === "radio") box.style.removeProperty("--z");
1533
+ requestAnimationFrame(tell);
1534
+ });
1535
+ addEventListener("hashchange", () => requestAnimationFrame(tell));
1536
+ addEventListener("resize", tell);
1537
+ wrap.classList.add("pz");
1538
+ tell();
1539
+ }
1540
+ })();
1541
+ </script>"""
1542
+
1543
+
1544
+ def _g_hidden(graph: CodeGraph, opens: bool) -> str:
1545
+ """
1546
+ The legend's pill for the functions a graph leaves out, and why: without JavaScript where to find them, with it
1547
+ (when opens) that a box's line of them opens them.
1548
+ """
1549
+ if not graph.hidden:
1550
+ return ""
1551
+ why = (
1552
+ "too deep"
1553
+ if graph.too_deep == graph.hidden
1554
+ else "too many to draw at once"
1555
+ if not graph.too_deep
1556
+ else "too deep or too many"
1557
+ )
1558
+ head = f"<b>+{graph.hidden}</b>{'more function' if graph.hidden == 1 else 'more functions'} not drawn ({why})"
1559
+ pill = f'<span class="kp kh{" nj" if opens else ""}">{head}: the Step by step tab has them all</span>'
1560
+ if opens:
1561
+ pill += f'<span class="kp kh oj">{head}: click a box\'s blue line of calls to open them</span>'
1562
+ return pill
1563
+
1564
+
1565
+ def _g_canvas(
1566
+ graph: CodeGraph,
1567
+ block: _Graph,
1568
+ report: _Report,
1569
+ lines_of: Callable[[str], list[str]],
1570
+ panels: dict[str, tuple[str, str]],
1571
+ opens: dict[str, str],
1572
+ prefix: str,
1573
+ ) -> tuple[str, str]:
1574
+ """
1575
+ One graph drawn: its SVG and its legend. Its boxes' panels go into panels ({a function, or a panel without its
1576
+ id: (its id, the panel)}, one for a function or the same code wherever it's drawn, the first graph's; a new one's
1577
+ id starts with prefix), and a box whose function is in opens ({module.name: a
1578
+ link}) has its line of calls not drawn made that link.
1579
+ """
1377
1580
  cards = _g_cards(graph)
1378
1581
  boxes, routes, width, tall, xs, widths, column_of = _graph_layout(graph, cards)
1379
1582
  ends = _g_anchors(graph, cards, boxes)
@@ -1386,20 +1589,24 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1386
1589
  for name in _g_names(e):
1387
1590
  access[e.target].setdefault(name, e.kind)
1388
1591
  root = report.root or _ROOT
1389
- highlighted: dict[str, list[str]] = {}
1390
-
1391
- def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
1392
- if file not in highlighted:
1393
- highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
1394
- return highlighted[file]
1395
-
1396
- panels = {n.id: _g_panel(n, lines_of, block.editor) for n in graph.nodes} if block.sources else {}
1397
- for n in graph.nodes:
1398
- if panels.get(n.id): # the box whose code is open, outlined
1399
- report.rules.add(
1400
- f".{root} .cgw:has(#{_g_panel_id(n)}:target) .cg .n-{n.id} .fg"
1401
- "{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
1402
- )
1592
+ panel_of: dict[str, str] = {} # a box -> its panel's id
1593
+ if block.sources:
1594
+ for n in graph.nodes:
1595
+ same = "\0" + n.name if n.name else ""
1596
+ if same not in panels:
1597
+ html = _g_panel(n, lines_of, block.editor, "\0")
1598
+ if not html:
1599
+ continue
1600
+ same = same or html
1601
+ if same not in panels:
1602
+ key = prefix + _g_panel_id(n)
1603
+ panels[same] = (key, html.replace('id="\0"', f'id="{key}"', 1))
1604
+ panel_of[n.id] = panels[same][0]
1605
+ for key in set(panel_of.values()): # the box whose code is open, outlined
1606
+ report.rules.add(
1607
+ f".{root} .cgw:has(#{key}:target) .cg .p-{key} .fg"
1608
+ "{stroke:light-dark(#2563eb,#60a5fa);stroke-opacity:1;stroke-width:2}"
1609
+ )
1403
1610
  for n in graph.nodes: # pointing at a box leaves only it, its arrows and the boxes they join in sight
1404
1611
  report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .ce:not(.s-{n.id}):not(.t-{n.id}){{opacity:.06}}")
1405
1612
  report.rules.add(f".{root} .cg:has(.n-{n.id}:hover) .cn:not(.n-{n.id}):not(.a-{n.id}){{opacity:.28}}")
@@ -1446,16 +1653,33 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1446
1653
  marked.add((e.source, e.target))
1447
1654
  edges = "".join(body for body, _ in drawn)
1448
1655
  ports = "".join(dots for _, dots in drawn)
1656
+ opened = {n.id: opens[n.name] for n in graph.nodes if n.more and n.name in opens}
1449
1657
  nodes = "".join(
1450
1658
  (
1451
- f'<a class="cl" href="#{_esc(_g_panel_id(n))}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
1452
- if panels.get(n.id)
1659
+ f'<a class="cl" href="#{_esc(panel_of[n.id])}" aria-label="{_esc("Show the code of " + n.label)}">{box}</a>'
1660
+ if n.id in panel_of
1453
1661
  else box
1454
1662
  )
1455
1663
  for n in graph.nodes
1456
- for box in [_node_svg(cards[n.id], boxes[n.id], list(dict.fromkeys(near[n.id])), access[n.id])]
1664
+ for box in [
1665
+ _node_svg(
1666
+ cards[n.id],
1667
+ boxes[n.id],
1668
+ list(dict.fromkeys(near[n.id])),
1669
+ access[n.id],
1670
+ panel_of.get(n.id, ""),
1671
+ n.id in opened,
1672
+ )
1673
+ ]
1674
+ )
1675
+ links = "".join( # over each line of calls not drawn that opens a graph of its own: drawn with JavaScript only
1676
+ f'<a class="dr" href="#{_esc(link)}" aria-label="{_esc(f"Open the calls {n.label} makes")}">'
1677
+ f'<rect x="{boxes[n.id][0] + 8:.1f}" y="{boxes[n.id][1] + _g_more_y(cards[n.id]) - 14:.1f}" '
1678
+ f'width="{boxes[n.id][2] - 16:.0f}" height="19" rx="5"/></a>'
1679
+ for n in graph.nodes
1680
+ if (link := opened.get(n.id))
1457
1681
  )
1458
- tips = "".join(_g_tip_svg(n, boxes[n.id], width, tall, lines_of, bool(panels.get(n.id))) for n in graph.nodes)
1682
+ tips = "".join(_g_tip_svg(n, boxes[n.id], width, tall, lines_of, n.id in panel_of) for n in graph.nodes)
1459
1683
  kinds = {e.kind for e in graph.edges}
1460
1684
  pills = [f'<span class="kp">{_g_swatch(k)}{_esc(word)}</span>' for k, word in _G_LEGEND if k in kinds]
1461
1685
  if any(e.guessed for e in graph.edges):
@@ -1474,22 +1698,71 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1474
1698
  f'aria-hidden="true"><circle r="7"/><path transform="scale(.6) translate(-8,-8)" d="{_G_LOOP}"/>'
1475
1699
  "</svg>in a loop: once per item</span>"
1476
1700
  )
1477
- hidden = (
1478
- (
1479
- f'<span class="kp kh"><b>+{graph.hidden}</b>{"more function" if graph.hidden == 1 else "more functions"} '
1480
- "not drawn (too deep or too many): the Step by step tab has them all</span>"
1481
- )
1482
- if graph.hidden
1483
- else ""
1484
- )
1485
1701
  hint = (
1486
1702
  '<span class="kt">Point at a box to see only what it\'s joined to'
1487
- + ("; click it to read its code" if any(panels.values()) else "")
1488
- + "</span>"
1703
+ + ("; click it to read its code" if panel_of else "")
1704
+ + '</span><span class="kt pzo">Drag to move about; Ctrl or ⌘ and scroll, or pinch, to zoom</span>'
1489
1705
  )
1706
+ svg = (
1707
+ f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
1708
+ f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
1709
+ f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{links}{ports}{tips}</svg>"
1710
+ )
1711
+ return svg, "".join(pills) + _g_hidden(graph, bool(opened)) + hint
1712
+
1713
+
1714
+ def _g_path(path: list[str], links: dict[str, str], main: str, last: str = "") -> str:
1715
+ """
1716
+ The calls from the entry point down to a view's function, over its graph: each one that has a view of its own a
1717
+ link to it, and a link back to the whole graph first. last, when given, is said after them in place of the last.
1718
+ """
1719
+ names = [_esc(_short_name(name)) + "()" for name in path]
1720
+ steps = [
1721
+ f'<a href="#{_esc(links[name])}"><code>{said}</code></a>' if name in links else f"<code>{said}</code>"
1722
+ for name, said in zip(path[:-1], names[:-1])
1723
+ ]
1724
+ steps.append(_esc(last) if last else f"<b><code>{names[-1]}</code></b>" if names else "")
1725
+ return f'<a class="cgcb" href="#{_esc(main)}">← The whole graph</a>' + "".join(
1726
+ f'<span class="cgcs">›</span>{step}' for step in steps
1727
+ )
1728
+
1729
+
1730
+ @_draws(_Graph, "html")
1731
+ def _graph_html(block: _Graph, report: _Report) -> str:
1732
+ title = f"<h4>{_prose(block.title)}</h4>" if block.title else ""
1733
+ graph = block.graph
1734
+ if not graph.nodes:
1735
+ return title + f'<div class="more">{_esc(block.empty)}</div>'
1736
+ highlighted: dict[str, list[str]] = {}
1737
+
1738
+ def lines_of(file: str) -> list[str]: # a file's lines in colour, each file coloured once
1739
+ if file not in highlighted:
1740
+ highlighted[file] = _python_lines(block.sources[file]) if file in block.sources else []
1741
+ return highlighted[file]
1742
+
1490
1743
  made = report.shared("graphs", lambda: [0]) # graphs drawn so far, so each one's buttons have ids of their own
1491
1744
  group = f"cn-graph{made[0]}"
1492
1745
  made[0] += 1
1746
+ links = {view.name: f"{group}-v{k}" for k, view in enumerate(block.views, 1)}
1747
+ panels: dict[str, tuple[str, str]] = {}
1748
+ svg, legend = _g_canvas(graph, block, report, lines_of, panels, links, "")
1749
+ views = []
1750
+ for k, view in enumerate(block.views, 1):
1751
+ drawn, key = _g_canvas(view.graph, block, report, lines_of, panels, links, f"v{k}-")
1752
+ views.append(
1753
+ f'<template id="{group}-v{k}"><div class="x-cgc">{_g_path(view.path, links, group + "-main")}</div>'
1754
+ f'<div class="x-cg">{drawn}</div><div class="x-cgk">{key}</div></template>'
1755
+ )
1756
+ everything = ""
1757
+ if block.everything is not None and block.everything.nodes:
1758
+ count = sum(1 for n in block.everything.nodes if n.kind in ("handler", "function"))
1759
+ drawn, key = _g_canvas(block.everything, block, report, lines_of, panels, {}, "all-")
1760
+ views.append(
1761
+ f'<template id="{group}-all"><div class="x-cgc">'
1762
+ f"{_g_path([], links, group + '-main', f'Every function, all {count} of them')}</div>"
1763
+ f'<div class="x-cg">{drawn}</div><div class="x-cgk">{key}</div></template>'
1764
+ )
1765
+ everything = f'<a class="cgta oj" href="#{group}-all" title="Every function in one graph">All {count}</a>'
1493
1766
  # its zoom and full screen: inputs before the box they change, so CSS reaches it with ~ (no :has(), no script)
1494
1767
  switches = f'<input type="checkbox" class="cgs cgfs" id="{group}-full">' + "".join(
1495
1768
  f'<input type="radio" class="cgs cgz-{key}" name="{group}-zoom" id="{group}-{key}"'
@@ -1497,7 +1770,7 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1497
1770
  for key, _, _ in _GRAPH_ZOOMS
1498
1771
  )
1499
1772
  bar = (
1500
- '<div class="cgt"><span class="cgtz" role="group" aria-label="Zoom">'
1773
+ f'<div class="cgt">{everything}<span class="cgtz" role="group" aria-label="Zoom">'
1501
1774
  + "".join(
1502
1775
  f'<label class="zl-{key}" for="{group}-{key}" title="{_esc(tip)}">{_esc(word)}</label>'
1503
1776
  for key, word, tip in _GRAPH_ZOOMS
@@ -1505,14 +1778,24 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1505
1778
  + f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
1506
1779
  '<span class="off">Close full screen</span></label></div>'
1507
1780
  )
1781
+ zoom = ( # over the graph's corner, as a canvas has it: with JavaScript only
1782
+ '<span class="cgtm pzo" role="group" aria-label="Zoom out or in">'
1783
+ '<button type="button" class="zo" title="Zoom out (-, or Ctrl and scroll)">−</button>'
1784
+ '<span class="cgtp" title="How big the graph is drawn"></span>'
1785
+ '<button type="button" class="zi" title="Zoom in (+, or Ctrl and scroll)">+</button></span>'
1786
+ )
1787
+ script = ""
1788
+ if not report.shared("graph-script", lambda: [False])[0]: # once, for every graph: moving about and zooming
1789
+ report.shared("graph-script", lambda: [False])[0] = True
1790
+ script = _G_SCRIPT
1508
1791
  # in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
1509
1792
  # so a radio button elsewhere (the walk's steps) doesn't make the browser restyle the whole page
1793
+ data = f' data-group="{group}"' if views else ""
1510
1794
  return (
1511
- f'{title}<div class="cgw">{switches}<div class="cgb">{bar}<div class="cg">'
1512
- f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
1513
- f'style="--w:{width:.0f}px" role="img" aria-label="{_esc(block.title or "How the code fits together")}">'
1514
- f"{_g_captions(graph, xs, widths, column_of)}{edges}{nodes}{ports}{tips}</svg></div>"
1515
- f'<div class="cgk">{"".join(pills)}{hidden}{hint}</div></div>{"".join(panels.values())}</div>'
1795
+ f'{title}<div class="cgw"{data}>{switches}<div class="cgb">{bar}'
1796
+ f'<nav class="cgc" aria-label="Where this graph starts"></nav><div class="cgv"><div class="cg">{svg}</div>{zoom}</div>'
1797
+ f'<div class="cgk">{legend}</div></div>{"".join(html for _, html in panels.values())}{"".join(views)}</div>'
1798
+ f"{script}"
1516
1799
  )
1517
1800
 
1518
1801
 
code_node/report/style.py CHANGED
@@ -102,7 +102,8 @@ _EXTRA_CSS = """\
102
102
  .cnd :is(.flow,.cside,.walk,.ctip) .pa{font-style:italic}
103
103
  .cnd :is(.flow,.cside,.walk,.ctip) .pc{opacity:.6;font-style:italic}
104
104
  .cnd .cgb{width:fit-content;max-width:min(100%,1180px)}
105
- .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;margin:6px 0 8px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
105
+ .cnd .cgv{position:relative;margin:6px 0 8px}
106
+ .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
106
107
  background-image:radial-gradient(circle at 1px 1px,rgba(127,127,127,.3) 1px,transparent 0);background-position:9px 9px}
107
108
  .cnd .cg svg{display:block;width:calc(var(--w)*var(--z,1));max-width:none;height:auto;overflow:visible;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
108
109
  .cnd .cgw{position:relative}
@@ -121,10 +122,38 @@ _EXTRA_CSS = """\
121
122
  .cnd .cgz-auto:checked~.cgb .cg svg{width:var(--w);max-width:100%;min-width:calc(var(--w)*.8)}
122
123
  .cnd .cgz-fit:checked~.cgb .cg svg{width:var(--w);max-width:100%}
123
124
  .cnd .cgfs:checked~.cgb{position:fixed;inset:0;z-index:25;width:auto;max-width:none;display:flex;flex-direction:column;box-sizing:border-box;padding:12px clamp(12px,2vw,28px) 4px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
124
- .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0;margin:0 0 8px}
125
+ .cnd .cgfs:checked~.cgb .cgv{flex:1;min-height:0;display:flex;flex-direction:column;margin:0 0 8px}
126
+ .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0}
125
127
  .cnd .cgfs:checked~.cgz-fit:checked~.cgb .cg svg{width:100%;height:100%;max-width:none}
126
128
  .cnd .cgfs:checked~.cgz-auto:checked~.cgb .cg svg{min-width:0}
127
- @media (min-width:1100px){.cnd .cgw:has(.cside:target) .cgfs:checked~.cgb{right:min(660px,94vw)}}
129
+ .cnd .cgw:not(.js) :is(.oj,.dr),.cnd .cgw.js .nj{display:none}
130
+ .cnd .cgw:not(.pz) .pzo{display:none}
131
+ .cnd .cgw.pz .cg{cursor:grab;user-select:none;-webkit-user-select:none}
132
+ .cnd .cgw.pz .cg.pan,.cnd .cgw.pz .cg.pan *{cursor:grabbing}
133
+ .cnd .cgw.pz .cg:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
134
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb{width:100%;max-width:none}
135
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb .cg{height:max(360px,72vh)}
136
+ .cnd .cgw.pz .cg{display:flex}
137
+ .cnd .cgw.pz .cg svg{flex:none;margin:auto}
138
+ .cnd .cgw.pz>.cgz-fit:checked~.cgb .cg svg{width:calc(var(--w)*var(--fit,1));height:auto;max-width:none}
139
+ .cnd .cgtm{position:absolute;right:14px;bottom:14px;z-index:2;display:inline-flex;align-items:center;font-size:12px;user-select:none;-webkit-user-select:none;border:1px solid rgba(127,127,127,.3);border-radius:8px;overflow:hidden;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));box-shadow:0 1px 2px rgba(0,0,0,.06),0 4px 12px rgba(15,23,42,.08)}
140
+ .cnd .cgtm button{font:inherit;font-size:16px;line-height:1;color:inherit;background:none;border:0;padding:6px 11px;cursor:pointer;opacity:.75}
141
+ .cnd .cgtm button:hover{opacity:1;background:rgba(127,127,127,.08)}
142
+ .cnd .cgtm button:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
143
+ .cnd .cgtp{min-width:3.4em;padding:3px 2px;text-align:center;opacity:.7;font-variant-numeric:tabular-nums;border-inline:1px solid rgba(127,127,127,.2)}
144
+ .cnd .cgta{padding:3px 10px;border:1px solid rgba(127,127,127,.28);border-radius:8px;color:inherit;text-decoration:none;opacity:.7}
145
+ .cnd .cgta:hover{opacity:.95;background:rgba(127,127,127,.08)}
146
+ .cnd .cgw[data-view$="-all"] .cgta{opacity:1;font-weight:650;background:rgba(127,127,127,.14)}
147
+ .cnd .cgc{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 7px;margin:0 0 6px;font-size:13px}
148
+ .cnd .cgc:empty{display:none}
149
+ .cnd .cgc a{color:light-dark(#2563eb,#60a5fa);text-decoration:none}
150
+ .cnd .cgc a:hover{text-decoration:underline}
151
+ .cnd .cgc code{font-family:var(--mono);font-size:12.5px}
152
+ .cnd .cgc .cgcs{opacity:.4}
153
+ .cnd .cg .dr rect{fill:transparent;cursor:pointer}
154
+ .cnd .cg .dr:hover rect{fill:rgba(37,99,235,.09)}
155
+ .cnd .cg .dr:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa)}
156
+ .cnd .cgw.js .cg .hd .md{fill:light-dark(#2563eb,#60a5fa);fill-opacity:1;font-style:normal}
128
157
  .cnd .cg .cap{font-size:12px;font-weight:650;letter-spacing:1.2px;fill:currentColor;fill-opacity:.6}
129
158
  .cnd .cg .capl{stroke:currentColor;stroke-opacity:.14;stroke-width:1;fill:none}
130
159
  .cnd .cg .cn,.cnd .cg .ce{transition:opacity .15s}
@@ -270,7 +299,6 @@ _EXTRA_CSS = """\
270
299
  .cnd .cside .sl.lit{background:rgba(245,158,11,.16);box-shadow:inset 3px 0 #f59e0b}
271
300
  .cnd .cside .sn{display:inline-block;width:3.2em;padding-right:14px;text-align:right;opacity:.42;user-select:none;-webkit-user-select:none}
272
301
  .cnd .cside .csm{font-size:12px;font-style:italic;opacity:.6;margin:6px 2px 0}
273
- @media (min-width:1100px){.cnd .cgw:has(.cside:target){margin-right:min(660px,94vw)}}
274
302
  @media (prefers-reduced-motion:reduce){.cnd .cside,.cnd .cside:target{transition:none}}
275
303
  .cnd .tabs{position:relative;margin:0 0 8px}
276
304
  .cnd .tbar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px;padding-top:6px;margin:0 0 18px;border-bottom:1px solid rgba(127,127,127,.22);background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
@@ -358,6 +386,14 @@ _EXTRA_CSS = """\
358
386
  .cnd .walk .wsay p{margin:4px 0 0;opacity:.85}
359
387
  .cnd .walk .wsay code{font-size:12.5px}
360
388
  .cnd .walk .wdoc{font-style:italic;opacity:.7}
389
+ .cnd .cnh{margin:10px 0 0;padding:8px 0 0 10px;border-left:2px solid rgba(127,127,127,.3);font-size:12.5px;line-height:1.5}
390
+ .cnd .cnh p{margin:0 0 3px;opacity:.75;overflow-wrap:anywhere}
391
+ .cnd .cnh p:last-child{margin-bottom:0}
392
+ .cnd .cnh b{font-weight:600}
393
+ .cnd .cnh code{font:12px var(--mono)}
394
+ .cnd .cnh a{color:light-dark(#2563eb,#60a5fa);text-decoration:none}
395
+ .cnd .cnh a:hover{text-decoration:underline}
396
+ .cnd .cnh .cnhu{color:light-dark(#b45309,#fbbf24)}
361
397
  .cnd .walk .wjump{user-select:none;-webkit-user-select:none;color:var(--blue);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
362
398
  .cnd .walk .wfacts{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid rgba(127,127,127,.14);display:flex;flex-direction:column;gap:5px}
363
399
  .cnd .walk .wfacts li{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px;font-size:12.5px;overflow-wrap:anywhere}