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.
- dtree_explorer/__init__.py +3 -0
- dtree_explorer/__main__.py +105 -0
- dtree_explorer/bridge.py +87 -0
- dtree_explorer/explorer.html +1963 -0
- dtree_explorer-1.0.0.dist-info/METADATA +74 -0
- dtree_explorer-1.0.0.dist-info/RECORD +10 -0
- dtree_explorer-1.0.0.dist-info/WHEEL +5 -0
- dtree_explorer-1.0.0.dist-info/entry_points.txt +2 -0
- dtree_explorer-1.0.0.dist-info/licenses/LICENSE.md +21 -0
- dtree_explorer-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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/">← 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 “Paper” are taken from E. Olgac, <a href="https://doi.org/10.5772/intechopen.1013408">“Topology and Structure of Directed Trees: An Incremental Encoding,” 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 · <code>s > c</code> c directly after sibling s · <code>s < c</code> c directly before s · 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…</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">“Topology and Structure of Directed Trees: An Incremental Encoding”</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">◀</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">▶</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> · computed by <a href="https://pypi.org/project/dtree-encoding/">dtree-encoding</a> (MIT) · <a href="https://github.com/EnisOlgac/DTree-Explorer">source on GitHub</a> · <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 © 2026 by Ihsan Enis Olgac, Bö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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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 →</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 ✓)" : "(after ✗)"}`;
|
|
1687
|
+
return `Does ${nm(a)} reach ${nm(b)}? <span class="answer ${yes ? "yes" : "no"}">${yes ? "Yes" : "No"}</span> · ${cmp("Q1", i1, j1)} · ${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> · ${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)… 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…");
|
|
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>
|