zettcode-commands 0.4.2__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.
- zettcode/commands/__init__.py +1 -0
- zettcode/commands/context.py +95 -0
- zettcode/commands/context_charts.py +283 -0
- zettcode/commands/context_page.py +418 -0
- zettcode/commands/handlers.py +646 -0
- zettcode/commands/hub.py +1077 -0
- zettcode/commands/hub_graph.py +736 -0
- zettcode/commands/plugin.py +56 -0
- zettcode/commands/py.typed +0 -0
- zettcode/commands/usage.py +46 -0
- zettcode/commands/usage_page.py +290 -0
- zettcode_commands-0.4.2.dist-info/METADATA +23 -0
- zettcode_commands-0.4.2.dist-info/RECORD +15 -0
- zettcode_commands-0.4.2.dist-info/WHEEL +4 -0
- zettcode_commands-0.4.2.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""The picture on ``/hub``'s first tab: sessions as boxes, messages as lines, one colour per state.
|
|
2
|
+
|
|
3
|
+
Nothing here reads anything. :func:`build` turns the broker's graph into :class:`Node` and :class:`Link` values;
|
|
4
|
+
:func:`layout` ranks the nodes by how much they talk, keeps as many of the busiest as fit the rectangle without
|
|
5
|
+
touching, puts the reader's own session in the middle with the rest on a ring, and routes every line around the boxes
|
|
6
|
+
it does not belong to; :func:`paint` draws the result, and may be called again with another node selected without
|
|
7
|
+
routing anything again.
|
|
8
|
+
|
|
9
|
+
How a line is drawn:
|
|
10
|
+
|
|
11
|
+
* It leaves one box at the middle of a side and enters the other the same way, in right angles with rounded corners,
|
|
12
|
+
and joins the lines it meets (``├ ┬ ┼ ...``). It is found by A* over the character grid: a step down costs twice a
|
|
13
|
+
step across (a cell is about twice as tall as it is wide), a turn costs, and a cell another line already uses costs
|
|
14
|
+
more, so lines keep apart where they can.
|
|
15
|
+
* Its number is the messages the two exchanged, both ways, written beside the end it is drawn from: the other end
|
|
16
|
+
than the reader's own session, otherwise the busier one. The line has the colour of that end's state, so the number
|
|
17
|
+
and the colour say whose line it is.
|
|
18
|
+
* With a node selected its lines stay as they are and everything else is dimmed.
|
|
19
|
+
|
|
20
|
+
A state has one colour; the legend says which. They are the theme's own, so a palette change recolours the picture, and
|
|
21
|
+
the two lists use the same colours for the same words.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import heapq
|
|
27
|
+
import math
|
|
28
|
+
from collections.abc import Mapping, Sequence
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
from zettcode.tui import Canvas, Theme
|
|
33
|
+
from zettcode.tui.render import Style, display_width, truncate
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BOX_HEIGHT",
|
|
37
|
+
"MAX_NODES",
|
|
38
|
+
"STATES",
|
|
39
|
+
"Link",
|
|
40
|
+
"Node",
|
|
41
|
+
"Picture",
|
|
42
|
+
"State",
|
|
43
|
+
"build",
|
|
44
|
+
"colours",
|
|
45
|
+
"glyph",
|
|
46
|
+
"layout",
|
|
47
|
+
"legend",
|
|
48
|
+
"nearest",
|
|
49
|
+
"paint",
|
|
50
|
+
"state_of",
|
|
51
|
+
"summary",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
State = Literal["working", "idle", "external", "offline"]
|
|
55
|
+
|
|
56
|
+
#: The states a node can be in, in the order the legend lists them.
|
|
57
|
+
STATES: tuple[State, ...] = ("working", "idle", "external", "offline")
|
|
58
|
+
|
|
59
|
+
#: How many nodes the picture draws at most, and how many lines; more is a smear.
|
|
60
|
+
MAX_NODES = 16
|
|
61
|
+
MAX_LINKS = 40
|
|
62
|
+
|
|
63
|
+
#: A box is this many rows tall, and at most this many columns wide, label included.
|
|
64
|
+
BOX_HEIGHT = 3
|
|
65
|
+
MAX_BOX = 24
|
|
66
|
+
|
|
67
|
+
# A cell of line is the set of sides it joins: these bits, and the glyph each set is drawn as.
|
|
68
|
+
_N, _E, _S, _W = 1, 2, 4, 8
|
|
69
|
+
_DIRS = ((0, -1), (1, 0), (0, 1), (-1, 0))
|
|
70
|
+
_BITS = (_N, _E, _S, _W)
|
|
71
|
+
_JOINS = {
|
|
72
|
+
_N: "\u2502",
|
|
73
|
+
_S: "\u2502",
|
|
74
|
+
_N | _S: "\u2502",
|
|
75
|
+
_E: "\u2500",
|
|
76
|
+
_W: "\u2500",
|
|
77
|
+
_E | _W: "\u2500",
|
|
78
|
+
_N | _E: "\u2570",
|
|
79
|
+
_N | _W: "\u256f",
|
|
80
|
+
_S | _E: "\u256d",
|
|
81
|
+
_S | _W: "\u256e",
|
|
82
|
+
_N | _E | _S: "\u251c",
|
|
83
|
+
_N | _S | _W: "\u2524",
|
|
84
|
+
_E | _S | _W: "\u252c",
|
|
85
|
+
_N | _E | _W: "\u2534",
|
|
86
|
+
_N | _E | _S | _W: "\u253c",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Cell = tuple[int, int]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Node:
|
|
94
|
+
"""One session, daemon or outsider the picture shows."""
|
|
95
|
+
|
|
96
|
+
key: str
|
|
97
|
+
label: str
|
|
98
|
+
state: State
|
|
99
|
+
me: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class Link:
|
|
104
|
+
"""Two nodes that exchanged messages, and how many."""
|
|
105
|
+
|
|
106
|
+
a: str
|
|
107
|
+
b: str
|
|
108
|
+
count: int
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class Route:
|
|
113
|
+
"""One line: its link, the end it is numbered at, and the cells it runs through."""
|
|
114
|
+
|
|
115
|
+
link: Link
|
|
116
|
+
origin: str
|
|
117
|
+
cells: tuple[Cell, ...]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class Picture:
|
|
122
|
+
"""A laid-out graph: what is drawn, where, and what was left out.
|
|
123
|
+
|
|
124
|
+
Attributes:
|
|
125
|
+
nodes: The nodes drawn, busiest first and the reader's own at the head.
|
|
126
|
+
boxes: Each drawn node's box as ``(left, top, width)``.
|
|
127
|
+
routes: The lines, strongest first.
|
|
128
|
+
cells: For each cell a line runs through, which routes and which sides of the cell each joins.
|
|
129
|
+
labels: ``(x, y, text, route index)`` for each number that found a place.
|
|
130
|
+
hidden_nodes: How many nodes did not fit.
|
|
131
|
+
undrawn: How many lines join two drawn nodes and are not drawn: only the strongest :data:`MAX_LINKS` are
|
|
132
|
+
routed, and a line with no way round the boxes is not drawn either.
|
|
133
|
+
unnumbered: How many drawn lines found no place to write their number.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
nodes: tuple[Node, ...] = ()
|
|
137
|
+
boxes: Mapping[str, tuple[int, int, int]] = field(default_factory=dict)
|
|
138
|
+
routes: tuple[Route, ...] = ()
|
|
139
|
+
cells: Mapping[Cell, tuple[tuple[int, int], ...]] = field(default_factory=dict)
|
|
140
|
+
labels: tuple[tuple[int, int, str, int], ...] = ()
|
|
141
|
+
hidden_nodes: int = 0
|
|
142
|
+
undrawn: int = 0
|
|
143
|
+
unnumbered: int = 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def state_of(status: str, kind: str) -> State:
|
|
147
|
+
"""Return the state a roster entry (or a daemon's status) is drawn in."""
|
|
148
|
+
if kind == "external":
|
|
149
|
+
return "external"
|
|
150
|
+
return "working" if status == "working" else "idle"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def glyph(state: str) -> str:
|
|
154
|
+
"""Return the mark in front of a state: a diamond for an outsider, a dot for the rest."""
|
|
155
|
+
return "\u25c7" if state == "external" else "\u25cf"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def colours(theme: Theme) -> dict[str, str]:
|
|
159
|
+
"""Return the colour of each state, from the theme: working is the accent, one with no process the muted colour."""
|
|
160
|
+
return {
|
|
161
|
+
"working": theme.accent,
|
|
162
|
+
"idle": theme.subtle,
|
|
163
|
+
"external": theme.warning,
|
|
164
|
+
"offline": theme.muted,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build(
|
|
169
|
+
graph: Mapping[str, Any],
|
|
170
|
+
*,
|
|
171
|
+
me: str = "",
|
|
172
|
+
titles: Mapping[str, str] | None = None,
|
|
173
|
+
offline: Sequence[str] = (),
|
|
174
|
+
) -> tuple[tuple[Node, ...], tuple[Link, ...]]:
|
|
175
|
+
"""Return the nodes and links of the broker's graph.
|
|
176
|
+
|
|
177
|
+
A session the graph has a line to that is not in its roster is drawn ``offline``, as is every id in ``offline``:
|
|
178
|
+
nothing runs it, whether it was a daemon that was stopped or a session that was only stored.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
graph: What ``Client.traffic_graph`` returned: ``sessions`` and ``edges``.
|
|
182
|
+
me: The reader's own session id, drawn in the middle.
|
|
183
|
+
titles: Names for sessions that are stored but not registered, by id.
|
|
184
|
+
offline: Ids of daemons of this workspace whose process is gone, which get a node even with no messages.
|
|
185
|
+
"""
|
|
186
|
+
names = dict(titles or {})
|
|
187
|
+
gone = set(offline)
|
|
188
|
+
nodes: dict[str, Node] = {}
|
|
189
|
+
for entry in graph.get("sessions", ()):
|
|
190
|
+
key = str(entry.get("session_id", ""))
|
|
191
|
+
if not key:
|
|
192
|
+
continue
|
|
193
|
+
label = str(entry.get("handle") or entry.get("title") or key[:8])
|
|
194
|
+
nodes[key] = Node(key, label, state_of(str(entry.get("status", "idle")), str(entry.get("kind", ""))), key == me)
|
|
195
|
+
pairs: dict[frozenset[str], Link] = {}
|
|
196
|
+
for edge in graph.get("edges", ()):
|
|
197
|
+
a, b = str(edge.get("a", "")), str(edge.get("b", ""))
|
|
198
|
+
if not a or not b or a == b:
|
|
199
|
+
continue
|
|
200
|
+
for key in (a, b):
|
|
201
|
+
if key not in nodes:
|
|
202
|
+
nodes[key] = Node(key, names.get(key) or key[:8], "offline", key == me)
|
|
203
|
+
# A pair is one line whichever way the messages went: two entries for it are added, not drawn twice.
|
|
204
|
+
count = int(edge.get("count", 1))
|
|
205
|
+
seen = pairs.get(frozenset((a, b)))
|
|
206
|
+
pairs[frozenset((a, b))] = Link(a, b, count + (seen.count if seen else 0))
|
|
207
|
+
links = list(pairs.values())
|
|
208
|
+
for key in gone:
|
|
209
|
+
if key not in nodes and key in names:
|
|
210
|
+
nodes[key] = Node(key, names[key], "offline", key == me)
|
|
211
|
+
return tuple(nodes.values()), tuple(links)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# -- ranking and placing ----------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _text(node: Node) -> str:
|
|
218
|
+
"""Return what a box says: the label, and ``(you)`` after the reader's own."""
|
|
219
|
+
return node.label + (" (you)" if node.me else "")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _box(node: Node) -> int:
|
|
223
|
+
"""Return the width of a node's box: its glyph, its label and a column of air on both sides."""
|
|
224
|
+
return min(MAX_BOX, display_width(_text(node)) + 6)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _totals(links: Sequence[Link]) -> dict[str, int]:
|
|
228
|
+
"""Return the messages each node is part of."""
|
|
229
|
+
total: dict[str, int] = {}
|
|
230
|
+
for link in links:
|
|
231
|
+
total[link.a] = total.get(link.a, 0) + link.count
|
|
232
|
+
total[link.b] = total.get(link.b, 0) + link.count
|
|
233
|
+
return total
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def rank(nodes: Sequence[Node], links: Sequence[Link]) -> list[Node]:
|
|
237
|
+
"""Return the nodes busiest first, the reader's own at the head whatever it has said."""
|
|
238
|
+
total = _totals(links)
|
|
239
|
+
order = sorted(range(len(nodes)), key=lambda i: (not nodes[i].me, -total.get(nodes[i].key, 0), i))
|
|
240
|
+
return [nodes[i] for i in order]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _ring_order(nodes: Sequence[Node], links: Sequence[Link]) -> list[Node]:
|
|
244
|
+
"""Return the nodes around the ring so that talkers sit next to each other where they can."""
|
|
245
|
+
weight: dict[str, dict[str, int]] = {node.key: {} for node in nodes}
|
|
246
|
+
for link in links:
|
|
247
|
+
if link.a in weight and link.b in weight:
|
|
248
|
+
weight[link.a][link.b] = weight[link.a].get(link.b, 0) + link.count
|
|
249
|
+
weight[link.b][link.a] = weight[link.b].get(link.a, 0) + link.count
|
|
250
|
+
left = {node.key: node for node in nodes}
|
|
251
|
+
total = {key: sum(neighbours.values()) for key, neighbours in weight.items()}
|
|
252
|
+
out: list[Node] = []
|
|
253
|
+
current: str | None = None
|
|
254
|
+
while left:
|
|
255
|
+
pool = weight[current] if current else {}
|
|
256
|
+
key = max(left, key=lambda k: (pool.get(k, 0), total[k]))
|
|
257
|
+
out.append(left.pop(key))
|
|
258
|
+
current = key
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _spots(
|
|
263
|
+
shown: Sequence[Node], links: Sequence[Link], width: int, height: int, *, whole_rows: bool = False
|
|
264
|
+
) -> dict[str, Cell]:
|
|
265
|
+
"""Return the centre of each node's box: the reader's own in the middle, the rest on an ellipse round it.
|
|
266
|
+
|
|
267
|
+
The ellipse's vertical radius is half a row when the height is even, and rounding a ring box onto a half row can
|
|
268
|
+
push its last row onto the edge; ``whole_rows`` takes the radius down to a whole number of rows instead.
|
|
269
|
+
"""
|
|
270
|
+
cx, cy = width // 2, height // 2
|
|
271
|
+
if len(shown) == 1:
|
|
272
|
+
return {shown[0].key: (cx, cy)}
|
|
273
|
+
if len(shown) == 2:
|
|
274
|
+
first, second = shown
|
|
275
|
+
w1, w2 = _box(first), _box(second)
|
|
276
|
+
# Each box is centred in its own half by its own width, then pushed inside the edge: two wide boxes in a
|
|
277
|
+
# narrow area sit against the two edges, one cell from each, with whatever lane is left between them.
|
|
278
|
+
left1 = max(1, width // 4 - w1 // 2)
|
|
279
|
+
left2 = min(width - 1 - w2, width - width // 4 - w2 // 2)
|
|
280
|
+
return {first.key: (left1 + w1 // 2, cy), second.key: (left2 + w2 // 2, cy)}
|
|
281
|
+
centre = next((node for node in shown if node.me), None)
|
|
282
|
+
ring = _ring_order([node for node in shown if node is not centre], links)
|
|
283
|
+
widest = max(_box(node) for node in shown)
|
|
284
|
+
rx = max(1.0, (width - widest) / 2 - 1)
|
|
285
|
+
ry = max(1.0, (height - BOX_HEIGHT) / 2 - 1)
|
|
286
|
+
if whole_rows:
|
|
287
|
+
ry = float(max(1, int(ry)))
|
|
288
|
+
spots: dict[str, Cell] = {}
|
|
289
|
+
if centre is not None:
|
|
290
|
+
spots[centre.key] = (cx, cy)
|
|
291
|
+
for index, node in enumerate(ring):
|
|
292
|
+
angle = -math.pi / 2 + 2 * math.pi * index / len(ring)
|
|
293
|
+
spots[node.key] = (round(cx + rx * math.cos(angle)), round(cy + ry * math.sin(angle)))
|
|
294
|
+
return spots
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _rects(shown: Sequence[Node], spots: Mapping[str, Cell]) -> dict[str, tuple[int, int, int]]:
|
|
298
|
+
"""Return each box as ``(left, top, width)`` from its centre."""
|
|
299
|
+
return {
|
|
300
|
+
node.key: (spots[node.key][0] - _box(node) // 2, spots[node.key][1] - BOX_HEIGHT // 2, _box(node))
|
|
301
|
+
for node in shown
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _clear(boxes: Mapping[str, tuple[int, int, int]], width: int, height: int) -> bool:
|
|
306
|
+
"""Return whether every box is inside the rectangle with a cell to spare and a free lane to the next one."""
|
|
307
|
+
rects = list(boxes.values())
|
|
308
|
+
for left, top, w in rects:
|
|
309
|
+
if left < 1 or top < 1 or left + w > width - 1 or top + BOX_HEIGHT > height - 1:
|
|
310
|
+
return False
|
|
311
|
+
for index, (al, at, aw) in enumerate(rects):
|
|
312
|
+
for bl, bt, bw in rects[index + 1 :]:
|
|
313
|
+
if al - 1 < bl + bw and bl < al + aw + 1 and at - 1 < bt + BOX_HEIGHT and bt < at + BOX_HEIGHT + 1:
|
|
314
|
+
return False
|
|
315
|
+
return True
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# -- routing ----------------------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _ports(box: tuple[int, int, int], toward: Cell) -> list[tuple[Cell, int, int]]:
|
|
322
|
+
"""Return the cells a line may start from or end in: the cell, the side that points into the box, and a penalty.
|
|
323
|
+
|
|
324
|
+
A side that faces away from ``toward`` costs extra, so a line leaves by the side nearest the box it goes to and
|
|
325
|
+
only goes round when that side is taken.
|
|
326
|
+
"""
|
|
327
|
+
left, top, w = box
|
|
328
|
+
middle = top + BOX_HEIGHT // 2
|
|
329
|
+
centre = left + w // 2
|
|
330
|
+
dx, dy = toward[0] - centre, toward[1] - middle
|
|
331
|
+
# A cell is about twice as tall as wide, so a vertical offset counts double.
|
|
332
|
+
horizontal = abs(dx) >= 2 * abs(dy)
|
|
333
|
+
return [
|
|
334
|
+
((left - 1, middle), _E, 0 if dx < 0 and horizontal else 6 if dx < 0 else 14),
|
|
335
|
+
((left + w, middle), _W, 0 if dx > 0 and horizontal else 6 if dx > 0 else 14),
|
|
336
|
+
((centre, top - 1), _S, 0 if dy < 0 and not horizontal else 6 if dy < 0 else 14),
|
|
337
|
+
((centre, top + BOX_HEIGHT), _N, 0 if dy > 0 and not horizontal else 6 if dy > 0 else 14),
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _search(
|
|
342
|
+
starts: list[tuple[Cell, int, int]],
|
|
343
|
+
ends: list[tuple[Cell, int, int]],
|
|
344
|
+
blocked: set[Cell],
|
|
345
|
+
used: Mapping[Cell, int],
|
|
346
|
+
halo: Mapping[Cell, frozenset[str]],
|
|
347
|
+
allowed: frozenset[str],
|
|
348
|
+
width: int,
|
|
349
|
+
height: int,
|
|
350
|
+
) -> tuple[list[Cell], int, int] | None:
|
|
351
|
+
"""Return the cheapest path between two boxes as its cells and the two sides that point into the boxes.
|
|
352
|
+
|
|
353
|
+
A step across costs 1 and a step down 2, a turn 4, a cell a parallel line already runs through 6 and a crossing
|
|
354
|
+
2, and a cell beside a box that is not one of the two 2, which keeps a line off the borders it passes.
|
|
355
|
+
"""
|
|
356
|
+
goals = {cell: (bit, penalty) for cell, bit, penalty in ends}
|
|
357
|
+
|
|
358
|
+
def heuristic(x: int, y: int) -> int:
|
|
359
|
+
return min(abs(x - gx) + 2 * abs(y - gy) for gx, gy in goals)
|
|
360
|
+
|
|
361
|
+
heap: list[tuple[int, int, int, int, int, int]] = []
|
|
362
|
+
best: dict[tuple[int, int, int], int] = {}
|
|
363
|
+
parent: dict[tuple[int, int, int], tuple[int, int, int] | None] = {}
|
|
364
|
+
first: dict[tuple[int, int, int], int] = {}
|
|
365
|
+
tick = 0
|
|
366
|
+
for (x, y), bit, penalty in starts:
|
|
367
|
+
if not (0 <= x < width and 0 <= y < height) or (x, y) in blocked:
|
|
368
|
+
continue
|
|
369
|
+
outward = (_BITS.index(bit) + 2) % 4
|
|
370
|
+
state = (x, y, outward)
|
|
371
|
+
best[state] = penalty
|
|
372
|
+
parent[state] = None
|
|
373
|
+
first[state] = bit
|
|
374
|
+
heapq.heappush(heap, (penalty + heuristic(x, y), penalty, tick, x, y, outward))
|
|
375
|
+
tick += 1
|
|
376
|
+
expanded = 0
|
|
377
|
+
while heap:
|
|
378
|
+
_, cost, _, x, y, d = heapq.heappop(heap)
|
|
379
|
+
if cost > best.get((x, y, d), cost):
|
|
380
|
+
continue
|
|
381
|
+
if (x, y) in goals:
|
|
382
|
+
cells: list[Cell] = []
|
|
383
|
+
walk: tuple[int, int, int] | None = (x, y, d)
|
|
384
|
+
start_bit = 0
|
|
385
|
+
while walk is not None:
|
|
386
|
+
cells.append((walk[0], walk[1]))
|
|
387
|
+
start_bit = first.get(walk, start_bit)
|
|
388
|
+
walk = parent[walk]
|
|
389
|
+
cells.reverse()
|
|
390
|
+
return cells, start_bit, goals[(x, y)][0]
|
|
391
|
+
expanded += 1
|
|
392
|
+
if expanded > 30000:
|
|
393
|
+
return None
|
|
394
|
+
for nd, (dx, dy) in enumerate(_DIRS):
|
|
395
|
+
nx, ny = x + dx, y + dy
|
|
396
|
+
if not (0 <= nx < width and 0 <= ny < height) or (nx, ny) in blocked:
|
|
397
|
+
continue
|
|
398
|
+
axis = 1 if nd in (1, 3) else 2
|
|
399
|
+
step = 1 if axis == 1 else 2
|
|
400
|
+
step += 0 if nd == d else 4
|
|
401
|
+
seen = used.get((nx, ny), 0)
|
|
402
|
+
if seen & axis:
|
|
403
|
+
step += 6
|
|
404
|
+
elif seen:
|
|
405
|
+
step += 2
|
|
406
|
+
if halo.get((nx, ny), frozenset()) - allowed:
|
|
407
|
+
step += 2
|
|
408
|
+
if (nx, ny) in goals:
|
|
409
|
+
step += goals[(nx, ny)][1]
|
|
410
|
+
total = cost + step
|
|
411
|
+
state = (nx, ny, nd)
|
|
412
|
+
if total < best.get(state, 1 << 30):
|
|
413
|
+
best[state] = total
|
|
414
|
+
parent[state] = (x, y, d)
|
|
415
|
+
heapq.heappush(heap, (total + heuristic(nx, ny), total, tick, nx, ny, nd))
|
|
416
|
+
tick += 1
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _centre(box: tuple[int, int, int]) -> Cell:
|
|
421
|
+
"""Return the middle cell of a box."""
|
|
422
|
+
return box[0] + box[2] // 2, box[1] + BOX_HEIGHT // 2
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _sides(cells: Sequence[Cell], start_bit: int, end_bit: int) -> list[int]:
|
|
426
|
+
"""Return which sides of each cell the path joins, the first and last also reaching into their boxes."""
|
|
427
|
+
out = []
|
|
428
|
+
for index, here in enumerate(cells):
|
|
429
|
+
bits = 0
|
|
430
|
+
for other in (cells[index - 1] if index else None, cells[index + 1] if index + 1 < len(cells) else None):
|
|
431
|
+
if other is None:
|
|
432
|
+
continue
|
|
433
|
+
dx, dy = other[0] - here[0], other[1] - here[1]
|
|
434
|
+
bits |= _E if dx > 0 else _W if dx < 0 else _S if dy > 0 else _N
|
|
435
|
+
if index == 0:
|
|
436
|
+
bits |= start_bit
|
|
437
|
+
if index == len(cells) - 1:
|
|
438
|
+
bits |= end_bit
|
|
439
|
+
out.append(bits)
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _origin(link: Link, nodes: Mapping[str, Node], total: Mapping[str, int]) -> str:
|
|
444
|
+
"""Return the end a line is numbered at: not the reader's own session, else the busier one."""
|
|
445
|
+
a, b = nodes[link.a], nodes[link.b]
|
|
446
|
+
if a.me != b.me:
|
|
447
|
+
return b.key if a.me else a.key
|
|
448
|
+
return a.key if total.get(a.key, 0) >= total.get(b.key, 0) else b.key
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def layout(nodes: Sequence[Node], links: Sequence[Link], width: int, height: int) -> Picture:
|
|
452
|
+
"""Lay the graph out in a ``width`` by ``height`` rectangle.
|
|
453
|
+
|
|
454
|
+
The busiest nodes are kept, as many as fit on one ellipse with a lane left between every two boxes (a count that
|
|
455
|
+
does not fit by a row is dropped, and the next smaller is tried); the rest are counted in ``hidden_nodes``.
|
|
456
|
+
Only the strongest :data:`MAX_LINKS` lines are routed, strongest first so the busy ones get the straightest paths;
|
|
457
|
+
those left out between two drawn nodes are counted in ``undrawn``.
|
|
458
|
+
"""
|
|
459
|
+
if not nodes or width <= 0 or height <= 0:
|
|
460
|
+
return Picture(hidden_nodes=len(nodes))
|
|
461
|
+
ranked = rank(nodes, links)
|
|
462
|
+
shown: list[Node] = []
|
|
463
|
+
spots: dict[str, Cell] = {}
|
|
464
|
+
boxes: dict[str, tuple[int, int, int]] = {}
|
|
465
|
+
for count in range(min(MAX_NODES, len(ranked)), 0, -1):
|
|
466
|
+
shown = ranked[:count]
|
|
467
|
+
for whole_rows in (False, True):
|
|
468
|
+
spots = _spots(shown, links, width, height, whole_rows=whole_rows)
|
|
469
|
+
boxes = _rects(shown, spots)
|
|
470
|
+
if _clear(boxes, width, height):
|
|
471
|
+
break
|
|
472
|
+
else:
|
|
473
|
+
continue
|
|
474
|
+
break
|
|
475
|
+
else:
|
|
476
|
+
return Picture(hidden_nodes=len(nodes))
|
|
477
|
+
|
|
478
|
+
by_key = {node.key: node for node in shown}
|
|
479
|
+
total = _totals(links)
|
|
480
|
+
inside = [link for link in links if link.a in by_key and link.b in by_key]
|
|
481
|
+
inside.sort(key=lambda link: -link.count)
|
|
482
|
+
owner = {
|
|
483
|
+
(x, y): key
|
|
484
|
+
for key, (left, top, w) in boxes.items()
|
|
485
|
+
for x in range(left, left + w)
|
|
486
|
+
for y in range(top, top + BOX_HEIGHT)
|
|
487
|
+
}
|
|
488
|
+
blocked = set(owner)
|
|
489
|
+
halo: dict[Cell, frozenset[str]] = {}
|
|
490
|
+
for key, (left, top, w) in boxes.items():
|
|
491
|
+
for x in range(left - 1, left + w + 1):
|
|
492
|
+
for y in range(top - 1, top + BOX_HEIGHT + 1):
|
|
493
|
+
if (x, y) not in blocked:
|
|
494
|
+
halo[(x, y)] = halo.get((x, y), frozenset()) | {key}
|
|
495
|
+
|
|
496
|
+
used: dict[Cell, int] = {}
|
|
497
|
+
routes: list[Route] = []
|
|
498
|
+
cells: dict[Cell, list[tuple[int, int]]] = {}
|
|
499
|
+
for link in inside[:MAX_LINKS]:
|
|
500
|
+
origin = _origin(link, by_key, total)
|
|
501
|
+
other = link.b if origin == link.a else link.a
|
|
502
|
+
found = _search(
|
|
503
|
+
_ports(boxes[origin], _centre(boxes[other])),
|
|
504
|
+
_ports(boxes[other], _centre(boxes[origin])),
|
|
505
|
+
blocked,
|
|
506
|
+
used,
|
|
507
|
+
halo,
|
|
508
|
+
frozenset((link.a, link.b)),
|
|
509
|
+
width,
|
|
510
|
+
height,
|
|
511
|
+
)
|
|
512
|
+
if found is None:
|
|
513
|
+
continue
|
|
514
|
+
path, start_bit, end_bit = found
|
|
515
|
+
index = len(routes)
|
|
516
|
+
routes.append(Route(link, origin, tuple(path)))
|
|
517
|
+
for cell, sides in zip(path, _sides(path, start_bit, end_bit), strict=True):
|
|
518
|
+
cells.setdefault(cell, []).append((index, sides))
|
|
519
|
+
used[cell] = used.get(cell, 0) | (1 if sides & (_E | _W) else 0) | (2 if sides & (_N | _S) else 0)
|
|
520
|
+
labels = _place_labels(routes, cells, owner, width, height)
|
|
521
|
+
return Picture(
|
|
522
|
+
nodes=tuple(shown),
|
|
523
|
+
boxes=boxes,
|
|
524
|
+
routes=tuple(routes),
|
|
525
|
+
cells={cell: tuple(parts) for cell, parts in cells.items()},
|
|
526
|
+
labels=tuple(labels),
|
|
527
|
+
hidden_nodes=len(nodes) - len(shown),
|
|
528
|
+
undrawn=len(inside) - len(routes),
|
|
529
|
+
unnumbered=len(routes) - len(labels),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _place_labels(
|
|
534
|
+
routes: Sequence[Route],
|
|
535
|
+
cells: Mapping[Cell, Sequence[tuple[int, int]]],
|
|
536
|
+
owner: Mapping[Cell, str],
|
|
537
|
+
width: int,
|
|
538
|
+
height: int,
|
|
539
|
+
) -> list[tuple[int, int, str, int]]:
|
|
540
|
+
"""Put each line's number on its own line close to the end it is drawn from, or beside it, where nothing else is.
|
|
541
|
+
|
|
542
|
+
On the line it replaces a straight stretch only (never a corner, never the cell that joins the box at the far
|
|
543
|
+
end). Beside it, any side of any of the first cells will do, including the first, so a line of one cell still gets
|
|
544
|
+
its number. A number is never written over a box, over a cell any line runs through or over another number, and
|
|
545
|
+
it never touches a box other than the two its line joins, where it would read as that box's own label.
|
|
546
|
+
"""
|
|
547
|
+
taken: set[Cell] = set()
|
|
548
|
+
out: list[tuple[int, int, str, int]] = []
|
|
549
|
+
|
|
550
|
+
def free(spot: set[Cell], ends: tuple[str, str]) -> bool:
|
|
551
|
+
for x, y in spot:
|
|
552
|
+
if not (0 <= x < width and 0 <= y < height) or (x, y) in owner or (x, y) in cells or (x, y) in taken:
|
|
553
|
+
return False
|
|
554
|
+
if any(owner.get(near, ends[0]) not in ends for near in ((x - 1, y), (x, y - 1), (x + 1, y), (x, y + 1))):
|
|
555
|
+
return False
|
|
556
|
+
return True
|
|
557
|
+
|
|
558
|
+
for index, route in enumerate(routes):
|
|
559
|
+
text = f" {route.link.count} "
|
|
560
|
+
path = route.cells
|
|
561
|
+
placed = False
|
|
562
|
+
for start in range(1, len(path) - len(text)):
|
|
563
|
+
run = path[start : start + len(text)]
|
|
564
|
+
straight = all(cell[1] == run[0][1] for cell in run) and all(
|
|
565
|
+
abs(run[i + 1][0] - run[i][0]) == 1 for i in range(len(run) - 1)
|
|
566
|
+
)
|
|
567
|
+
if not straight or any(cell in taken or dict(cells[cell])[index] != _E | _W for cell in run):
|
|
568
|
+
continue
|
|
569
|
+
if any(len(cells[cell]) != 1 for cell in run):
|
|
570
|
+
continue
|
|
571
|
+
out.append((min(cell[0] for cell in run), run[0][1], text, index))
|
|
572
|
+
taken.update(run)
|
|
573
|
+
placed = True
|
|
574
|
+
break
|
|
575
|
+
if placed:
|
|
576
|
+
continue
|
|
577
|
+
number = str(route.link.count)
|
|
578
|
+
for cell_x, cell_y in path[:12]:
|
|
579
|
+
for left, top in (
|
|
580
|
+
(cell_x + 1, cell_y),
|
|
581
|
+
(cell_x - len(number), cell_y),
|
|
582
|
+
(cell_x, cell_y - 1),
|
|
583
|
+
(cell_x, cell_y + 1),
|
|
584
|
+
):
|
|
585
|
+
spot = {(left + i, top) for i in range(len(number))}
|
|
586
|
+
if free(spot, (route.link.a, route.link.b)):
|
|
587
|
+
out.append((left, top, number, index))
|
|
588
|
+
taken |= spot
|
|
589
|
+
placed = True
|
|
590
|
+
break
|
|
591
|
+
if placed:
|
|
592
|
+
break
|
|
593
|
+
return out
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# -- painting ---------------------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def paint(canvas: Canvas, theme: Theme, x: int, y: int, picture: Picture, selected: str | None = None) -> None:
|
|
600
|
+
"""Paint a laid-out graph with its top left corner at ``(x, y)``; ``selected`` keeps its lines and dims the rest."""
|
|
601
|
+
if not picture.nodes:
|
|
602
|
+
return
|
|
603
|
+
states = colours(theme)
|
|
604
|
+
by_key = {node.key: node for node in picture.nodes}
|
|
605
|
+
page = theme.surface_alt
|
|
606
|
+
|
|
607
|
+
def lit(route: Route) -> bool:
|
|
608
|
+
return selected is None or selected in (route.link.a, route.link.b)
|
|
609
|
+
|
|
610
|
+
def colour_of(route: Route) -> str:
|
|
611
|
+
return states[by_key[route.origin].state] if lit(route) else theme.border
|
|
612
|
+
|
|
613
|
+
for (cell_x, cell_y), parts in picture.cells.items():
|
|
614
|
+
# The glyph keeps every line through the cell, so a dimmed line is not cut where a bright one crosses it; the
|
|
615
|
+
# colour is a bright line's when there is one.
|
|
616
|
+
bits = 0
|
|
617
|
+
for _, sides in parts:
|
|
618
|
+
bits |= sides
|
|
619
|
+
owner_route = next((index for index, _ in parts if lit(picture.routes[index])), parts[0][0])
|
|
620
|
+
if bits in _JOINS:
|
|
621
|
+
style = Style(foreground=colour_of(picture.routes[owner_route]), background=page)
|
|
622
|
+
canvas.set_cell(x + cell_x, y + cell_y, _JOINS[bits], style)
|
|
623
|
+
for left, top, text, index in picture.labels:
|
|
624
|
+
route = picture.routes[index]
|
|
625
|
+
canvas.draw_text(x + left, y + top, text, Style(foreground=colour_of(route), background=page, bold=lit(route)))
|
|
626
|
+
|
|
627
|
+
near = {selected} if selected is not None else set()
|
|
628
|
+
for route in picture.routes:
|
|
629
|
+
if selected in (route.link.a, route.link.b):
|
|
630
|
+
near.update((route.link.a, route.link.b))
|
|
631
|
+
for node in picture.nodes:
|
|
632
|
+
left, top, w = picture.boxes[node.key]
|
|
633
|
+
colour = states[node.state]
|
|
634
|
+
chosen = node.key == selected
|
|
635
|
+
dim = selected is not None and node.key not in near
|
|
636
|
+
fill = theme.selection if chosen else theme.surface
|
|
637
|
+
edge = Style(
|
|
638
|
+
foreground=theme.border if dim else colour,
|
|
639
|
+
background=fill,
|
|
640
|
+
bold=chosen or node.me or node.state == "working",
|
|
641
|
+
)
|
|
642
|
+
body = Style(foreground=theme.muted if dim else theme.text, background=fill, bold=node.me or chosen)
|
|
643
|
+
canvas.fill(x + left, y + top, w, BOX_HEIGHT, Style(background=fill))
|
|
644
|
+
if node.me or chosen:
|
|
645
|
+
tl, tr, bl, br, bar, side = "\u2554", "\u2557", "\u255a", "\u255d", "\u2550", "\u2551"
|
|
646
|
+
else:
|
|
647
|
+
tl, tr, bl, br, bar, side = "\u256d", "\u256e", "\u2570", "\u256f", "\u2500", "\u2502"
|
|
648
|
+
canvas.draw_text(x + left, y + top, tl + bar * (w - 2) + tr, edge)
|
|
649
|
+
canvas.draw_text(x + left, y + top + 2, bl + bar * (w - 2) + br, edge)
|
|
650
|
+
canvas.draw_text(x + left, y + top + 1, side, edge)
|
|
651
|
+
canvas.draw_text(x + left + w - 1, y + top + 1, side, edge)
|
|
652
|
+
canvas.draw_text(
|
|
653
|
+
x + left + 2, y + top + 1, glyph(node.state), Style(foreground=colour, background=fill, bold=True)
|
|
654
|
+
)
|
|
655
|
+
canvas.draw_text(x + left + 4, y + top + 1, truncate(_text(node), w - 6), body, max_width=w - 6)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
#: The step an arrow key takes on the picture, as (columns, rows).
|
|
659
|
+
_ARROWS = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def nearest(picture: Picture, selected: str | None, direction: str) -> str | None:
|
|
663
|
+
"""Return the node to select when an arrow key is pressed, or ``None`` when there is none that way.
|
|
664
|
+
|
|
665
|
+
Every node has a place, so an arrow goes to the node nearest in that direction: of those ahead of the selected
|
|
666
|
+
one it takes the one with the least distance along the arrow plus twice the distance across it, preferring
|
|
667
|
+
those inside a cone of about 60 degrees either side, and falling back to any that lies ahead. A row is about
|
|
668
|
+
twice as tall as a column is wide, so rows count double. With nothing selected the first arrow selects the
|
|
669
|
+
reader's own session, which comes first.
|
|
670
|
+
"""
|
|
671
|
+
if not picture.nodes:
|
|
672
|
+
return None
|
|
673
|
+
if selected is None or selected not in picture.boxes:
|
|
674
|
+
return picture.nodes[0].key
|
|
675
|
+
step_x, step_y = _ARROWS[direction]
|
|
676
|
+
# Everything below is in columns: a row is about twice as tall as a column is wide, so a row is two units. That
|
|
677
|
+
# one unit is used for the cone and for the score, which is what makes up and down behave like left and right.
|
|
678
|
+
left, top, width = picture.boxes[selected]
|
|
679
|
+
here_x, here_y = left + width / 2, (top + BOX_HEIGHT / 2) * 2
|
|
680
|
+
best: dict[bool, tuple[float, str]] = {}
|
|
681
|
+
for node in picture.nodes:
|
|
682
|
+
if node.key == selected:
|
|
683
|
+
continue
|
|
684
|
+
left, top, width = picture.boxes[node.key]
|
|
685
|
+
dx, dy = left + width / 2 - here_x, (top + BOX_HEIGHT / 2) * 2 - here_y
|
|
686
|
+
ahead = dx * step_x + dy * step_y
|
|
687
|
+
across = abs(dx * step_y) + abs(dy * step_x)
|
|
688
|
+
if ahead <= 0:
|
|
689
|
+
continue
|
|
690
|
+
inside = across <= 1.7 * ahead
|
|
691
|
+
score = (ahead + 2 * across, node.key)
|
|
692
|
+
if inside not in best or score < best[inside]:
|
|
693
|
+
best[inside] = score
|
|
694
|
+
chosen = best.get(True) or best.get(False)
|
|
695
|
+
return chosen[1] if chosen else None
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def summary(nodes: Sequence[Node], links: Sequence[Link], picture: Picture, selected: str | None = None) -> str:
|
|
699
|
+
"""Return the line over the picture: what the whole graph holds, or what the selected node is part of."""
|
|
700
|
+
chosen = next((node for node in nodes if node.key == selected), None)
|
|
701
|
+
if chosen is not None:
|
|
702
|
+
mine = [link for link in links if selected in (link.a, link.b)]
|
|
703
|
+
drawn = sum(1 for route in picture.routes if selected in (route.link.a, route.link.b))
|
|
704
|
+
shown = f"{drawn}" if drawn == len(mine) else f"{drawn} of {len(mine)}"
|
|
705
|
+
return (
|
|
706
|
+
f"{glyph(chosen.state)} {_text(chosen)} \u00b7 {chosen.state} \u00b7 "
|
|
707
|
+
f"{shown} {'line' if len(mine) == 1 else 'lines'} \u00b7 {sum(link.count for link in mine)} messages"
|
|
708
|
+
)
|
|
709
|
+
working = sum(1 for node in nodes if node.state == "working")
|
|
710
|
+
# What was left out comes before the count of working ones, so a narrow page that cuts the line cuts that.
|
|
711
|
+
text = f"{len(nodes)} nodes \u00b7 {sum(link.count for link in links)} messages"
|
|
712
|
+
if picture.hidden_nodes:
|
|
713
|
+
text += f" \u00b7 {picture.hidden_nodes} quieter not drawn"
|
|
714
|
+
if picture.undrawn:
|
|
715
|
+
text += f" \u00b7 {picture.undrawn} lines not drawn"
|
|
716
|
+
if picture.unnumbered:
|
|
717
|
+
text += f" \u00b7 {picture.unnumbered} without a number"
|
|
718
|
+
return f"{text} \u00b7 {working} working"
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def legend(theme: Theme) -> list[list[tuple[str, Style]]]:
|
|
722
|
+
"""Return the legend as groups of styled text, what a number means first.
|
|
723
|
+
|
|
724
|
+
A group is drawn whole or not at all, so a narrow page drops the last state rather than half of it.
|
|
725
|
+
"""
|
|
726
|
+
states = colours(theme)
|
|
727
|
+
muted = Style(foreground=theme.muted, background=theme.surface_alt)
|
|
728
|
+
groups = [[("n = messages ", muted)]]
|
|
729
|
+
for state in STATES:
|
|
730
|
+
groups.append(
|
|
731
|
+
[
|
|
732
|
+
(glyph(state) + " ", Style(foreground=states[state], background=theme.surface_alt, bold=True)),
|
|
733
|
+
(state + " ", muted),
|
|
734
|
+
]
|
|
735
|
+
)
|
|
736
|
+
return groups
|