dtree-explorer 1.0.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.
@@ -0,0 +1,1963 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>DTree Explorer</title>
7
+ <meta name="description" content="Encode a directed tree with DTree's two projections Q1 and Q2, step by step, in your browser.">
8
+ <style>
9
+ /* Layout: a peach band as on digraphs.blog, then input on the left and the pictures on the right;
10
+ one column on phones. One light look on purpose, matching the site. */
11
+ :root {
12
+ --peach: #f8c99c;
13
+ --peach-deep: #e7a668;
14
+ --paper: #ffffff;
15
+ --panel: #fbf7f2;
16
+ --ink: #26211c;
17
+ --muted: #6e645a;
18
+ --line: #e7ddd2;
19
+ --accent: #b4521a;
20
+ --accent-soft: #fbe1cf;
21
+ --anc: #2d68ad;
22
+ --anc-soft: #e1ebf7;
23
+ --desc: #2b8152;
24
+ --desc-soft: #dff1e6;
25
+ --edge: #bfb2a4;
26
+ --font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
27
+ --mono: Consolas, "SFMono-Regular", Menlo, "Liberation Mono", monospace;
28
+ color-scheme: light;
29
+ }
30
+ * { box-sizing: border-box; }
31
+ html, body { margin: 0; }
32
+ body { background: var(--paper); color: var(--ink); font: 15px/1.5 var(--font); }
33
+ a { color: var(--accent); }
34
+ .wrap { max-width: 1180px; margin: 0 auto; padding-left: max(16px, env(safe-area-inset-left, 0px)); padding-right: max(16px, env(safe-area-inset-right, 0px)); }
35
+ header.band { background: var(--peach); }
36
+ header.band .wrap { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 24px; padding-block: 18px 16px; }
37
+ header.band h1 { margin: 0; color: #fff; font-weight: 600; font-size: clamp(26px, 4vw, 36px); letter-spacing: 0.01em; text-shadow: 0 1px 0 rgba(120, 60, 10, 0.25); }
38
+ header.band p { margin: 2px 0 0; color: #fff; font-size: 17px; text-shadow: 0 1px 0 rgba(120, 60, 10, 0.2); }
39
+ header.band nav a { color: var(--ink); font-weight: 600; text-decoration: none; }
40
+ header.band nav a:hover, header.band nav a:focus-visible { text-decoration: underline; }
41
+ main.wrap { display: grid; grid-template-columns: minmax(0, 330px) minmax(0, 1fr); gap: 24px; padding-block: 22px 32px; }
42
+ @media (max-width: 860px) { main.wrap { grid-template-columns: minmax(0, 1fr); } }
43
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 14px; }
44
+ aside { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
45
+ section.work { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
46
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin: 0 0 8px; font-weight: 600; }
47
+ label.field { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
48
+ select, textarea, input[type="file"] { width: 100%; font: inherit; }
49
+ select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 4px; background: var(--paper); color: var(--ink); }
50
+ textarea { font-family: var(--mono); font-size: 13px; line-height: 1.45; padding: 8px; border: 1px solid var(--line); border-radius: 4px; resize: vertical; min-height: 220px; background: var(--paper); color: var(--ink); }
51
+ .row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
52
+ button { font: inherit; border-radius: 4px; border: 1px solid var(--line); background: var(--paper); color: var(--ink); padding: 6px 12px; cursor: pointer; }
53
+ button:hover:not(:disabled) { border-color: var(--peach-deep); }
54
+ button:disabled { opacity: 0.55; cursor: default; }
55
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
56
+ button:focus-visible, select:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--anc); outline-offset: 2px; }
57
+ .syntax { font-size: 13px; color: var(--muted); margin: 8px 0 0; }
58
+ .syntax code { font-family: var(--mono); color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 3px; padding: 0 4px; }
59
+ .errors { margin: 8px 0 0; padding-left: 18px; color: #9b2c1c; font-size: 13px; }
60
+ .status { font-size: 13px; color: var(--muted); display: flex; gap: 8px; align-items: flex-start; }
61
+ .status .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--peach-deep); margin-top: 5px; flex: none; }
62
+ .status.ready .dot { background: var(--desc); }
63
+ .status.error .dot { background: #b3261e; }
64
+ .toolbar { display: flex; flex-wrap: wrap; gap: 10px 18px; align-items: center; }
65
+ .stepper { display: flex; align-items: center; gap: 8px; flex: 1 1 320px; min-width: 0; }
66
+ .stepper input[type="range"] { flex: 1; min-width: 80px; accent-color: var(--accent); }
67
+ .steplabel { font-size: 14px; min-height: 1.5em; }
68
+ .steplabel b { font-family: var(--mono); }
69
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
70
+ .chip { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); cursor: pointer; user-select: none; }
71
+ .chip input { accent-color: var(--accent); margin: 0; }
72
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
73
+ .seg button { border: 0; border-radius: 0; font-size: 13px; padding: 5px 10px; }
74
+ .seg button[aria-pressed="true"] { background: var(--ink); color: #fff; }
75
+ .query { font-size: 14px; }
76
+ .query .answer { font-weight: 600; }
77
+ .query .yes { color: var(--desc); }
78
+ .query .no { color: #9b2c1c; }
79
+ .legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); margin-top: 6px; }
80
+ .legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; border: 1.5px solid var(--sw); background: var(--sf); }
81
+ .panel-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 12px; margin-bottom: 8px; }
82
+ .panel-head h2 { margin: 0; }
83
+ .panel-head .note { font-size: 12px; color: var(--muted); }
84
+ .scroll { overflow: auto; max-height: 72vh; border: 1px solid var(--line); border-radius: 4px; background: var(--paper); }
85
+ .scroll svg { display: block; }
86
+ .lists { overflow-x: auto; }
87
+ .qrow { display: flex; align-items: stretch; gap: 4px; margin: 4px 0 10px; width: max-content; }
88
+ .qrow .qname { font-family: var(--mono); font-weight: 600; width: 34px; flex: none; align-self: center; }
89
+ .cell { font-family: var(--mono); font-size: 12px; text-align: center; border: 1px solid var(--line); border-radius: 4px; padding: 3px 6px; background: var(--paper); cursor: pointer; min-width: 38px; }
90
+ .cell small { display: block; color: var(--muted); font-size: 10px; }
91
+ .cell.new { background: var(--accent-soft); border-color: var(--accent); }
92
+ .cell.anc { background: var(--anc-soft); border-color: var(--anc); }
93
+ .cell.desc { background: var(--desc-soft); border-color: var(--desc); }
94
+ .cell.sel1 { background: var(--accent); border-color: var(--accent); color: #fff; }
95
+ .cell.sel1 small, .cell.sel2 small { color: #fff; }
96
+ .cell.sel2 { background: var(--ink); border-color: var(--ink); color: #fff; }
97
+ .cell.hid { opacity: 0.4; }
98
+ .stats { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
99
+ footer.band { background: var(--peach); margin-top: 8px; }
100
+ footer.band .wrap { padding-block: 16px 22px; font-size: 13px; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 24px; }
101
+ footer.band p { margin: 2px 0; }
102
+ footer.band a { color: var(--ink); }
103
+ #consent { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; background: var(--paper); border-top: 2px solid var(--peach-deep); box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.08); padding-bottom: env(safe-area-inset-bottom, 0px); }
104
+ #consent .wrap { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 18px; padding-block: 12px; font-size: 14px; }
105
+ #consent .row button.agree { background: #2e9b4f; border-color: #2e9b4f; color: #fff; font-weight: 600; }
106
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
107
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
108
+ </style>
109
+ </head>
110
+ <body>
111
+ <header class="band">
112
+ <div class="wrap">
113
+ <div>
114
+ <h1>DTree Explorer</h1>
115
+ <p>Topology and Structure of Directed Trees, encoded in your browser</p>
116
+ </div>
117
+ <nav><a href="https://digraphs.blog/">&larr; digraphs.blog</a></nav>
118
+ </div>
119
+ </header>
120
+
121
+ <main class="wrap">
122
+ <aside>
123
+ <div class="card">
124
+ <h2>Tree description</h2>
125
+ <label class="field" for="example">Example</label>
126
+ <select id="example"></select>
127
+ <p class="syntax">Examples marked &ldquo;Paper&rdquo; are taken from E. Olgac, <a href="https://doi.org/10.5772/intechopen.1013408">&ldquo;Topology and Structure of Directed Trees: An Incremental Encoding,&rdquo; IntechOpen, 2025</a>.</p>
128
+ <label class="field" for="file">Or open your own file (.csx or .txt)</label>
129
+ <input id="file" type="file" accept=".csx,.txt,text/plain">
130
+ <label class="field" for="text">Edges, one line each</label>
131
+ <textarea id="text" spellcheck="false"></textarea>
132
+ <div class="row" style="margin-top:10px">
133
+ <button id="encode" class="primary" type="button" disabled>Encode</button>
134
+ <span class="stats" id="stats"></span>
135
+ </div>
136
+ <p class="syntax"><code>p: c1; c2</code> last children of p &middot; <code>s &gt; c</code> c directly after sibling s &middot; <code>s &lt; c</code> c directly before s &middot; lines starting with <code>#</code> are comments</p>
137
+ <ul class="errors" id="errors" hidden></ul>
138
+ </div>
139
+ <div class="card">
140
+ <div class="status" id="status"><span class="dot"></span><span id="statustext">Starting&hellip;</span></div>
141
+ </div>
142
+ <div class="card">
143
+ <h2>How to read it</h2>
144
+ <p style="margin:0 0 8px">Every node gets a position in two lists. <b>Q1</b> lists the tree top-down with siblings in their order, <b>Q2</b> with siblings reversed. A node <i>u</i> reaches <i>v</i> exactly when <i>u</i> comes before <i>v</i> in both lists.</p>
145
+ <p style="margin:0 0 8px">A new node needs two list insertions; the other positions stay as they are. Step through the build with the slider and watch it happen, edge by edge.</p>
146
+ <p style="margin:0;font-size:13px;color:var(--muted)">The method is described in E. Olgac, <a href="https://doi.org/10.5772/intechopen.1013408">&ldquo;Topology and Structure of Directed Trees: An Incremental Encoding&rdquo;</a>.</p>
147
+ </div>
148
+ </aside>
149
+
150
+ <section class="work">
151
+ <div class="card">
152
+ <div class="toolbar">
153
+ <div class="stepper">
154
+ <button id="prev" type="button" aria-label="Previous step">&#9664;</button>
155
+ <input id="step" type="range" min="0" max="0" value="0" aria-label="Step">
156
+ <button id="next" type="button" aria-label="Next step">&#9654;</button>
157
+ <button id="play" type="button">Play</button>
158
+ </div>
159
+ <div class="chips" role="group" aria-label="Views">
160
+ <label class="chip"><input type="checkbox" id="v-tree" checked> Tree</label>
161
+ <label class="chip"><input type="checkbox" id="v-plane" checked> Plane</label>
162
+ <label class="chip"><input type="checkbox" id="v-lists" checked> Q1 / Q2</label>
163
+ </div>
164
+ <div class="seg" role="group" aria-label="Clicking a node">
165
+ <button type="button" id="m-pick" aria-pressed="true">Pick nodes</button>
166
+ <button type="button" id="m-collapse" aria-pressed="false">Collapse / expand</button>
167
+ </div>
168
+ </div>
169
+ <div class="steplabel" id="steplabel" style="margin-top:8px"></div>
170
+ <div class="query" id="query" style="margin-top:6px"></div>
171
+ <div class="legend">
172
+ <span style="--sw:var(--accent);--sf:var(--accent-soft)">new in this step</span>
173
+ <span style="--sw:var(--accent);--sf:var(--accent)">picked node</span>
174
+ <span style="--sw:var(--anc);--sf:var(--anc-soft)">reaches the picked node</span>
175
+ <span style="--sw:var(--desc);--sf:var(--desc-soft)">reached by it</span>
176
+ </div>
177
+ </div>
178
+
179
+ <div class="card" id="p-tree">
180
+ <div class="panel-head">
181
+ <h2>Tree</h2>
182
+ <span class="note" id="tree-note"></span>
183
+ <div class="row"><button type="button" data-save="tree" data-fmt="svg">Save SVG</button><button type="button" data-save="tree" data-fmt="png">Save PNG</button></div>
184
+ </div>
185
+ <div class="scroll" id="tree"></div>
186
+ </div>
187
+
188
+ <div class="card" id="p-plane">
189
+ <div class="panel-head">
190
+ <h2>Plane</h2>
191
+ <span class="note">each node at (its rank in Q1, its rank in Q2)</span>
192
+ <div class="row"><button type="button" data-save="plane" data-fmt="svg">Save SVG</button><button type="button" data-save="plane" data-fmt="png">Save PNG</button></div>
193
+ </div>
194
+ <div class="scroll" id="plane"></div>
195
+ </div>
196
+
197
+ <div class="card" id="p-lists">
198
+ <div class="panel-head">
199
+ <h2>Q1 and Q2</h2>
200
+ <span class="note">positions as the library stores them (fractions; a new node takes the mediant of its neighbors)</span>
201
+ </div>
202
+ <div class="lists" id="lists"></div>
203
+ </div>
204
+ </section>
205
+ </main>
206
+
207
+ <footer class="band">
208
+ <div class="wrap">
209
+ <div>
210
+ <p><b>DTree Explorer 1.0.0</b> &middot; computed by <a href="https://pypi.org/project/dtree-encoding/">dtree-encoding</a> (MIT) &middot; <a href="https://github.com/EnisOlgac/DTree-Explorer">source on GitHub</a> &middot; <a href="https://doi.org/10.5772/intechopen.1013408">the paper</a></p>
211
+ <p>Your tree never leaves your computer. Opened from a file or a website, the page loads the Python runtime from cdn.jsdelivr.net and the package from pypi.org; started with <code>dtree-explorer</code>, it uses the library installed on your machine.</p>
212
+ </div>
213
+ <div><p>Contact: Enis Olgac, enis@digraphs.info</p><p>Copyright &copy; 2026 by Ihsan Enis Olgac, B&ouml;blingen</p></div>
214
+ </div>
215
+ </footer>
216
+
217
+ <div id="consent" hidden>
218
+ <div class="wrap">
219
+ <span>This page counts visits with Google Analytics, using cookies, if you agree. Please read the statement on <a id="privacy-link" href="https://digraphs.blog/">data protection and privacy</a>.</span>
220
+ <span class="row"><button type="button" id="c-no">No thanks</button><button type="button" class="agree" id="c-yes">I agree</button></span>
221
+ </div>
222
+ </div>
223
+
224
+ <script type="text/plain" id="src-init">"""DTree: directed tree library with dual projections Q1 and Q2."""
225
+
226
+ from .dtree import SINK, SOURCE, DList, DNode, DTree, UMax, UMin, VMax, VMin
227
+
228
+ __all__ = ["DTree", "DNode", "DList", "VMin", "VMax", "UMin", "UMax", "SOURCE", "SINK"]
229
+ __version__ = "1.8.0"
230
+ </script>
231
+ <script type="text/plain" id="src-dtree">"""DTree: a single-rooted directed tree encoded by two linear projections.
232
+
233
+ Python implementation of the DTree design and API by Enis Olgac (original C#
234
+ prototype in releases up to 1.1.0). This port was written with Claude (Anthropic) and checked against a
235
+ brute-force reference in ``tests/``.
236
+
237
+ Projection model
238
+ ----------------
239
+ Every tree is encoded by two orderings of its nodes, both bracketed by the
240
+ symbolic markers SOURCE ("[") and SINK ("]"):
241
+
242
+ * Q1: pre-order, children left to right (in insertion order).
243
+ * Q2: pre-order, children right to left (sibling-reversed).
244
+
245
+ Node u reaches node v if and only if u strictly precedes v in both Q1 and Q2.
246
+ Reachability is irreflexive: no node reaches itself.
247
+
248
+ Update rules (taken from the C# original)
249
+ ----------------------------------------
250
+ * insert(u, v): v is placed immediately after u in Q2, and in Q1 immediately
251
+ before the first node after u that u cannot reach (the end of u's subtree).
252
+ Insertion costs O(depth of the subtree below u); positions of other nodes
253
+ normally stay unchanged.
254
+ * delete(v): v is removed from both projections; its children move up to v's
255
+ parent and keep their relative order (a cut-and-paste of v's children).
256
+ * delete_subtree(v): the contiguous block of v's subtree is removed from both
257
+ projections.
258
+ * cut_n_paste(u, p): the contiguous block of u's subtree is lifted out of both
259
+ projections and re-inserted under p using the same rule as insert.
260
+
261
+ Positions are fractions. A new node gets the mediant (a+c)/(b+d) of its two
262
+ neighbors a/b and c/d (the Stern-Brocot construction), which always lies
263
+ strictly between them, so normally no other node moves. Rational positions
264
+ were Enis Olgac's idea; repeated insertion into the same gap (a node with many
265
+ children, appending at either end) then keeps positions at O(log n) bits. Only
266
+ when a denominator would exceed 64 bits, which needs alternating insertions,
267
+ is a small surrounding region renumbered with simple fractions k/2**32.
268
+ """
269
+
270
+ from __future__ import annotations
271
+
272
+ from fractions import Fraction
273
+ from typing import Dict, Iterable, Iterator, List, Optional
274
+
275
+ SOURCE = "["
276
+ SINK = "]"
277
+
278
+ _Q = 1 << 32 # denominator used when a crowded region is renumbered
279
+ _LIMIT = 64 # renumber when a denominator would exceed this many bits
280
+ _SLACK = 16 # after renumbering, neighbors are at least this many 1/_Q apart
281
+ _GROWTH = 2 # each doubling of the region raises the required spacing by this factor
282
+
283
+
284
+ class _Cell:
285
+ __slots__ = ("name", "prev", "next", "num", "den", "node")
286
+
287
+ def __init__(self, name: str) -> None:
288
+ self.name = name
289
+ self.prev: Optional[_Cell] = None
290
+ self.next: Optional[_Cell] = None
291
+ self.num = 0
292
+ self.den = 1
293
+ self.node: Optional["DNode"] = None # the tree node owning this cell (DTree only)
294
+
295
+
296
+ class DList:
297
+ """Doubly linked list with rational positions (O(1) precedence test).
298
+
299
+ Positions are fractions num/den. A new node gets the mediant
300
+ (a+c)/(b+d) of its neighbors a/b and c/d, which always lies strictly
301
+ between them; SOURCE and SINK act as -1/0 and +1/0. Nothing else moves.
302
+ Only when a denominator would exceed _LIMIT bits is a small surrounding
303
+ region renumbered with simple fractions k/_Q.
304
+ """
305
+
306
+ def __init__(self, name: str) -> None:
307
+ self.name = name
308
+ self._cells: Dict[str, _Cell] = {}
309
+ self.first = self._new(SOURCE)
310
+ self.last = self._new(SINK)
311
+ self.first.next = self.last
312
+ self.last.prev = self.first
313
+ self.first.num, self.first.den = -1, 0 # -infinity
314
+ self.last.num, self.last.den = 1, 0 # +infinity
315
+ # Statistics: how often positions were renumbered and how many moved.
316
+ self.renumber_count = 0
317
+ self.renumber_moved = 0
318
+
319
+ # -- internals --------------------------------------------------------
320
+ def _new(self, name: str) -> _Cell:
321
+ cell = _Cell(name)
322
+ self._cells[name] = cell
323
+ return cell
324
+
325
+ @staticmethod
326
+ def _lt(x: _Cell, y: _Cell) -> bool:
327
+ """x lies strictly left of y, by comparing x.num/x.den with y.num/y.den."""
328
+ if x.den and y.den:
329
+ return x.num * y.den < y.num * x.den
330
+ if x.den == 0 and y.den == 0:
331
+ return x.num < y.num
332
+ return (x.num if x.den == 0 else 0) < (y.num if y.den == 0 else 0)
333
+
334
+ def _link_after(self, cell: _Cell, prev: _Cell) -> None:
335
+ nxt = prev.next
336
+ cell.prev, cell.next = prev, nxt
337
+ prev.next = cell
338
+ nxt.prev = cell
339
+ if prev is self.first and nxt is self.last:
340
+ cell.num, cell.den = 0, 1 # first node of an empty list
341
+ return
342
+ # Mediant of the neighbors. Next to SOURCE (-1/0) or SINK (+1/0) this
343
+ # steps by one whole unit, so a list can grow at either end forever
344
+ # without renumbering (positives to the right, negatives to the left).
345
+ cell.num = prev.num + nxt.num
346
+ cell.den = prev.den + nxt.den
347
+ if cell.den.bit_length() > _LIMIT:
348
+ self._rebalance(cell)
349
+
350
+ def _rebalance(self, cell: _Cell) -> None:
351
+ """Renumber the smallest region around ``cell`` that has room, using fractions k/_Q."""
352
+ q = _Q
353
+ lo = hi = cell
354
+ m, k, need = 1, 1, _SLACK
355
+ while True:
356
+ for _ in range(k):
357
+ if lo.prev is not self.first:
358
+ lo = lo.prev
359
+ m += 1
360
+ if hi.next is not self.last:
361
+ hi = hi.next
362
+ m += 1
363
+ left, right = lo.prev, hi.next
364
+ if left is self.first and right is self.last:
365
+ ks = [(j - m // 2) * q for j in range(m)]
366
+ break
367
+ if left is self.first:
368
+ top = -((-right.num * q) // right.den) # ceil(right * q)
369
+ ks = [top - (m - j) * q for j in range(m)]
370
+ break
371
+ if right is self.last:
372
+ bottom = (left.num * q) // left.den # floor(left * q)
373
+ ks = [bottom + (j + 1) * q for j in range(m)]
374
+ break
375
+ bottom = (left.num * q) // left.den
376
+ top = -((-right.num * q) // right.den)
377
+ room = top - bottom - 1 # integers k with left < k/q < right
378
+ if room >= m * need:
379
+ ks = [bottom + ((j + 1) * (room + 1)) // (m + 1) for j in range(m)]
380
+ break
381
+ # Larger regions must be sparser before they are accepted (as in
382
+ # Bender et al.), so a renumbered region keeps spare room and the
383
+ # next overflow nearby can again be handled locally.
384
+ k *= 2
385
+ need *= _GROWTH
386
+ c = lo
387
+ for kv in ks:
388
+ c.num, c.den = kv, q
389
+ c = c.next
390
+ self.renumber_count += 1
391
+ self.renumber_moved += m
392
+
393
+ def _unlink(self, cell: _Cell) -> None:
394
+ cell.prev.next = cell.next
395
+ cell.next.prev = cell.prev
396
+ cell.prev = cell.next = None
397
+
398
+ # -- public -----------------------------------------------------------
399
+ def __contains__(self, name: str) -> bool:
400
+ return name in self._cells
401
+
402
+ # Names follow List.cs: Source/Sink, First/Last, Prev/Next, Index,
403
+ # IsAllocated, IsLeftOf ... IsSelfOrRightOf, InsAfter/InsBefore, IsSameAs.
404
+
405
+ @property
406
+ def source(self) -> str:
407
+ return self.first.name
408
+
409
+ @property
410
+ def sink(self) -> str:
411
+ return self.last.name
412
+
413
+ def index(self, name: str):
414
+ """Position of ``name`` as a Fraction (SOURCE/SINK: -inf/+inf), or None if absent (List.cs: Index)."""
415
+ cell = self._cells.get(name)
416
+ if cell is None:
417
+ return None
418
+ if cell.den == 0:
419
+ return float("inf") if cell.num > 0 else float("-inf")
420
+ return Fraction(cell.num, cell.den)
421
+
422
+ label = index
423
+
424
+ def is_allocated(self, name: str) -> bool:
425
+ return name in self._cells
426
+
427
+ def is_not_allocated(self, name: str) -> bool:
428
+ return name not in self._cells
429
+
430
+ def next(self, name: str) -> Optional[str]:
431
+ nxt = self._cells[name].next
432
+ return nxt.name if nxt else None
433
+
434
+ def prev(self, name: str) -> Optional[str]:
435
+ prv = self._cells[name].prev
436
+ return prv.name if prv else None
437
+
438
+ def is_left_of(self, v: str, u: str) -> bool:
439
+ return self._lt(self._cells[v], self._cells[u])
440
+
441
+ def is_self_or_left_of(self, v: str, u: str) -> bool:
442
+ return v == u or self._lt(self._cells[v], self._cells[u])
443
+
444
+ def is_right_of(self, v: str, u: str) -> bool:
445
+ return self._lt(self._cells[u], self._cells[v])
446
+
447
+ def is_self_or_right_of(self, v: str, u: str) -> bool:
448
+ return v == u or self._lt(self._cells[u], self._cells[v])
449
+
450
+ def precedes(self, u: str, v: str) -> bool:
451
+ """Strict order: u lies left of v (same as is_left_of)."""
452
+ x, y = self._cells[u], self._cells[v]
453
+ if x.den and y.den:
454
+ return x.num * y.den < y.num * x.den
455
+ return self._lt(x, y)
456
+
457
+ def _cell_for(self, name: str) -> _Cell:
458
+ """Existing cell (unlinked first, so it is moved) or a new one."""
459
+ cell = self._cells.get(name)
460
+ if cell is None:
461
+ return self._new(name)
462
+ self._unlink(cell)
463
+ return cell
464
+
465
+ def insert_after(self, name: str, anchor: str) -> None:
466
+ """Place ``name`` directly after ``anchor``; moves it if already present (List.cs: InsAfter)."""
467
+ if name == anchor or name == SINK:
468
+ return
469
+ if anchor == SINK:
470
+ return self.insert_before(name, SINK)
471
+ a = self._cells[anchor]
472
+ if a.next is not None and a.next.name == name:
473
+ return
474
+ cell = self._cell_for(name)
475
+ self._link_after(cell, self._cells[anchor])
476
+
477
+ def insert_before(self, name: str, anchor: str) -> None:
478
+ """Place ``name`` directly before ``anchor``; moves it if already present (List.cs: InsBefore)."""
479
+ if name == anchor or name == SOURCE:
480
+ return
481
+ if anchor == SOURCE:
482
+ return self.insert_after(name, SOURCE)
483
+ a = self._cells[anchor]
484
+ if a.prev is not None and a.prev.name == name:
485
+ return
486
+ cell = self._cell_for(name)
487
+ self._link_after(cell, self._cells[anchor].prev)
488
+
489
+ def is_same_as(self, other: "DList") -> bool:
490
+ """True if both lists hold the same nodes in the same order (List.cs: IsSameAs)."""
491
+ return list(self) == list(other)
492
+
493
+ def remove(self, name: str) -> None:
494
+ self._unlink(self._cells.pop(name))
495
+
496
+ def cut_block(self, first: str, last: str) -> List[str]:
497
+ """Remove the contiguous block first..last and return its names in order."""
498
+ a, b = self._cells[first], self._cells[last]
499
+ names, cell = [], a
500
+ while True:
501
+ names.append(cell.name)
502
+ if cell is b:
503
+ break
504
+ cell = cell.next
505
+ a.prev.next = b.next
506
+ b.next.prev = a.prev
507
+ for n in names:
508
+ self._cells.pop(n)
509
+ return names
510
+
511
+ def paste_block_after(self, names: List[str], anchor: str) -> None:
512
+ for n in names:
513
+ self.insert_after(n, anchor)
514
+ anchor = n
515
+
516
+ def __iter__(self) -> Iterator[str]:
517
+ cell = self.first.next
518
+ while cell is not self.last:
519
+ yield cell.name
520
+ cell = cell.next
521
+
522
+ def to_list(self, with_markers: bool = False) -> List[str]:
523
+ body = list(self)
524
+ return [SOURCE, *body, SINK] if with_markers else body
525
+
526
+ def __repr__(self) -> str:
527
+ return f"{self.name}: [{', '.join(self)}]"
528
+
529
+
530
+ class DNode:
531
+ """Handle of a tree node: refers directly to its positions in Q1 and Q2.
532
+
533
+ Returned by ``DTree.insert`` and ``DTree.get_node``. Every DTree method accepts a
534
+ handle wherever it accepts a node name; with handles, ``can_reach`` is two position
535
+ comparisons without any lookup by name. The idea of using addresses as ids comes from
536
+ Enis Olgac's original implementation.
537
+ """
538
+
539
+ __slots__ = ("name", "up", "kids", "c1", "c2", "last", "owner")
540
+
541
+ def __init__(self, name: str, owner: "DTree") -> None:
542
+ self.name = name
543
+ self.up: Optional[DNode] = None # parent (None for the root)
544
+ self.kids: Optional[List[DNode]] = None
545
+ self.c1: Optional[_Cell] = None # position in Q1
546
+ self.c2: Optional[_Cell] = None # position in Q2
547
+ self.last: Optional[DNode] = None # hint: last node of the subtree in Q1 (validated before use)
548
+ self.owner: Optional[DTree] = owner # None once the node has been removed
549
+
550
+ @property
551
+ def parent(self) -> Optional["DNode"]:
552
+ return self.up
553
+
554
+ @property
555
+ def children(self) -> List["DNode"]:
556
+ return list(self.kids or ())
557
+
558
+ @property
559
+ def is_in_tree(self) -> bool:
560
+ return self.owner is not None
561
+
562
+ def __repr__(self) -> str:
563
+ return f"DNode({self.name!r})" if self.owner is not None else f"DNode({self.name!r}, removed)"
564
+
565
+
566
+ _BUDGET = 8 # steps of the plain walk to the end of a subtree before the validated hint is used
567
+
568
+
569
+ class DTree:
570
+ """Single-rooted directed tree with dual projections Q1 and Q2."""
571
+
572
+ def __init__(self, tree_id: str = "P1") -> None:
573
+ self.tree_id = tree_id
574
+ self._nodes: Dict[str, DNode] = {}
575
+ self._root: Optional[DNode] = None
576
+ self._q1 = DList(f"{tree_id}.Q1")
577
+ self._q2 = DList(f"{tree_id}.Q2")
578
+ self.edge_log: List[str] = []
579
+ self._reshapes = 0 # counts deletions and moves; insertions keep the order of existing nodes
580
+
581
+ # -- construction ------------------------------------------------------
582
+ @classmethod
583
+ def from_file(cls, path: str, tree_id: Optional[str] = None) -> "DTree":
584
+ """Read the format used by the .csx examples.
585
+
586
+ ``parent: child1; child2`` adds children as last children of ``parent``;
587
+ ``sibling > child1; child2`` inserts them as siblings directly after ``sibling``
588
+ (in that order), ``sibling < child1; child2`` directly before it.
589
+ """
590
+ import os
591
+
592
+ tree = cls(tree_id or os.path.splitext(os.path.basename(path))[0])
593
+ with open(path, encoding="utf-8-sig") as fh:
594
+ for line in fh:
595
+ line = line.strip()
596
+ if not line or line.startswith(("#", "--", "**")):
597
+ continue
598
+ if line.startswith("@@"):
599
+ break
600
+ tree.add_line(line)
601
+ return tree
602
+
603
+ def add_line(self, line: str) -> List[DNode]:
604
+ """Apply one line of the description format (see from_file); returns the new handles."""
605
+ at = min((i for i in (line.find(":"), line.find(">"), line.find("<")) if i >= 0), default=-1)
606
+ if at < 0:
607
+ raise ValueError(f"no ':', '>' or '<' in {line!r}")
608
+ op, anchor = line[at], line[:at].strip()
609
+ children = [c.strip() for c in line[at + 1:].split(";") if c.strip()]
610
+ added = []
611
+ for child in children:
612
+ if op == ":":
613
+ added.append(self.insert(anchor, child))
614
+ elif op == ">":
615
+ added.append(self.insert_after(anchor, child))
616
+ anchor = child # the next one goes after this one
617
+ else:
618
+ added.append(self.insert_before(anchor, child))
619
+ return added
620
+
621
+ def insert(self, parent, child: str) -> DNode:
622
+ """Add the edge parent -> child and return the handle of ``child``.
623
+
624
+ ``parent`` is a name or a handle. The first call makes ``parent`` the root.
625
+ Afterwards ``parent`` must already exist and ``child`` must not.
626
+ """
627
+ if child in (SOURCE, SINK) or (isinstance(parent, str) and parent in (SOURCE, SINK)):
628
+ raise ValueError("SOURCE/SINK markers cannot be tree nodes")
629
+ if self._root is None:
630
+ if isinstance(parent, DNode):
631
+ raise ValueError("the first insert names the root by its name")
632
+ self._root = self._new_node(parent, None)
633
+ p = self._node(parent)
634
+ if child in self._nodes:
635
+ raise ValueError(f"child {child!r} is already in the tree")
636
+ n = self._new_node(child, p)
637
+ self.edge_log.append(f"({p.name}, {child})")
638
+ return n
639
+
640
+ def insert_after(self, sibling, child: str) -> DNode:
641
+ """Insert ``child`` as a sibling directly after ``sibling`` (same parent) and return its
642
+ handle. In Q1 it goes after the end of the sibling's subtree, in Q2 directly before the
643
+ sibling; no other node moves. The root has no siblings."""
644
+ s = self._sibling(sibling, child)
645
+ p = s.up
646
+ i = p.kids.index(s)
647
+ w = self._subtree_last_node(s)
648
+ n = self._link_node(child, p, i + 1, w.c1, s.c2.prev)
649
+ if i + 1 == len(p.kids) - 1:
650
+ p.last = n # n is now the last node of p's subtree
651
+ self.edge_log.append(f"({s.name} > {child})")
652
+ return n
653
+
654
+ def insert_before(self, sibling, child: str) -> DNode:
655
+ """Insert ``child`` as a sibling directly before ``sibling`` (same parent) and return its
656
+ handle. In Q1 it goes directly before the sibling, in Q2 after the end of the sibling's
657
+ subtree there (directly before the previous sibling; for a first child, after the end
658
+ of the parent's subtree in Q2, found by following first children); no other node moves."""
659
+ s = self._sibling(sibling, child)
660
+ p = s.up
661
+ i = p.kids.index(s)
662
+ q2_after = p.kids[i - 1].c2.prev if i > 0 else self._subtree_first_last_q2(s).c2
663
+ n = self._link_node(child, p, i, s.c1.prev, q2_after)
664
+ self.edge_log.append(f"({s.name} < {child})")
665
+ return n
666
+
667
+ def _sibling(self, sibling, child: str) -> DNode:
668
+ if child in (SOURCE, SINK):
669
+ raise ValueError("SOURCE/SINK markers cannot be tree nodes")
670
+ s = self._node(sibling)
671
+ if s.up is None:
672
+ raise ValueError("the root has no siblings")
673
+ if child in self._nodes:
674
+ raise ValueError(f"child {child!r} is already in the tree")
675
+ return s
676
+
677
+ def _link_node(self, name: str, p: DNode, index: int, q1_after, q2_after) -> DNode:
678
+ """Create a child of p at position ``index`` among p's children, linked after the given cells."""
679
+ n = DNode(name, self)
680
+ c1, c2 = self._q1._new(name), self._q2._new(name)
681
+ self._q2._link_after(c2, q2_after)
682
+ self._q1._link_after(c1, q1_after)
683
+ n.up = p
684
+ p.kids.insert(index, n)
685
+ c1.node = c2.node = n
686
+ n.c1, n.c2 = c1, c2
687
+ self._nodes[name] = n
688
+ return n
689
+
690
+ def _new_node(self, name: str, p: Optional[DNode]) -> DNode:
691
+ n = DNode(name, self)
692
+ q1, q2 = self._q1, self._q2
693
+ if p is None: # the root
694
+ c1, c2 = q1._new(name), q2._new(name)
695
+ q1._link_after(c1, q1.first)
696
+ q2._link_after(c2, q2.first)
697
+ else:
698
+ w = self._subtree_last_node(p) # Q1: after the end of p's subtree
699
+ c2 = q2._new(name) # Q2: right after p
700
+ q2._link_after(c2, p.c2)
701
+ c1 = q1._new(name)
702
+ q1._link_after(c1, w.c1)
703
+ n.up = p
704
+ if p.kids is None:
705
+ p.kids = [n]
706
+ else:
707
+ p.kids.append(n)
708
+ p.last = n # n is now the last node of p's subtree
709
+ c1.node = c2.node = n
710
+ n.c1, n.c2 = c1, c2
711
+ self._nodes[name] = n
712
+ return n
713
+
714
+ # -- the end of a subtree in Q1 ---------------------------------------
715
+ @staticmethod
716
+ def _in(p: DNode, x: DNode) -> bool:
717
+ """x is p or lies in p's subtree."""
718
+ if x is p:
719
+ return True
720
+ a, b = p.c1, x.c1
721
+ if not a.num * b.den < b.num * a.den:
722
+ return False
723
+ a, b = p.c2, x.c2
724
+ return a.num * b.den < b.num * a.den
725
+
726
+ def _is_last(self, p: DNode, x: Optional[DNode]) -> bool:
727
+ """x is the last node of p's subtree in Q1: x lies in it and x's Q1 successor does not."""
728
+ if x is None or x.owner is not self or not self._in(p, x):
729
+ return False
730
+ nxt = x.c1.next
731
+ return nxt.den == 0 or not self._in(p, nxt.node)
732
+
733
+ def _subtree_last_node(self, p: DNode) -> DNode:
734
+ """Last node of p's subtree in Q1.
735
+
736
+ A short walk down the last children finds it on ordinary trees. Beyond _BUDGET steps,
737
+ the end remembered last time (p.last) is used if it validates in O(1); otherwise the
738
+ walk continues, stops at a node whose own hint validates, and every node passed gets
739
+ the found end as its new hint (path compression). Stale hints are never used, so the
740
+ hints of ancestors need no update when a subtree grows.
741
+ """
742
+ w, k = p, 0
743
+ while w.kids and k < _BUDGET:
744
+ w = w.kids[-1]
745
+ k += 1
746
+ if not w.kids:
747
+ return w
748
+ if self._is_last(p, p.last):
749
+ return p.last
750
+ path, w = [p], p
751
+ while w.kids:
752
+ w = w.kids[-1]
753
+ if self._is_last(w, w.last):
754
+ w = w.last
755
+ break
756
+ path.append(w)
757
+ for v in path:
758
+ v.last = w
759
+ return w
760
+
761
+ @staticmethod
762
+ def _subtree_first_last_q2(p: DNode) -> DNode:
763
+ """Last node of p's subtree in Q2 (siblings reversed): follow first children."""
764
+ w = p
765
+ while w.kids:
766
+ w = w.kids[0]
767
+ return w
768
+
769
+ def next_in_q1(self, node) -> Optional[str]:
770
+ """The node after ``node`` in Q1, as a name, or None after the last node. Q1 is the
771
+ display order of a tree view (parents before children, siblings in insertion order)."""
772
+ c = self._node(node).c1.next
773
+ return c.node.name if c is not None and c.node is not None else None
774
+
775
+ def subtree_last(self, node):
776
+ """Last node of the subtree of ``node`` in Q1, as a name (the subtree is the block from
777
+ ``node`` to it). Usually O(1) when asked again for the same node."""
778
+ return self._subtree_last_node(self._node(node)).name
779
+
780
+ # -- structural reconfiguration ---------------------------------------
781
+ def delete(self, node) -> None:
782
+ """Remove ``node``; its children are re-attached to its parent.
783
+
784
+ Deleting the root is disallowed.
785
+ """
786
+ n = self._node(node)
787
+ if n is self._root:
788
+ raise ValueError("the root cannot be deleted")
789
+ p = n.up
790
+ kids = n.kids or []
791
+ i = p.kids.index(n)
792
+ p.kids[i : i + 1] = kids
793
+ for k in kids:
794
+ k.up = p
795
+ if not p.kids:
796
+ p.kids = None
797
+ self._q1.remove(n.name)
798
+ self._q2.remove(n.name)
799
+ self._forget(n)
800
+ self._reshapes += 1
801
+
802
+ def delete_subtree(self, node) -> List[str]:
803
+ """Remove ``node`` together with all its descendants.
804
+
805
+ Returns the removed nodes in Q1 order. Deleting the root's subtree
806
+ (the whole tree) is disallowed, as for delete.
807
+ """
808
+ n = self._node(node)
809
+ if n is self._root:
810
+ raise ValueError("the root cannot be deleted")
811
+ last1 = self._subtree_last_node(n)
812
+ last2 = self._subtree_first_last_q2(n)
813
+ removed = self._q1.cut_block(n.name, last1.name)
814
+ self._q2.cut_block(n.name, last2.name)
815
+ p = n.up
816
+ p.kids.remove(n)
817
+ if not p.kids:
818
+ p.kids = None
819
+ for v in removed:
820
+ self._forget(self._nodes[v])
821
+ self._reshapes += 1
822
+ return removed
823
+
824
+ def cut_n_paste(self, subtree_root, new_parent) -> None:
825
+ """Detach the subtree rooted at ``subtree_root`` and attach it under ``new_parent``.
826
+
827
+ The subtree keeps its internal shape and becomes the last child of
828
+ ``new_parent``. ``new_parent`` must not lie inside the moved subtree,
829
+ and the root cannot be moved.
830
+ """
831
+ u = self._node(subtree_root)
832
+ p = self._node(new_parent)
833
+ if u is self._root:
834
+ raise ValueError("the root cannot be moved")
835
+ if p is u or self._in(u, p):
836
+ raise ValueError("new_parent lies inside the subtree being moved")
837
+ last1 = self._subtree_last_node(u)
838
+ last2 = self._subtree_first_last_q2(u)
839
+ block1 = self._q1.cut_block(u.name, last1.name)
840
+ block2 = self._q2.cut_block(u.name, last2.name)
841
+ moved = [self._nodes[v] for v in block1]
842
+ for v in moved:
843
+ v.owner = None # detached for a moment: their hints and cells are not valid
844
+ old = u.up
845
+ old.kids.remove(u)
846
+ if not old.kids:
847
+ old.kids = None
848
+ # Same placement rule as insert: Q2 right after the parent,
849
+ # Q1 right after the current last node of the parent's subtree.
850
+ last = self._subtree_last_node(p)
851
+ self._q2.paste_block_after(block2, p.name)
852
+ self._q1.paste_block_after(block1, last.name)
853
+ for v in moved: # the pasted cells are new objects
854
+ v.owner = self
855
+ v.c1, v.c2 = self._q1._cells[v.name], self._q2._cells[v.name]
856
+ v.c1.node = v.c2.node = v
857
+ u.up = p
858
+ if p.kids is None:
859
+ p.kids = [u]
860
+ else:
861
+ p.kids.append(u)
862
+ p.last = last1
863
+ self._reshapes += 1
864
+
865
+ def _forget(self, n: DNode) -> None:
866
+ del self._nodes[n.name]
867
+ n.owner = None
868
+ n.up = n.kids = n.last = None
869
+
870
+ # -- handles and names -------------------------------------------------
871
+ def get_node(self, name: str) -> DNode:
872
+ """Handle of the node called ``name``. Raises KeyError if absent."""
873
+ n = self._nodes.get(name)
874
+ if n is None:
875
+ raise KeyError(f"node {name!r} is not in the tree")
876
+ return n
877
+
878
+ def try_get_node(self, name: str) -> Optional[DNode]:
879
+ return self._nodes.get(name)
880
+
881
+ def _node(self, x) -> DNode:
882
+ """Resolve a name or a handle of this tree; raises KeyError otherwise."""
883
+ if x.__class__ is DNode:
884
+ if x.owner is not self:
885
+ raise KeyError(f"node {x.name!r} is not in this tree")
886
+ return x
887
+ n = self._nodes.get(x)
888
+ if n is None:
889
+ raise KeyError(f"node {x!r} is not in the tree")
890
+ return n
891
+
892
+ def _name(self, x) -> str:
893
+ return x.name if x.__class__ is DNode else x
894
+
895
+ # -- accessors --------------------------------------------------------
896
+ @property
897
+ def root(self) -> Optional[str]:
898
+ return self._root.name if self._root is not None else None
899
+
900
+ @property
901
+ def source(self) -> str:
902
+ return SOURCE
903
+
904
+ @property
905
+ def sink(self) -> str:
906
+ return SINK
907
+
908
+ @property
909
+ def Q1(self) -> DList:
910
+ """The Q1 projection itself, for list-level checks (is_left_of, index, ...).
911
+
912
+ Live view: read it, but change the tree only through DTree methods.
913
+ """
914
+ return self._q1
915
+
916
+ @property
917
+ def Q2(self) -> DList:
918
+ """The Q2 projection itself, for list-level checks. Live view, as Q1."""
919
+ return self._q2
920
+
921
+ @property
922
+ def q1(self) -> List[str]:
923
+ """Q1 as a plain list of node names (a copy, without the markers)."""
924
+ return self._q1.to_list()
925
+
926
+ @property
927
+ def q2(self) -> List[str]:
928
+ return self._q2.to_list()
929
+
930
+ @property
931
+ def size(self) -> int:
932
+ return len(self._nodes)
933
+
934
+ @property
935
+ def nodes(self) -> List[str]:
936
+ return self.q1
937
+
938
+ @property
939
+ def _parent(self) -> Dict[str, str]:
940
+ """child name -> parent name (root -> SOURCE); a snapshot, e.g. for comparisons."""
941
+ return {n.name: (n.up.name if n.up is not None else SOURCE) for n in self._nodes.values()}
942
+
943
+ def __len__(self) -> int:
944
+ return self.size
945
+
946
+ def __contains__(self, node) -> bool:
947
+ return self.contains(node)
948
+
949
+ def __repr__(self) -> str:
950
+ return f"DTree({self.tree_id!r}, size={self.size})\n {self._q1!r}\n {self._q2!r}"
951
+
952
+ # -- queries ----------------------------------------------------------
953
+ def contains(self, node) -> bool:
954
+ if node.__class__ is DNode:
955
+ return node.owner is self
956
+ return node in self._nodes
957
+
958
+ def get_parent(self, node) -> Optional[str]:
959
+ """Parent of ``node``; ``None`` for the root. Raises KeyError if absent."""
960
+ p = self._node(node).up
961
+ return p.name if p is not None else None
962
+
963
+ def children(self, node) -> List[str]:
964
+ return [k.name for k in (self._node(node).kids or ())]
965
+
966
+ def can_reach(self, u, v) -> bool:
967
+ """True if there is a directed path of length >= 1 from u to v (names or handles).
968
+
969
+ Irreflexive: can_reach(u, u) is False. With handles: two position comparisons.
970
+ """
971
+ if u.__class__ is DNode:
972
+ if u.owner is not self:
973
+ return False
974
+ else:
975
+ u = self._nodes.get(u)
976
+ if u is None:
977
+ return False
978
+ if v.__class__ is DNode:
979
+ if v.owner is not self:
980
+ return False
981
+ else:
982
+ v = self._nodes.get(v)
983
+ if v is None:
984
+ return False
985
+ a, b = u.c1, v.c1
986
+ if not a.num * b.den < b.num * a.den:
987
+ return False
988
+ a, b = u.c2, v.c2
989
+ return a.num * b.den < b.num * a.den
990
+
991
+ def can_not_reach(self, u, v) -> bool:
992
+ return not self.can_reach(u, v)
993
+
994
+ def is_root(self, node) -> bool:
995
+ return self.contains(node) and self._node(node) is self._root
996
+
997
+ def is_leaf(self, node) -> bool:
998
+ return not self._node(node).kids
999
+
1000
+ def is_ancestor(self, u, v) -> bool:
1001
+ """True if u is a proper ancestor of v (u lies above v on v's root path)."""
1002
+ return self.can_reach(u, v)
1003
+
1004
+ def is_descendant(self, u, v) -> bool:
1005
+ """True if u lies below v. Equivalent to is_ancestor(v, u)."""
1006
+ return self.can_reach(v, u)
1007
+
1008
+ def same_subtree(self, u, v) -> bool:
1009
+ """True if both nodes are in this tree (and so share its root)."""
1010
+ return self.contains(u) and self.contains(v)
1011
+
1012
+ def subtree(self, node) -> List[str]:
1013
+ """Nodes of the subtree rooted at ``node``, in Q1 order."""
1014
+ n = self._node(node)
1015
+ out, c = [n.name], n.c1.next
1016
+ end = self._subtree_last_node(n).c1
1017
+ if end is n.c1:
1018
+ return out
1019
+ while True:
1020
+ out.append(c.name)
1021
+ if c is end:
1022
+ return out
1023
+ c = c.next
1024
+
1025
+ def subtree_size(self, node) -> int:
1026
+ return len(self.subtree(node))
1027
+
1028
+ def path_to_root(self, node) -> List[str]:
1029
+ """[node, parent, ..., root]."""
1030
+ out, w = [], self._node(node)
1031
+ while w is not None:
1032
+ out.append(w.name)
1033
+ w = w.up
1034
+ return out
1035
+
1036
+ def root_to_path(self, node) -> List[str]:
1037
+ """[root, ..., parent, node]."""
1038
+ return self.path_to_root(node)[::-1]
1039
+
1040
+ def path(self, u, v) -> List[str]:
1041
+ """Nodes from u down to v, both included; [u] when u == v.
1042
+
1043
+ Raises if u != v and u cannot reach v.
1044
+ """
1045
+ a, b = self._node(u), self._node(v)
1046
+ if a is not b and not self.can_reach(a, b):
1047
+ raise ValueError(f"{a.name!r} cannot reach {b.name!r}")
1048
+ out, w = [b.name], b
1049
+ while w is not a:
1050
+ w = w.up
1051
+ out.append(w.name)
1052
+ return out[::-1]
1053
+
1054
+ def depth(self, node) -> int:
1055
+ """Distance from the root; the root has depth 0."""
1056
+ d, w = 0, self._node(node).up
1057
+ while w is not None:
1058
+ d += 1
1059
+ w = w.up
1060
+ return d
1061
+
1062
+ # -- helpers ----------------------------------------------------------
1063
+ # -- and/or queries on the topology -----------------------------------
1064
+ def vmin(self, nodes: Iterable[str] = ()) -> "VMin":
1065
+ """Constructed node VMin: the minimum of each index over the nodes (see VMin)."""
1066
+ return VMin(self, nodes)
1067
+
1068
+ def vmax(self, nodes: Iterable[str] = ()) -> "VMax":
1069
+ """Constructed node VMax: the maximum of each index over the nodes (see VMax)."""
1070
+ return VMax(self, nodes)
1071
+
1072
+ def umin(self, nodes: Iterable[str] = ()) -> "UMin":
1073
+ """Constructed node UMin: the minimum of each index over the nodes (see UMin)."""
1074
+ return UMin(self, nodes)
1075
+
1076
+ def umax(self, nodes: Iterable[str] = ()) -> "UMax":
1077
+ """Constructed node UMax: the maximum of each index over the nodes (see UMax)."""
1078
+ return UMax(self, nodes)
1079
+
1080
+ def _require(self, node) -> None:
1081
+ self._node(node)
1082
+
1083
+
1084
+ class _VNode:
1085
+ """A constructed node for a set S of tree nodes (E. Olgac, "Topology and Structure of
1086
+ Directed Graphs - Inducing a Subgraph", 2021): the minimum (VMin, UMin) or maximum
1087
+ (VMax, UMax) of each index over S. V means S takes the target role, can_reach(u, S),
1088
+ and U the source role, can_reach(S, v); UMin and VMin hold the same numbers.
1089
+
1090
+ It stores no positions, only the members and, for each of Q1 and Q2, the member holding
1091
+ the minimal (or maximal) index there; the two may be different nodes, so the constructed
1092
+ node usually corresponds to no real node. Insertions and renumbering keep the order of
1093
+ existing nodes, so it stays valid while the tree grows. After a deletion or a cut_n_paste it is
1094
+ recomputed from the remaining members on its next use. Members are added with ``add`` and
1095
+ removed with ``discard``.
1096
+ """
1097
+
1098
+ _lowest = True # VMin keeps the leftmost member, VMax the rightmost
1099
+
1100
+ def __init__(self, tree: "DTree", nodes: Iterable[str] = ()) -> None:
1101
+ self._t = tree
1102
+ self._members: List[str] = [] # in Q1 order
1103
+ self._seen: set = set()
1104
+ self._c1: Optional[str] = None
1105
+ self._c2: Optional[str] = None
1106
+ self._reshapes = tree._reshapes
1107
+ for v in nodes:
1108
+ self.add(v)
1109
+
1110
+ def _better(self, q: DList, v: str, c: Optional[str]) -> bool:
1111
+ if c is None:
1112
+ return True
1113
+ return q.precedes(v, c) if self._lowest else q.precedes(c, v)
1114
+
1115
+ def add(self, node: str) -> "_VNode":
1116
+ """Add a node of the tree (name or handle) to the set; returns the constructed node itself."""
1117
+ node = self._t._name(node)
1118
+ self._t._require(node)
1119
+ if node in self._seen:
1120
+ return self
1121
+ self._check()
1122
+ self._seen.add(node)
1123
+ q1 = self._t._q1
1124
+ lo, hi = 0, len(self._members) # keep members sorted by Q1 order
1125
+ while lo < hi:
1126
+ mid = (lo + hi) // 2
1127
+ if q1.precedes(node, self._members[mid]):
1128
+ hi = mid
1129
+ else:
1130
+ lo = mid + 1
1131
+ self._members.insert(lo, node)
1132
+ if self._better(self._t._q1, node, self._c1):
1133
+ self._c1 = node
1134
+ if self._better(self._t._q2, node, self._c2):
1135
+ self._c2 = node
1136
+ self._added(node)
1137
+ return self
1138
+
1139
+ def discard(self, node) -> "_VNode":
1140
+ """Remove a node (name or handle) from the set; no error if it is not a member.
1141
+
1142
+ O(log k) to find it plus the list removal; if it held an index (the minimal or
1143
+ maximal position in Q1 or Q2), the index nodes are recomputed in O(k). Returns the
1144
+ constructed node itself. Typical use: expanding a node in a tree view, after it was
1145
+ added to the set of collapsed nodes.
1146
+ """
1147
+ node = self._t._name(node)
1148
+ self._check()
1149
+ if node not in self._seen:
1150
+ return self
1151
+ self._seen.discard(node)
1152
+ q1, m = self._t._q1, self._members
1153
+ lo, hi = 0, len(m) # members are sorted by Q1 order
1154
+ while lo < hi:
1155
+ mid = (lo + hi) // 2
1156
+ if q1.precedes(m[mid], node):
1157
+ lo = mid + 1
1158
+ else:
1159
+ hi = mid
1160
+ del m[lo]
1161
+ if node == self._c1 or node == self._c2:
1162
+ t = self._t
1163
+ self._c1 = self._c2 = None
1164
+ for v in self._members:
1165
+ if self._better(t._q1, v, self._c1):
1166
+ self._c1 = v
1167
+ if self._better(t._q2, v, self._c2):
1168
+ self._c2 = v
1169
+ self._discarded(node)
1170
+ return self
1171
+
1172
+ def __contains__(self, node) -> bool:
1173
+ self._check()
1174
+ return self._t._name(node) in self._seen
1175
+
1176
+ def _added(self, node: str) -> None:
1177
+ """Hook for subclasses that keep more than the index nodes."""
1178
+
1179
+ def _discarded(self, node: str) -> None:
1180
+ """Hook for subclasses that keep more than the index nodes."""
1181
+
1182
+ def _rebuilt(self) -> None:
1183
+ """Hook for subclasses that keep more than the index nodes."""
1184
+
1185
+ def refresh(self) -> None:
1186
+ """Drop deleted members and recompute the index nodes."""
1187
+ t = self._t
1188
+ q1 = t._q1
1189
+ self._members = sorted((v for v in self._members if t.contains(v)),
1190
+ key=lambda v: q1.index(v))
1191
+ self._seen = set(self._members)
1192
+ self._c1 = self._c2 = None
1193
+ for v in self._members:
1194
+ if self._better(t._q1, v, self._c1):
1195
+ self._c1 = v
1196
+ if self._better(t._q2, v, self._c2):
1197
+ self._c2 = v
1198
+ self._reshapes = t._reshapes
1199
+ self._rebuilt()
1200
+
1201
+ def _check(self) -> None:
1202
+ t = self._t
1203
+ if self._reshapes != t._reshapes:
1204
+ self.refresh()
1205
+
1206
+ @property
1207
+ def members(self) -> List[str]:
1208
+ self._check()
1209
+ return list(self._members)
1210
+
1211
+ @property
1212
+ def index_nodes(self) -> tuple:
1213
+ """The members holding the minimal (VMin, UMin) or maximal (VMax, UMax) index in Q1 and in Q2 (possibly two different nodes)."""
1214
+ self._check()
1215
+ return self._c1, self._c2
1216
+
1217
+ def __len__(self) -> int:
1218
+ self._check()
1219
+ return len(self._members)
1220
+
1221
+
1222
+ class VMin(_VNode):
1223
+ """S in the target role, can_reach(u, S). Minimum of each index over a node set: u reaches all of the set exactly when u lies
1224
+ strictly left of VMin in both Q1 and Q2."""
1225
+
1226
+ _lowest = True
1227
+
1228
+ def reaches_all(self, u) -> bool:
1229
+ """True if u reaches every node of the set (vacuously True for an empty set)."""
1230
+ u = self._t._name(u)
1231
+ self._check()
1232
+ t = self._t
1233
+ if not t.contains(u):
1234
+ return False
1235
+ if self._c1 is None:
1236
+ return True
1237
+ return t._q1.precedes(u, self._c1) and t._q2.precedes(u, self._c2)
1238
+
1239
+
1240
+ class VMax(_VNode):
1241
+ """S in the target role, can_reach(u, S). Maximum of each index over a node set: lying
1242
+ strictly left of VMax in both Q1 and Q2 is necessary for u to reach some of the set, but
1243
+ not sufficient, so may_reach_some is only a filter; reaches_some is exact."""
1244
+
1245
+ _lowest = False
1246
+
1247
+ def may_reach_some(self, u) -> bool:
1248
+ """Fast rejection: False means u reaches no node of the set. True is not a confirmation."""
1249
+ u = self._t._name(u)
1250
+ self._check()
1251
+ t = self._t
1252
+ if self._c1 is None or not t.contains(u):
1253
+ return False
1254
+ return t._q1.precedes(u, self._c1) and t._q2.precedes(u, self._c2)
1255
+
1256
+ def reaches_some(self, u) -> bool:
1257
+ """True if u reaches at least one node of the set (exact).
1258
+
1259
+ The subtree of u is the block directly after u in Q1, so u reaches some member
1260
+ exactly when the first member after u in Q1 lies in it: a binary search over the
1261
+ members (kept in Q1 order) and one reachability test.
1262
+ """
1263
+ u = self._t._name(u)
1264
+ if not self.may_reach_some(u):
1265
+ return False
1266
+ t, m = self._t, self._members
1267
+ q1 = t._q1
1268
+ lo, hi = 0, len(m)
1269
+ while lo < hi:
1270
+ mid = (lo + hi) // 2
1271
+ if q1.precedes(u, m[mid]):
1272
+ hi = mid
1273
+ else:
1274
+ lo = mid + 1
1275
+ return lo < len(m) and t.can_reach(u, m[lo])
1276
+
1277
+
1278
+ class UMax(_VNode):
1279
+ """S in the source role, can_reach(S, v). Maximum of each index over a node set: v is
1280
+ reached by all of the set exactly when v lies strictly right of UMax in both Q1 and Q2."""
1281
+
1282
+ _lowest = False
1283
+
1284
+ def reached_by_all(self, v) -> bool:
1285
+ """True if every node of the set reaches v (vacuously True for an empty set)."""
1286
+ v = self._t._name(v)
1287
+ self._check()
1288
+ t = self._t
1289
+ if not t.contains(v):
1290
+ return False
1291
+ if self._c1 is None:
1292
+ return True
1293
+ return t._q1.precedes(self._c1, v) and t._q2.precedes(self._c2, v)
1294
+
1295
+
1296
+ class UMin(_VNode):
1297
+ """S in the source role, can_reach(S, v). Minimum of each index over a node set: lying
1298
+ strictly right of UMin in both Q1 and Q2 is necessary for v to be reached by some of the
1299
+ set, but not sufficient, so may_be_reached_by_some is only a filter; reached_by_some is
1300
+ exact.
1301
+
1302
+ The exact test keeps the topmost members (those with no ancestor in the set) in Q1
1303
+ order. Their subtrees are disjoint, so if one of them is an ancestor of v, no other
1304
+ topmost member lies between it and v in Q1: the last topmost member before v in Q1 is
1305
+ the only one to test.
1306
+ """
1307
+
1308
+ _lowest = True
1309
+
1310
+ def __init__(self, tree: "DTree", nodes: Iterable[str] = ()) -> None:
1311
+ self._tops: List[str] = [] # topmost members, in Q1 order
1312
+ super().__init__(tree, nodes)
1313
+
1314
+ def _last_top_before(self, v: str) -> int:
1315
+ """Number of topmost members strictly left of v in Q1."""
1316
+ q1, m = self._t._q1, self._tops
1317
+ lo, hi = 0, len(m)
1318
+ while lo < hi:
1319
+ mid = (lo + hi) // 2
1320
+ if q1.precedes(m[mid], v):
1321
+ lo = mid + 1
1322
+ else:
1323
+ hi = mid
1324
+ return lo
1325
+
1326
+ def _covered(self, v: str) -> bool:
1327
+ i = self._last_top_before(v)
1328
+ return i > 0 and self._t.can_reach(self._tops[i - 1], v)
1329
+
1330
+ def _added(self, node: str) -> None:
1331
+ if self._covered(node):
1332
+ return # an ancestor of node is already in the set
1333
+ i = self._last_top_before(node)
1334
+ t, m = self._t, self._tops
1335
+ while i < len(m) and t.can_reach(node, m[i]):
1336
+ del m[i] # node is above them: they are no longer topmost
1337
+ m.insert(i, node)
1338
+
1339
+ def _discarded(self, node: str) -> None:
1340
+ if node in self._tops: # members it covered may become topmost
1341
+ self._rebuilt()
1342
+
1343
+ def _rebuilt(self) -> None:
1344
+ self._tops = []
1345
+ t = self._t
1346
+ for v in self._members: # Q1 order: ancestors come first
1347
+ if not (self._tops and t.can_reach(self._tops[-1], v)):
1348
+ self._tops.append(v)
1349
+
1350
+ def may_be_reached_by_some(self, v) -> bool:
1351
+ """Fast rejection: False means no node of the set reaches v; True is not a confirmation."""
1352
+ v = self._t._name(v)
1353
+ self._check()
1354
+ t = self._t
1355
+ if self._c1 is None or not t.contains(v):
1356
+ return False
1357
+ return t._q1.precedes(self._c1, v) and t._q2.precedes(self._c2, v)
1358
+
1359
+ def reached_by_some(self, v) -> bool:
1360
+ """True if at least one node of the set reaches v (exact, O(log k))."""
1361
+ v = self._t._name(v)
1362
+ if not self.may_be_reached_by_some(v):
1363
+ return False
1364
+ return self._covered(v)
1365
+
1366
+ def covering(self, v) -> Optional[str]:
1367
+ """The topmost member of the set that reaches v, or None if no member does (O(log k)).
1368
+
1369
+ With the collapsed nodes of a tree view as a UMin, this is the visible row that
1370
+ contains v: None means v itself is visible.
1371
+ """
1372
+ v = self._t._name(v)
1373
+ self._check()
1374
+ if not self._t.contains(v):
1375
+ return None
1376
+ i = self._last_top_before(v)
1377
+ if i > 0 and self._t.can_reach(self._tops[i - 1], v):
1378
+ return self._tops[i - 1]
1379
+ return None
1380
+ </script>
1381
+ <script type="text/plain" id="src-bridge">"""Bridge between the DTree Explorer page and the dtree package.
1382
+
1383
+ The page runs this module in the browser (Pyodide) with the published dtree-encoding
1384
+ package; the same code runs under CPython for the tests and to precompute the example
1385
+ that is shown while the Python runtime loads. Everything the page displays about the
1386
+ encoding (the order of Q1 and Q2, the positions, the parents) comes from here.
1387
+ """
1388
+
1389
+ import json
1390
+
1391
+ import dtree
1392
+ from dtree import DTree
1393
+
1394
+ LIMIT = 300 # insertions recorded step by step; larger trees show the final state only
1395
+
1396
+
1397
+ def _split(line):
1398
+ at = min((i for i in (line.find(":"), line.find(">"), line.find("<")) if i >= 0), default=-1)
1399
+ if at < 0:
1400
+ return None
1401
+ return line[at], line[:at].strip(), [c.strip() for c in line[at + 1:].split(";") if c.strip()]
1402
+
1403
+
1404
+ def _state(t):
1405
+ q1, q2 = t.q1, t.q2
1406
+ pos = {v: [str(t.Q1.index(v)), str(t.Q2.index(v))] for v in q1}
1407
+ return {"q1": q1, "q2": q2, "pos": pos}
1408
+
1409
+
1410
+ def encode(text, limit=LIMIT):
1411
+ """Build a tree from the description and return the steps as JSON.
1412
+
1413
+ Each step is one inserted node: the operation, its line, the new node and its parent,
1414
+ and (up to ``limit`` insertions) the full state after it: Q1, Q2 and the positions.
1415
+ """
1416
+ t = DTree("E")
1417
+ steps, errors, parents = [], [], {}
1418
+ recording = True
1419
+ for no, raw in enumerate(text.splitlines(), 1):
1420
+ line = raw.strip().lstrip("")
1421
+ if not line or line.startswith(("#", "--", "**")):
1422
+ continue
1423
+ if line.startswith("@@"):
1424
+ break
1425
+ parts = _split(line)
1426
+ if parts is None:
1427
+ errors.append({"line": no, "text": line, "msg": "expected ':', '>' or '<'"})
1428
+ continue
1429
+ op, anchor, kids = parts
1430
+ for child in kids:
1431
+ try:
1432
+ if op == ":":
1433
+ t.insert(anchor, child)
1434
+ shown = f"{anchor}:{child}"
1435
+ elif op == ">":
1436
+ t.insert_after(anchor, child)
1437
+ shown = f"{anchor}>{child}"
1438
+ anchor = child
1439
+ else:
1440
+ t.insert_before(anchor, child)
1441
+ shown = f"{anchor}<{child}"
1442
+ except (KeyError, ValueError) as e:
1443
+ msg = e.args[0] if e.args else str(e)
1444
+ errors.append({"line": no, "text": line, "msg": f"{child}: {msg}"})
1445
+ continue
1446
+ parents[child] = t.get_parent(child)
1447
+ step = {"line": no, "op": shown, "new": child}
1448
+ if recording and len(steps) < limit:
1449
+ step.update(_state(t))
1450
+ elif recording:
1451
+ recording = False
1452
+ for s in steps:
1453
+ for k in ("q1", "q2", "pos"):
1454
+ s.pop(k, None)
1455
+ steps.append(step)
1456
+ final = _state(t) if t.root is not None else {"q1": [], "q2": [], "pos": {}}
1457
+ return json.dumps({
1458
+ "version": dtree.__version__,
1459
+ "root": t.root,
1460
+ "parents": parents,
1461
+ "steps": steps,
1462
+ "final": final,
1463
+ "stepwise": recording,
1464
+ "limit": limit,
1465
+ "renumber": [t.Q1.renumber_count, t.Q2.renumber_count],
1466
+ "errors": errors,
1467
+ })
1468
+ </script>
1469
+ <script>
1470
+ "use strict";
1471
+ // ---- configuration --------------------------------------------------------------------
1472
+ const CONFIG = {
1473
+ dtreeVersion: "1.8.0",
1474
+ pyodideURL: "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/",
1475
+ explorerVersion: "1.0.0",
1476
+ gaId: "", // Google Analytics 4, only in the copy for digraphs.blog; empty elsewhere
1477
+ privacyURL: "", // statement on data protection and privacy (blog copy)
1478
+ };
1479
+ const EXAMPLES = {
1480
+ "Quick start": "A: B; D\nB: C\n",
1481
+ "Paper, Sec. 1: D between B and C": "# Paper, Section 1: D becomes a child of B, between B and its sibling C\nA: B; C\nB: D\n",
1482
+ "Paper, Figure 1": "# Paper, Figure 1 (Q1 = 1 ... 12)\n1: 2\n2: 3; 4; 9\n4: 5\n5: 6; 7\n7: 8\n9: 10; 12\n10: 11\n",
1483
+ "Paper, Sec. 4.1, Case A: v before 6": "# Paper, Figure 1 (Q1 = 1 ... 12)\n1: 2\n2: 3; 4; 9\n4: 5\n5: 6; 7\n7: 8\n9: 10; 12\n10: 11\n# Insert(5, v, 6) before: v directly before its sibling 6\n6 < v\n",
1484
+ "Paper, Sec. 4.1, Case B: v after 6": "# Paper, Figure 1 (Q1 = 1 ... 12)\n1: 2\n2: 3; 4; 9\n4: 5\n5: 6; 7\n7: 8\n9: 10; 12\n10: 11\n# Insert(5, v, 6) after: v directly after its sibling 6\n6 > v\n",
1485
+ "Paper, Appendix A: base tree (Test.csx)": "01:02\n01:03\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22\n",
1486
+ "Paper, Appendix A, Variant A: X first (Test21.csx)": "01:X\nX:Y\nX:Z\n01:02\n01:03\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22",
1487
+ "Paper, Appendix A, Variant A with <": "01:02\n01:03\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22\n# Variant A written with '<': X directly before 02\n02 < X\nX: Y; Z\n",
1488
+ "Paper, Appendix A, Variant B: X middle (test23.csx)": "01:02\n01:03\n01:X\nX:Y\nX:Z\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22",
1489
+ "Paper, Appendix A, Variant B with >": "01:02\n01:03\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22\n# Variant B written with '>': X directly after 03\n03 > X\nX: Y; Z\n",
1490
+ "Paper, Appendix A, Variant C: X last (test22.csx)": "01:02\n01:03\n01:04\n01:X\nX:Y\nX:Z\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22",
1491
+ "Paper, Appendix A, Variant C with >": "01:02\n01:03\n01:04\n02:05\n02:07\n02:08\n03:11\n04:06\n04:16\n05:15\n05:23\n06:10\n06:12\n07:09\n07:14\n07:25\n08:13\n09:24\n10:22\n# Variant C written with '>': X directly after 04\n04 > X\nX: Y; Z\n",
1492
+ "Siblings with > and <": "# Sibling insertion: \">\" inserts after a sibling, \"<\" before it\n01: 02; 05\n02: 03\n02 > 04\n05 < 06\n03 < 03a\n",
1493
+ "Chain of 40": "n0: n1\nn1: n2\nn2: n3\nn3: n4\nn4: n5\nn5: n6\nn6: n7\nn7: n8\nn8: n9\nn9: n10\nn10: n11\nn11: n12\nn12: n13\nn13: n14\nn14: n15\nn15: n16\nn16: n17\nn17: n18\nn18: n19\nn19: n20\nn20: n21\nn21: n22\nn22: n23\nn23: n24\nn24: n25\nn25: n26\nn26: n27\nn27: n28\nn28: n29\nn29: n30\nn30: n31\nn31: n32\nn32: n33\nn33: n34\nn34: n35\nn35: n36\nn36: n37\nn37: n38\nn38: n39\nn39: n40\n"
1494
+ };
1495
+ const DEFAULT = {"example": "Paper, Appendix A: base tree (Test.csx)", "data": {"version": "1.8.0", "root": "01", "parents": {"02": "01", "03": "01", "04": "01", "05": "02", "07": "02", "08": "02", "11": "03", "06": "04", "16": "04", "15": "05", "23": "05", "10": "06", "12": "06", "09": "07", "14": "07", "25": "07", "13": "08", "24": "09", "22": "10"}, "steps": [{"line": 1, "op": "01:02", "new": "02", "q1": ["01", "02"], "q2": ["01", "02"], "pos": {"01": ["0", "0"], "02": ["1", "1"]}}, {"line": 2, "op": "01:03", "new": "03", "q1": ["01", "02", "03"], "q2": ["01", "03", "02"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "03": ["2", "1/2"]}}, {"line": 3, "op": "01:04", "new": "04", "q1": ["01", "02", "03", "04"], "q2": ["01", "04", "03", "02"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "03": ["2", "1/2"], "04": ["3", "1/3"]}}, {"line": 4, "op": "02:05", "new": "05", "q1": ["01", "02", "05", "03", "04"], "q2": ["01", "04", "03", "02", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "03": ["2", "1/2"], "04": ["3", "1/3"]}}, {"line": 5, "op": "02:07", "new": "07", "q1": ["01", "02", "05", "07", "03", "04"], "q2": ["01", "04", "03", "02", "07", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "07": ["5/3", "3/2"], "03": ["2", "1/2"], "04": ["3", "1/3"]}}, {"line": 6, "op": "02:08", "new": "08", "q1": ["01", "02", "05", "07", "08", "03", "04"], "q2": ["01", "04", "03", "02", "08", "07", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "04": ["3", "1/3"]}}, {"line": 7, "op": "03:11", "new": "11", "q1": ["01", "02", "05", "07", "08", "03", "11", "04"], "q2": ["01", "04", "03", "11", "02", "08", "07", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"]}}, {"line": 8, "op": "04:06", "new": "06", "q1": ["01", "02", "05", "07", "08", "03", "11", "04", "06"], "q2": ["01", "04", "06", "03", "11", "02", "08", "07", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"]}}, {"line": 9, "op": "04:16", "new": "16", "q1": ["01", "02", "05", "07", "08", "03", "11", "04", "06", "16"], "q2": ["01", "04", "16", "06", "03", "11", "02", "08", "07", "05"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "16": ["5", "3/8"]}}, {"line": 10, "op": "05:15", "new": "15", "q1": ["01", "02", "05", "15", "07", "08", "03", "11", "04", "06", "16"], "q2": ["01", "04", "16", "06", "03", "11", "02", "08", "07", "05", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "16": ["5", "3/8"]}}, {"line": 11, "op": "05:23", "new": "23", "q1": ["01", "02", "05", "15", "23", "07", "08", "03", "11", "04", "06", "16"], "q2": ["01", "04", "16", "06", "03", "11", "02", "08", "07", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "16": ["5", "3/8"]}}, {"line": 12, "op": "06:10", "new": "10", "q1": ["01", "02", "05", "15", "23", "07", "08", "03", "11", "04", "06", "10", "16"], "q2": ["01", "04", "16", "06", "10", "03", "11", "02", "08", "07", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "16": ["5", "3/8"]}}, {"line": 13, "op": "06:12", "new": "12", "q1": ["01", "02", "05", "15", "23", "07", "08", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "07", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 14, "op": "07:09", "new": "09", "q1": ["01", "02", "05", "15", "23", "07", "09", "08", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "07", "09", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 15, "op": "07:14", "new": "14", "q1": ["01", "02", "05", "15", "23", "07", "09", "14", "08", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "07", "14", "09", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "14": ["19/11", "8/5"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 16, "op": "07:25", "new": "25", "q1": ["01", "02", "05", "15", "23", "07", "09", "14", "25", "08", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "07", "25", "14", "09", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "14": ["19/11", "8/5"], "25": ["26/15", "11/7"], "08": ["7/4", "4/3"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 17, "op": "08:13", "new": "13", "q1": ["01", "02", "05", "15", "23", "07", "09", "14", "25", "08", "13", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "13", "07", "25", "14", "09", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "14": ["19/11", "8/5"], "25": ["26/15", "11/7"], "08": ["7/4", "4/3"], "13": ["9/5", "7/5"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 18, "op": "09:24", "new": "24", "q1": ["01", "02", "05", "15", "23", "07", "09", "24", "14", "25", "08", "13", "03", "11", "04", "06", "10", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "03", "11", "02", "08", "13", "07", "25", "14", "09", "24", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "24": ["31/18", "7/4"], "14": ["19/11", "8/5"], "25": ["26/15", "11/7"], "08": ["7/4", "4/3"], "13": ["9/5", "7/5"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, {"line": 19, "op": "10:22", "new": "22", "q1": ["01", "02", "05", "15", "23", "07", "09", "24", "14", "25", "08", "13", "03", "11", "04", "06", "10", "22", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "22", "03", "11", "02", "08", "13", "07", "25", "14", "09", "24", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "24": ["31/18", "7/4"], "14": ["19/11", "8/5"], "25": ["26/15", "11/7"], "08": ["7/4", "4/3"], "13": ["9/5", "7/5"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "22": ["23/5", "4/9"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}], "final": {"q1": ["01", "02", "05", "15", "23", "07", "09", "24", "14", "25", "08", "13", "03", "11", "04", "06", "10", "22", "12", "16"], "q2": ["01", "04", "16", "06", "12", "10", "22", "03", "11", "02", "08", "13", "07", "25", "14", "09", "24", "05", "23", "15"], "pos": {"01": ["0", "0"], "02": ["1", "1"], "05": ["3/2", "2"], "15": ["8/5", "3"], "23": ["13/8", "5/2"], "07": ["5/3", "3/2"], "09": ["12/7", "5/3"], "24": ["31/18", "7/4"], "14": ["19/11", "8/5"], "25": ["26/15", "11/7"], "08": ["7/4", "4/3"], "13": ["9/5", "7/5"], "03": ["2", "1/2"], "11": ["5/2", "2/3"], "04": ["3", "1/3"], "06": ["4", "2/5"], "10": ["9/2", "3/7"], "22": ["23/5", "4/9"], "12": ["14/3", "5/12"], "16": ["5", "3/8"]}}, "stepwise": true, "limit": 300, "renumber": [0, 0], "errors": []}};
1496
+
1497
+ // ---- small helpers --------------------------------------------------------------------
1498
+ const $ = (s) => document.querySelector(s);
1499
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1500
+ const store = {
1501
+ get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
1502
+ set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* storage blocked */ } },
1503
+ };
1504
+ const COLORS = {
1505
+ ink: "#26211c", muted: "#6e645a", paper: "#ffffff", line: "#e7ddd2", edge: "#bfb2a4",
1506
+ accent: "#b4521a", accentSoft: "#fbe1cf", anc: "#2d68ad", ancSoft: "#e1ebf7", desc: "#2b8152", descSoft: "#dff1e6",
1507
+ };
1508
+ const FONT = 'Segoe UI, Helvetica Neue, Helvetica, Arial, sans-serif';
1509
+
1510
+ // ---- state ----------------------------------------------------------------------------
1511
+ const state = { data: null, step: 0, sel: [], collapsed: new Set(), mode: "pick", timer: null, example: null };
1512
+ let backend = null; // { encode(text) -> Promise<data>, label }
1513
+
1514
+ function current() {
1515
+ const d = state.data;
1516
+ if (!d || !d.steps.length) return null;
1517
+ const s = d.stepwise ? d.steps[state.step] : d.final;
1518
+ const r1 = new Map(s.q1.map((v, i) => [v, i]));
1519
+ const r2 = new Map(s.q2.map((v, i) => [v, i]));
1520
+ const kids = new Map();
1521
+ for (const v of s.q1) {
1522
+ const p = d.parents[v];
1523
+ if (p != null && r1.has(p)) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(v); }
1524
+ }
1525
+ const stepInfo = d.stepwise ? d.steps[state.step] : d.steps[d.steps.length - 1];
1526
+ return { s, r1, r2, kids, root: s.q1[0], newNode: d.stepwise ? stepInfo.new : null, stepInfo };
1527
+ }
1528
+ const reaches = (C, u, v) => C.r1.get(u) < C.r1.get(v) && C.r2.get(u) < C.r2.get(v);
1529
+
1530
+ function role(C, v) {
1531
+ const [a, b] = state.sel;
1532
+ if (v === a) return "sel1";
1533
+ if (v === b) return "sel2";
1534
+ if (a != null && state.sel.length === 1) {
1535
+ if (reaches(C, v, a)) return "anc";
1536
+ if (reaches(C, a, v)) return "desc";
1537
+ }
1538
+ if (v === C.newNode) return "new";
1539
+ return "";
1540
+ }
1541
+ const STYLE = {
1542
+ "": { fill: COLORS.paper, stroke: COLORS.ink, text: COLORS.ink, w: 1.2 },
1543
+ new: { fill: COLORS.accentSoft, stroke: COLORS.accent, text: COLORS.ink, w: 2.2 },
1544
+ anc: { fill: COLORS.ancSoft, stroke: COLORS.anc, text: COLORS.ink, w: 2 },
1545
+ desc: { fill: COLORS.descSoft, stroke: COLORS.desc, text: COLORS.ink, w: 2 },
1546
+ sel1: { fill: COLORS.accent, stroke: COLORS.accent, text: "#ffffff", w: 2 },
1547
+ sel2: { fill: COLORS.ink, stroke: COLORS.ink, text: "#ffffff", w: 2 },
1548
+ };
1549
+
1550
+ // visible nodes (not inside a collapsed subtree) and hidden counts
1551
+ function visibility(C) {
1552
+ const visible = new Set(), hiddenCount = new Map();
1553
+ const stack = [C.root];
1554
+ while (stack.length) {
1555
+ const v = stack.pop();
1556
+ visible.add(v);
1557
+ const ks = C.kids.get(v) || [];
1558
+ if (state.collapsed.has(v)) {
1559
+ let n = 0; const st = [...ks];
1560
+ while (st.length) { const w = st.pop(); n++; st.push(...(C.kids.get(w) || [])); }
1561
+ hiddenCount.set(v, n);
1562
+ } else {
1563
+ for (let i = ks.length - 1; i >= 0; i--) stack.push(ks[i]);
1564
+ }
1565
+ }
1566
+ return { visible, hiddenCount };
1567
+ }
1568
+
1569
+ // ---- tree picture ---------------------------------------------------------------------
1570
+ function labelWidth(v) { return Math.max(30, 8 * String(v).length + 14); }
1571
+
1572
+ function treeSVG(C) {
1573
+ const { visible, hiddenCount } = visibility(C);
1574
+ const maxW = Math.max(...[...visible].map(labelWidth));
1575
+ const colW = maxW + 10, rowH = 58, pad = 24;
1576
+ const pos = new Map();
1577
+ let slot = 0, depthMax = 0;
1578
+ // iterative post-order: leaves get consecutive slots in Q1 order, parents sit centered
1579
+ const stack = [[C.root, 0, false]];
1580
+ while (stack.length) {
1581
+ const [v, depth, done] = stack.pop();
1582
+ depthMax = Math.max(depthMax, depth);
1583
+ const ks = state.collapsed.has(v) ? [] : (C.kids.get(v) || []);
1584
+ if (!ks.length) { pos.set(v, { x: slot++, y: depth }); continue; }
1585
+ if (done) { pos.set(v, { x: (pos.get(ks[0]).x + pos.get(ks[ks.length - 1]).x) / 2, y: depth }); continue; }
1586
+ stack.push([v, depth, true]);
1587
+ for (let i = ks.length - 1; i >= 0; i--) stack.push([ks[i], depth + 1, false]);
1588
+ }
1589
+ const W = Math.max(slot * colW + 2 * pad, 260), H = (depthMax + 1) * rowH + 2 * pad - 20;
1590
+ const X = (v) => pad + pos.get(v).x * colW + colW / 2, Y = (v) => pad + pos.get(v).y * rowH + 14;
1591
+ let edges = "", nodes = "";
1592
+ for (const v of visible) {
1593
+ if (state.collapsed.has(v)) continue;
1594
+ for (const k of C.kids.get(v) || []) edges += `<line x1="${X(v)}" y1="${Y(v) + 13}" x2="${X(k)}" y2="${Y(k) - 13}" stroke="${COLORS.edge}" stroke-width="1.4"/>`;
1595
+ }
1596
+ for (const v of visible) {
1597
+ const st = STYLE[role(C, v)], w = labelWidth(v), x = X(v), y = Y(v);
1598
+ const coll = state.collapsed.has(v);
1599
+ nodes += `<g data-node="${esc(v)}" style="cursor:pointer"><title>${esc(v)}${coll ? ` (collapsed, ${hiddenCount.get(v)} hidden)` : ""}</title>`;
1600
+ if (coll) nodes += `<rect x="${x - w / 2 - 4}" y="${y - 17}" width="${w + 8}" height="34" rx="10" fill="none" stroke="${st.stroke}" stroke-width="1.2" stroke-dasharray="3 3"/>`;
1601
+ nodes += `<rect x="${x - w / 2}" y="${y - 13}" width="${w}" height="26" rx="7" fill="${st.fill}" stroke="${st.stroke}" stroke-width="${st.w}"/>`;
1602
+ nodes += `<text x="${x}" y="${y + 4.5}" text-anchor="middle" font-family="${FONT}" font-size="13" fill="${st.text}">${esc(v)}</text>`;
1603
+ if (coll) nodes += `<text x="${x}" y="${y + 30}" text-anchor="middle" font-family="${FONT}" font-size="11" fill="${COLORS.muted}">+${hiddenCount.get(v)}</text>`;
1604
+ nodes += `</g>`;
1605
+ }
1606
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="Tree drawing"><rect width="100%" height="100%" fill="${COLORS.paper}"/>${edges}${nodes}</svg>`;
1607
+ }
1608
+
1609
+ // ---- plane picture --------------------------------------------------------------------
1610
+ function planeSVG(C) {
1611
+ const n = C.s.q1.length;
1612
+ const cell = Math.max(14, Math.min(40, Math.floor(620 / Math.max(n, 1))));
1613
+ const labels = cell >= 18;
1614
+ const L = 64, B = 22, T = 52, R = 30; // the Q1 axis is labeled at the top
1615
+ const W = L + n * cell + R, H = T + n * cell + B;
1616
+ const X = (i) => L + i * cell + cell / 2, Y = (j) => T + j * cell + cell / 2; // Q2 rank grows downward
1617
+ const { visible } = visibility(C);
1618
+ let g = `<rect width="100%" height="100%" fill="${COLORS.paper}"/>`;
1619
+ g += `<rect x="${L}" y="${T}" width="${n * cell}" height="${n * cell}" fill="none" stroke="${COLORS.line}"/>`;
1620
+ const every = Math.max(1, Math.ceil(28 / cell));
1621
+ for (let i = 0; i < n; i += every) {
1622
+ g += `<line x1="${X(i)}" y1="${T}" x2="${X(i)}" y2="${T + n * cell}" stroke="${COLORS.line}" stroke-width="0.6"/>`;
1623
+ g += `<line x1="${L}" y1="${Y(i)}" x2="${L + n * cell}" y2="${Y(i)}" stroke="${COLORS.line}" stroke-width="0.6"/>`;
1624
+ g += `<text x="${X(i)}" y="${T - 8}" text-anchor="middle" font-family="${FONT}" font-size="10" fill="${COLORS.muted}">${i + 1}</text>`;
1625
+ g += `<text x="${L - 8}" y="${Y(i) + 3.5}" text-anchor="end" font-family="${FONT}" font-size="10" fill="${COLORS.muted}">${i + 1}</text>`;
1626
+ }
1627
+ g += `<text x="${L + (n * cell) / 2}" y="18" text-anchor="middle" font-family="${FONT}" font-size="12" fill="${COLORS.ink}">rank in Q1 &#8594;</text>`;
1628
+ g += `<text transform="translate(16 ${T + (n * cell) / 2}) rotate(90)" text-anchor="middle" font-family="${FONT}" font-size="12" fill="${COLORS.ink}">rank in Q2 &#8594;</text>`; // turned clockwise: reads downward, the arrow points down
1629
+ const a = state.sel[0];
1630
+ if (a != null && C.r1.has(a)) {
1631
+ const ax = X(C.r1.get(a)), ay = Y(C.r2.get(a));
1632
+ g += `<rect x="${L}" y="${T}" width="${ax - L}" height="${ay - T}" fill="${COLORS.ancSoft}" opacity="0.6"/>`;
1633
+ g += `<rect x="${ax}" y="${ay}" width="${L + n * cell - ax}" height="${T + n * cell - ay}" fill="${COLORS.descSoft}" opacity="0.6"/>`;
1634
+ g += `<line x1="${ax}" y1="${T}" x2="${ax}" y2="${T + n * cell}" stroke="${COLORS.accent}" stroke-dasharray="4 3"/>`;
1635
+ g += `<line x1="${L}" y1="${ay}" x2="${L + n * cell}" y2="${ay}" stroke="${COLORS.accent}" stroke-dasharray="4 3"/>`;
1636
+ // quadrant names next to the picked node: upper left reach it, lower right are reached by it
1637
+ const q = (x, y, anchor, color, text) => `<text x="${x}" y="${y}" text-anchor="${anchor}" font-family="${FONT}" font-size="11" fill="${color}" stroke="${COLORS.paper}" stroke-width="3" paint-order="stroke">${text}</text>`;
1638
+ g += q(ax - 8, ay - 11, "end", COLORS.anc, "reach it");
1639
+ g += q(ax + 8, ay + 19, "start", COLORS.desc, "reached by it");
1640
+ g += q(ax - 8, ay + 19, "end", COLORS.muted, "before it");
1641
+ g += q(ax + 8, ay - 11, "start", COLORS.muted, "after it");
1642
+ }
1643
+ for (const v of C.s.q1) {
1644
+ const st = STYLE[role(C, v)], x = X(C.r1.get(v)), y = Y(C.r2.get(v));
1645
+ const r = Math.max(4, Math.min(8, cell / 3));
1646
+ const op = visible.has(v) ? 1 : 0.35;
1647
+ g += `<g data-node="${esc(v)}" style="cursor:pointer" opacity="${op}"><title>${esc(v)}: Q1 rank ${C.r1.get(v) + 1}, Q2 rank ${C.r2.get(v) + 1}</title>`;
1648
+ g += `<circle cx="${x}" cy="${y}" r="${r + 4}" fill="transparent"/>`;
1649
+ g += `<circle cx="${x}" cy="${y}" r="${r}" fill="${st.fill === COLORS.paper ? COLORS.ink : st.fill}" stroke="${st.stroke}" stroke-width="${st.w}"/>`;
1650
+ if (labels) g += `<text x="${x + r + 4}" y="${y + 4}" font-family="${FONT}" font-size="11" fill="${COLORS.ink}" stroke="${COLORS.paper}" stroke-width="3" paint-order="stroke">${esc(v)}</text>`;
1651
+ g += `</g>`;
1652
+ }
1653
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="img" aria-label="Plane: rank in Q1 against rank in Q2">${g}</svg>`;
1654
+ }
1655
+
1656
+ // ---- lists ----------------------------------------------------------------------------
1657
+ function listsHTML(C) {
1658
+ const { visible } = visibility(C);
1659
+ const row = (name, list, k) => {
1660
+ let h = `<div class="qrow"><span class="qname">${name}</span>`;
1661
+ for (const v of list) {
1662
+ const cls = [role(C, v), visible.has(v) ? "" : "hid"].join(" ").trim();
1663
+ h += `<span class="cell ${cls}" data-node="${esc(v)}" title="${esc(v)} at position ${esc(C.s.pos[v][k])}">${esc(v)}<small>${esc(C.s.pos[v][k])}</small></span>`;
1664
+ }
1665
+ return h + `</div>`;
1666
+ };
1667
+ return row("Q1", C.s.q1, 0) + row("Q2", C.s.q2, 1);
1668
+ }
1669
+
1670
+ // ---- query panel ----------------------------------------------------------------------
1671
+ function queryHTML(C) {
1672
+ const [a, b] = state.sel;
1673
+ if (a == null) {
1674
+ return state.mode === "pick"
1675
+ ? "Pick a node to see which nodes reach it and which it reaches. Pick a second node to ask whether the first reaches it."
1676
+ : "Click a node with children to collapse it; click it again to expand it.";
1677
+ }
1678
+ const nm = (v) => `<b>${esc(v)}</b>`;
1679
+ if (b == null) {
1680
+ let anc = 0, desc = 0;
1681
+ for (const v of C.s.q1) { if (v !== a) { if (reaches(C, v, a)) anc++; else if (reaches(C, a, v)) desc++; } }
1682
+ return `${nm(a)}: reached by ${anc} node${anc === 1 ? "" : "s"}, reaches ${desc}. Q1 rank ${C.r1.get(a) + 1}, Q2 rank ${C.r2.get(a) + 1}. In the plane, the nodes that reach it lie upper left, the nodes it reaches lower right.`;
1683
+ }
1684
+ const i1 = C.r1.get(a), j1 = C.r1.get(b), i2 = C.r2.get(a), j2 = C.r2.get(b);
1685
+ const yes = i1 < j1 && i2 < j2;
1686
+ const cmp = (q, i, j) => `${q}: ${nm(a)} at rank ${i + 1}, ${nm(b)} at rank ${j + 1} ${i < j ? "(before &#10003;)" : "(after &#10007;)"}`;
1687
+ return `Does ${nm(a)} reach ${nm(b)}? <span class="answer ${yes ? "yes" : "no"}">${yes ? "Yes" : "No"}</span> &middot; ${cmp("Q1", i1, j1)} &middot; ${cmp("Q2", i2, j2)}`;
1688
+ }
1689
+
1690
+ // ---- rendering ------------------------------------------------------------------------
1691
+ function render() {
1692
+ const d = state.data;
1693
+ const C = current();
1694
+ const total = d ? d.steps.length : 0;
1695
+ const slider = $("#step");
1696
+ slider.max = Math.max(0, total - 1);
1697
+ slider.value = state.step;
1698
+ slider.disabled = !d || !d.stepwise || total < 2;
1699
+ $("#prev").disabled = slider.disabled || state.step === 0;
1700
+ $("#next").disabled = slider.disabled || state.step >= total - 1;
1701
+ $("#play").disabled = slider.disabled;
1702
+ if (!C) {
1703
+ $("#steplabel").textContent = "No tree yet: enter at least one edge, e.g. A: B";
1704
+ for (const id of ["#tree", "#plane", "#lists"]) $(id).innerHTML = "";
1705
+ $("#query").textContent = "";
1706
+ return;
1707
+ }
1708
+ const si = C.stepInfo;
1709
+ $("#steplabel").innerHTML = d.stepwise
1710
+ ? `Step ${state.step + 1} of ${total}: <b>${esc(si.op)}</b> <span style="color:var(--muted)">(line ${si.line})</span> &middot; ${C.s.q1.length} nodes`
1711
+ : `Final tree: ${C.s.q1.length} nodes. Step by step is available up to ${d.limit} insertions.`;
1712
+ $("#stats").textContent = `${d.final.q1.length} nodes · renumberings Q1 / Q2: ${d.renumber[0]} / ${d.renumber[1]}`;
1713
+ if ($("#v-tree").checked) $("#tree").innerHTML = treeSVG(C);
1714
+ if ($("#v-plane").checked) $("#plane").innerHTML = planeSVG(C);
1715
+ if ($("#v-lists").checked) $("#lists").innerHTML = listsHTML(C);
1716
+ $("#p-tree").hidden = !$("#v-tree").checked;
1717
+ $("#p-plane").hidden = !$("#v-plane").checked;
1718
+ $("#p-lists").hidden = !$("#v-lists").checked;
1719
+ $("#tree-note").textContent = state.collapsed.size ? `${state.collapsed.size} collapsed` : "children in Q1 order";
1720
+ $("#query").innerHTML = queryHTML(C);
1721
+ }
1722
+
1723
+ function setData(data) {
1724
+ stopPlay();
1725
+ state.data = data;
1726
+ state.step = data.stepwise ? Math.max(0, data.steps.length - 1) : 0;
1727
+ state.sel = [];
1728
+ state.collapsed.clear();
1729
+ const errs = $("#errors");
1730
+ errs.innerHTML = data.errors.map((e) => `<li>Line ${e.line}: ${esc(e.msg)}</li>`).join("");
1731
+ errs.hidden = !data.errors.length;
1732
+ render();
1733
+ }
1734
+
1735
+ function pruneToStep() {
1736
+ const C = current();
1737
+ if (!C) return;
1738
+ state.sel = state.sel.filter((v) => C.r1.has(v));
1739
+ for (const v of [...state.collapsed]) if (!C.r1.has(v)) state.collapsed.delete(v);
1740
+ }
1741
+
1742
+ function goStep(k) {
1743
+ const d = state.data;
1744
+ if (!d || !d.stepwise) return;
1745
+ state.step = Math.max(0, Math.min(d.steps.length - 1, k));
1746
+ pruneToStep();
1747
+ render();
1748
+ }
1749
+
1750
+ function stopPlay() {
1751
+ if (state.timer) { clearInterval(state.timer); state.timer = null; $("#play").textContent = "Play"; }
1752
+ }
1753
+
1754
+ // ---- events ---------------------------------------------------------------------------
1755
+ function onNodeClick(v) {
1756
+ const C = current();
1757
+ if (!C || !C.r1.has(v)) return;
1758
+ if (state.mode === "pick") {
1759
+ if (!state.sel.length || state.sel.length === 2) state.sel = [v];
1760
+ else if (state.sel[0] === v) state.sel = [];
1761
+ else { state.sel = [state.sel[0], v]; track("node_query"); }
1762
+ } else {
1763
+ if (!(C.kids.get(v) || []).length) return;
1764
+ if (state.collapsed.has(v)) state.collapsed.delete(v); else { state.collapsed.add(v); track("collapse"); }
1765
+ }
1766
+ render();
1767
+ }
1768
+
1769
+ function wire() {
1770
+ const sel = $("#example");
1771
+ sel.innerHTML = Object.keys(EXAMPLES).map((k) => `<option value="${esc(k)}">${esc(k)}</option>`).join("");
1772
+ sel.value = DEFAULT.example;
1773
+ sel.addEventListener("change", () => {
1774
+ state.example = sel.value;
1775
+ $("#text").value = EXAMPLES[sel.value];
1776
+ track("example_loaded", { example: sel.value });
1777
+ runEncode();
1778
+ });
1779
+ $("#file").addEventListener("change", (ev) => {
1780
+ const f = ev.target.files && ev.target.files[0];
1781
+ if (!f) return;
1782
+ const reader = new FileReader();
1783
+ reader.onload = () => { $("#text").value = String(reader.result); state.example = null; track("own_file"); runEncode(); };
1784
+ reader.readAsText(f);
1785
+ });
1786
+ $("#encode").addEventListener("click", () => {
1787
+ if (state.example && $("#text").value !== EXAMPLES[state.example]) state.example = null; // edited: no longer the example
1788
+ track("encode_text");
1789
+ runEncode();
1790
+ });
1791
+ $("#text").addEventListener("keydown", (ev) => { if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") runEncode(); });
1792
+ $("#step").addEventListener("input", (ev) => { stopPlay(); goStep(+ev.target.value); });
1793
+ $("#prev").addEventListener("click", () => { stopPlay(); goStep(state.step - 1); });
1794
+ $("#next").addEventListener("click", () => { stopPlay(); goStep(state.step + 1); });
1795
+ $("#play").addEventListener("click", () => {
1796
+ if (state.timer) { stopPlay(); return; }
1797
+ const d = state.data;
1798
+ if (state.step >= d.steps.length - 1) goStep(0);
1799
+ $("#play").textContent = "Pause";
1800
+ state.timer = setInterval(() => {
1801
+ if (state.step >= state.data.steps.length - 1) { stopPlay(); return; }
1802
+ goStep(state.step + 1);
1803
+ }, 800);
1804
+ });
1805
+ for (const id of ["#v-tree", "#v-plane", "#v-lists"]) $(id).addEventListener("change", render);
1806
+ const setMode = (m) => {
1807
+ state.mode = m;
1808
+ $("#m-pick").setAttribute("aria-pressed", String(m === "pick"));
1809
+ $("#m-collapse").setAttribute("aria-pressed", String(m === "collapse"));
1810
+ render();
1811
+ };
1812
+ $("#m-pick").addEventListener("click", () => setMode("pick"));
1813
+ $("#m-collapse").addEventListener("click", () => setMode("collapse"));
1814
+ document.addEventListener("click", (ev) => {
1815
+ const el = ev.target.closest("[data-node]");
1816
+ if (el) onNodeClick(el.getAttribute("data-node"));
1817
+ });
1818
+ document.querySelectorAll("[data-save]").forEach((b) => b.addEventListener("click", () => save(b.dataset.save, b.dataset.fmt)));
1819
+ }
1820
+
1821
+ // ---- saving pictures ------------------------------------------------------------------
1822
+ function download(blob, name) {
1823
+ const url = URL.createObjectURL(blob);
1824
+ const a = document.createElement("a");
1825
+ a.href = url; a.download = name;
1826
+ document.body.appendChild(a); a.click(); a.remove();
1827
+ setTimeout(() => URL.revokeObjectURL(url), 4000);
1828
+ }
1829
+ function save(which, fmt) {
1830
+ const svg = document.querySelector(`#${which} svg`);
1831
+ if (!svg) return;
1832
+ const text = '<?xml version="1.0" encoding="UTF-8"?>\n' + svg.outerHTML;
1833
+ const name = `dtree-${which}${state.example ? "-" + state.example.replace(/\W+/g, "_") : ""}`;
1834
+ track("save_picture", { picture: which, format: fmt });
1835
+ if (fmt === "svg") { download(new Blob([text], { type: "image/svg+xml" }), name + ".svg"); return; }
1836
+ const w = +svg.getAttribute("width"), h = +svg.getAttribute("height"), scale = 2;
1837
+ const img = new Image();
1838
+ const url = URL.createObjectURL(new Blob([text], { type: "image/svg+xml" }));
1839
+ img.onload = () => {
1840
+ const c = document.createElement("canvas");
1841
+ c.width = w * scale; c.height = h * scale;
1842
+ const ctx = c.getContext("2d");
1843
+ ctx.scale(scale, scale); ctx.drawImage(img, 0, 0);
1844
+ URL.revokeObjectURL(url);
1845
+ c.toBlob((b) => b && download(b, name + ".png"), "image/png");
1846
+ };
1847
+ img.src = url;
1848
+ }
1849
+
1850
+ // ---- computing: Pyodide with the published package -------------------------------------
1851
+ function setStatus(kind, text) {
1852
+ const s = $("#status");
1853
+ s.className = "status " + (kind || "");
1854
+ $("#statustext").innerHTML = text;
1855
+ }
1856
+
1857
+ async function runEncode() {
1858
+ if (!backend) { setStatus("", "The Python runtime is still loading; the tree is computed as soon as it is ready."); return; }
1859
+ try {
1860
+ const data = await backend.encode($("#text").value);
1861
+ setData(data);
1862
+ } catch (e) {
1863
+ setStatus("error", "Could not encode: " + esc(e.message || e));
1864
+ }
1865
+ }
1866
+
1867
+ function loadScript(src) {
1868
+ return new Promise((resolve, reject) => {
1869
+ const s = document.createElement("script");
1870
+ s.src = src; s.onload = resolve; s.onerror = () => reject(new Error("could not load " + src));
1871
+ document.head.appendChild(s);
1872
+ });
1873
+ }
1874
+
1875
+ // started with the dtree-explorer command: a local server computes with the installed library
1876
+ async function startLocal() {
1877
+ if (!/^https?:$/.test(location.protocol) || !["127.0.0.1", "localhost"].includes(location.hostname)) return null;
1878
+ try {
1879
+ const r = await fetch("api/info", { cache: "no-store" });
1880
+ if (!r.ok) return null;
1881
+ const info = await r.json();
1882
+ return {
1883
+ label: `dtree-encoding ${info.dtree} (installed) on Python ${info.python}, local`,
1884
+ local: true,
1885
+ encode: async (text) => {
1886
+ const res = await fetch("api/encode", { method: "POST", headers: { "Content-Type": "text/plain; charset=utf-8" }, body: text });
1887
+ if (!res.ok) throw new Error("local server answered " + res.status);
1888
+ return res.json();
1889
+ },
1890
+ };
1891
+ } catch (e) {
1892
+ return null;
1893
+ }
1894
+ }
1895
+
1896
+ async function startPyodide() {
1897
+ setStatus("", "Loading the Python runtime (Pyodide)&hellip; The example on the right was computed in advance.");
1898
+ await loadScript(CONFIG.pyodideURL + "pyodide.js");
1899
+ const py = await loadPyodide({ indexURL: CONFIG.pyodideURL });
1900
+ let source = "from PyPI";
1901
+ try {
1902
+ setStatus("", "Installing dtree-encoding " + CONFIG.dtreeVersion + " from PyPI&hellip;");
1903
+ await py.loadPackage("micropip");
1904
+ await py.runPythonAsync(`import micropip\nawait micropip.install("dtree-encoding==${CONFIG.dtreeVersion}")`);
1905
+ } catch (e) {
1906
+ source = "built-in copy"; // PyPI unreachable: the identical source shipped with this page
1907
+ py.FS.mkdirTree("/home/pyodide/dtree");
1908
+ py.FS.writeFile("/home/pyodide/dtree/__init__.py", $("#src-init").textContent);
1909
+ py.FS.writeFile("/home/pyodide/dtree/dtree.py", $("#src-dtree").textContent);
1910
+ }
1911
+ py.FS.writeFile("/home/pyodide/explorer_bridge.py", $("#src-bridge").textContent);
1912
+ py.runPython('import sys\nif "/home/pyodide" not in sys.path: sys.path.insert(0, "/home/pyodide")');
1913
+ const encode = py.runPython("import explorer_bridge\nexplorer_bridge.encode");
1914
+ const version = py.runPython("import dtree\ndtree.__version__");
1915
+ return {
1916
+ label: `dtree-encoding ${version} (${source}) on Pyodide ${py.version}`,
1917
+ encode: async (text) => JSON.parse(encode(text)),
1918
+ };
1919
+ }
1920
+
1921
+ async function boot() {
1922
+ wire();
1923
+ $("#text").value = EXAMPLES[DEFAULT.example];
1924
+ state.example = DEFAULT.example;
1925
+ setData(DEFAULT.data);
1926
+ try {
1927
+ backend = window.EXPLORER_BACKEND || await startLocal() || await startPyodide();
1928
+ $("#encode").disabled = false;
1929
+ setStatus("ready", "Ready: " + esc(backend.label) + ". Every tree on this page is now computed by the library" + (backend.local ? " on your machine." : " in your browser."));
1930
+ await runEncode();
1931
+ } catch (e) {
1932
+ setStatus("error", "The Python runtime could not be loaded (" + esc(e.message || e) + "). The precomputed example stays visible; check the network connection and reload.");
1933
+ }
1934
+ }
1935
+
1936
+ // ---- visit counting, only after consent ------------------------------------------------
1937
+ function track(name, params) { if (typeof window.gtag === "function") window.gtag("event", name, params || {}); }
1938
+ function startAnalytics() {
1939
+ if (!CONFIG.gaId || window.gtag || location.protocol === "file:") return; // local copies are not counted
1940
+ window.dataLayer = window.dataLayer || [];
1941
+ window.gtag = function () { window.dataLayer.push(arguments); };
1942
+ window.gtag("js", new Date());
1943
+ window.gtag("config", CONFIG.gaId, { anonymize_ip: true });
1944
+ const s = document.createElement("script");
1945
+ s.async = true; s.src = "https://www.googletagmanager.com/gtag/js?id=" + encodeURIComponent(CONFIG.gaId);
1946
+ document.head.appendChild(s);
1947
+ }
1948
+ function consent() {
1949
+ if (!CONFIG.gaId) return; // no visit counting in this copy: no banner
1950
+ $("#privacy-link").href = CONFIG.privacyURL;
1951
+ const c = store.get("dtree-explorer-consent");
1952
+ if (c === "yes") { startAnalytics(); return; }
1953
+ if (c === "no") return;
1954
+ $("#consent").hidden = false;
1955
+ $("#c-yes").addEventListener("click", () => { store.set("dtree-explorer-consent", "yes"); $("#consent").hidden = true; startAnalytics(); });
1956
+ $("#c-no").addEventListener("click", () => { store.set("dtree-explorer-consent", "no"); $("#consent").hidden = true; });
1957
+ }
1958
+
1959
+ consent();
1960
+ boot();
1961
+ </script>
1962
+ </body>
1963
+ </html>