@polycode-projects/the-mechanical-code-talker 1.5.5 → 1.8.3
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.
- package/README.md +123 -14
- package/ROADMAP.md +233 -1392
- package/bin/tmct.mjs +479 -98
- package/corpus/README.md +3 -0
- package/corpus/generated/README.md +43 -0
- package/corpus/generated/ace-surface-variants.jsonl +17 -0
- package/corpus/generated/manifest.json +9 -0
- package/corpus/tier2/generate.mjs +14668 -0
- package/corpus/tier2/human-examples-large.jsonl +1928 -0
- package/corpus/tier2/human-examples-medium.jsonl +356 -0
- package/corpus/tier2/human-examples.jsonl +120 -0
- package/corpus/tier2/human-large.jsonl +12001 -0
- package/corpus/tier2/human-medium.jsonl +944 -0
- package/corpus/tier2/human.jsonl +664 -0
- package/corpus/tier2/manifest.json +42 -0
- package/package.json +14 -8
- package/src/answer-variants.json +47 -0
- package/src/answer-variants.mjs +67 -0
- package/src/ask-browser-entry.mjs +34 -0
- package/src/ask-browser.bundle.js +5095 -0
- package/src/ask-vocab.mjs +93 -8
- package/src/ask.mjs +451 -49
- package/src/chat.mjs +1273 -137
- package/src/cli-args.mjs +164 -0
- package/src/codegraph.mjs +170 -32
- package/src/extensions.mjs +100 -19
- package/src/grammar/ace.mjs +85 -3
- package/src/grammar/lexicon-core.json +9531 -63
- package/src/grammar/lexicon.mjs +58 -8
- package/src/graph-merge.mjs +114 -0
- package/src/index.mjs +14 -0
- package/src/init.mjs +40 -14
- package/src/interpret/normalize.mjs +75 -1
- package/src/interpret/strategies/grammar.mjs +10 -0
- package/src/interpret/strategies/keywords.mjs +20 -0
- package/src/interpret/strategies/noise-strip.mjs +73 -4
- package/src/memory/core.mjs +466 -8
- package/src/router/goal-reasoner.mjs +41 -7
- package/src/router/guardrail.mjs +37 -7
- package/src/router/resolver.mjs +50 -4
- package/src/sessions.mjs +5 -1
- package/src/source.mjs +54 -1
- package/src/syllogise.mjs +398 -27
- package/src/toml-config.mjs +13 -4
- package/src/viz.mjs +541 -0
package/src/viz.mjs
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
// viz.mjs — `tmct viz`: a real, navigable, self-contained HTML graph view over
|
|
2
|
+
// the memory graph (PLAN_BREADTH_FIRST_NLU.md §5, design per PLAN_VIZ.md), now
|
|
3
|
+
// with a real "Ask the graph" chat panel running tmct's OWN engine client-side
|
|
4
|
+
// (§5 follow-on, operator directive 2026-07-11 — precedent: seonix's own
|
|
5
|
+
// site/viz.mjs + scripts/build-ask-bundle.mjs, adapted here to bundle tmct's
|
|
6
|
+
// ask.mjs directly rather than an external package import).
|
|
7
|
+
//
|
|
8
|
+
// Three pure/impure-separated pieces, mirroring src/syllogise.mjs's shape:
|
|
9
|
+
// - computeVizGraph(repoDir, {focus}) — I/O (loadMemory) + graph traversal,
|
|
10
|
+
// reusing spiralExpand/mostRecentIndividual/MEMORY_SPIRAL_EXPAND_KINDS/
|
|
11
|
+
// buildVizNodesAndEdges exactly as PLAN_VIZ.md's own traversal work
|
|
12
|
+
// already generalized them for this. No new traversal logic here.
|
|
13
|
+
// - renderVizHtml({nodes, edges, focus, payload, askBundle}) — a pure
|
|
14
|
+
// string-builder: one complete <!doctype html> document, graph data
|
|
15
|
+
// JSON-embedded inline, the real ask-engine bundle inlined verbatim, no
|
|
16
|
+
// external <script src>, no CDN, no fonts — opens and works offline.
|
|
17
|
+
// - readAskBundle() — the one bit of I/O renderVizHtml itself doesn't do:
|
|
18
|
+
// reads the checked-in build artifact (scripts/build-ask-bundle.mjs's
|
|
19
|
+
// output). bin/tmct.mjs's `viz` mode wires all three together.
|
|
20
|
+
|
|
21
|
+
import { loadMemory, CREATED_AT_PROP } from "./memory/core.mjs";
|
|
22
|
+
import { parseEntities, spiralExpand, mostRecentIndividual, MEMORY_SPIRAL_EXPAND_KINDS, buildVizNodesAndEdges } from "./codegraph.mjs";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** Load the memory graph under `repoDir`, walk it from a seed (an explicit
|
|
28
|
+
* `--focus <id>` override, or the most-recently-created individual by
|
|
29
|
+
* default) via spiralExpand over the real memory-graph edge-kind inventory,
|
|
30
|
+
* and enrich each walked node with the real label/class/timestamp data a
|
|
31
|
+
* renderer needs. Returns `{nodes, edges, focus, payload}` — `focus` is the
|
|
32
|
+
* seed id actually used (null when the graph is empty and no seed could be
|
|
33
|
+
* picked); `payload` is the FULL raw graph (every individual, not just the
|
|
34
|
+
* walked subset) — the embedded "Ask the graph" panel queries the whole
|
|
35
|
+
* graph and can re-walk from a new focus, never just the initially rendered
|
|
36
|
+
* subgraph, mirroring seonix's own "never the depth-limited display
|
|
37
|
+
* sub-graph" precedent. Never throws on a missing/empty memory dir:
|
|
38
|
+
* loadMemory's own ENOENT fallback (emptyMemory()) already degrades to zero
|
|
39
|
+
* individuals, which this function turns into
|
|
40
|
+
* `{nodes: [], edges: [], focus: null, payload}`. */
|
|
41
|
+
export async function computeVizGraph(repoDir, { focus } = {}) {
|
|
42
|
+
const payload = await loadMemory(repoDir);
|
|
43
|
+
const graph = parseEntities(payload);
|
|
44
|
+
if (!graph.individuals.length) return { nodes: [], edges: [], focus: null, payload };
|
|
45
|
+
|
|
46
|
+
const seedId = focus || mostRecentIndividual(graph, CREATED_AT_PROP)?.id || null;
|
|
47
|
+
if (!seedId) return { nodes: [], edges: [], focus: null, payload };
|
|
48
|
+
|
|
49
|
+
const walked = spiralExpand(graph, [], {
|
|
50
|
+
kinds: MEMORY_SPIRAL_EXPAND_KINDS,
|
|
51
|
+
classPredicate: () => true,
|
|
52
|
+
idNormalizer: (id) => id,
|
|
53
|
+
seeds: [seedId],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const { nodes, edges } = buildVizNodesAndEdges(graph, walked);
|
|
57
|
+
return { nodes, edges, focus: seedId, payload };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the checked-in browser ask-engine bundle (scripts/build-ask-bundle.mjs's
|
|
61
|
+
* output, `src/ask-browser.bundle.js`) — the one I/O `renderVizHtml` itself
|
|
62
|
+
* stays free of, keeping it a pure string-builder. Returns `""` (never
|
|
63
|
+
* throws) if the bundle hasn't been built yet — `renderVizHtml` renders a
|
|
64
|
+
* graph-only page with an honest "chat unavailable" note in that case,
|
|
65
|
+
* rather than a broken page. */
|
|
66
|
+
export async function readAskBundle() {
|
|
67
|
+
try {
|
|
68
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
69
|
+
return await readFile(join(here, "ask-browser.bundle.js"), "utf8");
|
|
70
|
+
} catch {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Escape untrusted text for safe placement inside HTML content/attributes. */
|
|
76
|
+
function escapeHtml(s) {
|
|
77
|
+
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** JSON-embed graph data into a `<script>` tag safely — escape `</` so a
|
|
81
|
+
* label/id containing "</script>" can't break out of the tag, and escape
|
|
82
|
+
* U+2028/U+2029 (valid in JSON strings, invalid unescaped in JS source). */
|
|
83
|
+
function embedJson(value) {
|
|
84
|
+
return JSON.stringify(value)
|
|
85
|
+
.replace(/</g, "\\u003c")
|
|
86
|
+
.replace(/\u2028/g, "\\u2028")
|
|
87
|
+
.replace(/\u2029/g, "\\u2029");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Render one complete, self-contained `<!doctype html>` document for
|
|
91
|
+
* `{nodes, edges, focus, payload, askBundle}` (computeVizGraph's return
|
|
92
|
+
* shape, plus the ask-engine bundle text from readAskBundle()): the graph
|
|
93
|
+
* data JSON-embedded inline, the real ask.mjs engine inlined verbatim
|
|
94
|
+
* (`askBundle`, adapter-less — no wink model, ~220KB, still answers via the
|
|
95
|
+
* curated + bounded-fuzzy tiers, exactly test/ask-nlp.test.mjs's own proven
|
|
96
|
+
* "viewer bundle without wink" boundary), inline <style>, inline vanilla-JS
|
|
97
|
+
* implementing a concentric ring layout keyed on hop (PLAN_VIZ.md §4 —
|
|
98
|
+
* seed/newest at the centre, each ring one hop further out), paint-order-by-
|
|
99
|
+
* hop with a lightness/opacity falloff for the depth read, pan (drag) + zoom
|
|
100
|
+
* (wheel), click-a-node for details, a depth stepper + per-class visibility
|
|
101
|
+
* filters (operator directive, seonix precedent), and a real "Ask the graph"
|
|
102
|
+
* chat panel — a query resolves via the SAME ask() the CLI ships, re-walks
|
|
103
|
+
* the graph from the resolved entity (focus-follows-answer), and a node's
|
|
104
|
+
* class/label in the detail panel are click-to-query affordances. Pure
|
|
105
|
+
* string building — no fs/network, no external <script src>, no CDN, no
|
|
106
|
+
* fonts. */
|
|
107
|
+
export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
|
|
108
|
+
const graphJson = embedJson({ nodes, edges, focus });
|
|
109
|
+
const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
|
|
110
|
+
const title = `tmct viz — ${nodes.length} node${nodes.length === 1 ? "" : "s"}${focus ? ` (seed: ${escapeHtml(focus)})` : ""}`;
|
|
111
|
+
const hasChat = Boolean(askBundle);
|
|
112
|
+
|
|
113
|
+
return `<!doctype html>
|
|
114
|
+
<html lang="en">
|
|
115
|
+
<head>
|
|
116
|
+
<meta charset="utf-8">
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
118
|
+
<title>${escapeHtml(title)}</title>
|
|
119
|
+
<style>
|
|
120
|
+
:root { color-scheme: light dark; }
|
|
121
|
+
html, body { margin: 0; padding: 0; height: 100%; background: #0b0d12; color: #e7e9ee; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
|
|
122
|
+
#wrap { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
|
|
123
|
+
canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
124
|
+
canvas.grabbing { cursor: grabbing; }
|
|
125
|
+
#hud { position: absolute; top: 12px; left: 12px; max-width: 42ch; background: rgba(20,22,30,0.82); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.45; pointer-events: none; }
|
|
126
|
+
#hud b { color: #fff; }
|
|
127
|
+
#hud .muted { color: #9aa1b0; }
|
|
128
|
+
#controls { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 7px 12px; font-size: 12.5px; flex-wrap: wrap; max-width: min(70vw, 640px); }
|
|
129
|
+
#controls .grp { display: flex; align-items: center; gap: 5px; }
|
|
130
|
+
#controls button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; width: 22px; height: 22px; line-height: 1; cursor: pointer; font-size: 13px; }
|
|
131
|
+
#controls button:hover:not(:disabled) { background: rgba(255,255,255,0.18); }
|
|
132
|
+
#controls button:disabled { opacity: 0.35; cursor: default; }
|
|
133
|
+
#controls .depthval { min-width: 1.4em; text-align: center; display: inline-block; }
|
|
134
|
+
#controls label.typechk { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
|
|
135
|
+
#controls label.typechk:hover { background: rgba(255,255,255,0.08); }
|
|
136
|
+
#controls .swatch { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
137
|
+
#panel { position: absolute; top: 12px; right: 12px; width: 280px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; display: none; }
|
|
138
|
+
#panel.show { display: block; }
|
|
139
|
+
#panel h2 { margin: 0 0 6px; font-size: 14px; word-break: break-word; }
|
|
140
|
+
#panel dl { margin: 8px 0 0; }
|
|
141
|
+
#panel dt { color: #9aa1b0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 6px; }
|
|
142
|
+
#panel dd { margin: 0; word-break: break-word; }
|
|
143
|
+
#panel dd a, #panel dd button.lnk { color: #7aa2f7; text-decoration: none; cursor: pointer; background: none; border: none; padding: 0; font: inherit; }
|
|
144
|
+
#panel dd a:hover, #panel dd button.lnk:hover { text-decoration: underline; }
|
|
145
|
+
#panel .row { display: flex; gap: 8px; margin-top: 10px; }
|
|
146
|
+
#panel button.act { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.18); color: #e7e9ee; border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
|
|
147
|
+
#panel button.act:hover { background: rgba(255,255,255,0.16); }
|
|
148
|
+
#empty { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; text-align: center; padding: 24px; }
|
|
149
|
+
#empty.show { display: flex; }
|
|
150
|
+
#empty div { max-width: 46ch; color: #9aa1b0; }
|
|
151
|
+
#empty b { color: #e7e9ee; }
|
|
152
|
+
#ask { position: absolute; bottom: 12px; right: 12px; width: 340px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; }
|
|
153
|
+
#ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
154
|
+
#ask .row { display: flex; gap: 6px; }
|
|
155
|
+
#askq { flex: 1; min-width: 0; background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 6px 9px; font: inherit; font-size: 12.5px; }
|
|
156
|
+
#askq:focus { outline: none; border-color: #7aa2f7; }
|
|
157
|
+
#askq:disabled { opacity: 0.6; }
|
|
158
|
+
#asksubmit { background: rgba(122,162,247,0.18); border: 1px solid #7aa2f7; color: #cfe0ff; border-radius: 5px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; }
|
|
159
|
+
#asksubmit:hover { background: rgba(122,162,247,0.3); }
|
|
160
|
+
#askresult { margin-top: 8px; max-height: 32vh; overflow: auto; line-height: 1.55; color: #c0caf5; white-space: pre-wrap; }
|
|
161
|
+
#askresult .q { color: #565f89; font-style: normal; margin-bottom: 3px; }
|
|
162
|
+
#askresult.miss { color: #a9b1d6; font-style: italic; }
|
|
163
|
+
#askresult .canon { margin-top: 6px; color: #6b7189; font-size: 11px; font-style: normal; border-top: 1px dashed rgba(255,255,255,0.1); padding-top: 5px; }
|
|
164
|
+
#ask .hint { color: #6b7189; font-size: 11px; }
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<div id="wrap">
|
|
169
|
+
<canvas id="c"></canvas>
|
|
170
|
+
<div id="hud"><b>tmct viz</b><br><span class="muted">drag to pan · scroll to zoom · click a node for details · double-click to re-centre</span></div>
|
|
171
|
+
<div id="controls">
|
|
172
|
+
<span class="grp"><span class="muted">depth</span><button id="depthdown" title="shallower">−</button><b class="depthval" id="depthval"></b><button id="depthup" title="deeper">+</button></span>
|
|
173
|
+
<span class="grp" id="typefilters"></span>
|
|
174
|
+
</div>
|
|
175
|
+
<div id="panel"></div>
|
|
176
|
+
<div id="empty"><div><b>No graph data.</b><br>This repo has no <code>.tmct/memory/graph.json</code> yet (or the requested <code>--focus</code> id wasn't found). Run <code>tmct chat</code> in that repo first, then re-run <code>tmct viz</code>.</div></div>
|
|
177
|
+
<div id="ask">
|
|
178
|
+
<h3>Ask the graph</h3>
|
|
179
|
+
<div class="row"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "where is X mentioned"'${hasChat ? "" : " disabled"}><button id="asksubmit"${hasChat ? "" : " disabled"}>ask</button></div>
|
|
180
|
+
<div id="askresult">${hasChat
|
|
181
|
+
? '<span class="hint">running the real tmct engine, client-side, right here — try "where is <label> mentioned". Answers re-centre the graph on what they resolve.</span>'
|
|
182
|
+
: '<span class="hint">chat unavailable — run <code>npm run build:ask-bundle</code> and re-generate this page.</span>'}</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
<script>
|
|
186
|
+
const GRAPH = ${graphJson};
|
|
187
|
+
const PAYLOAD = ${payloadJson};
|
|
188
|
+
</script>
|
|
189
|
+
${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
190
|
+
<script>
|
|
191
|
+
(function () {
|
|
192
|
+
"use strict";
|
|
193
|
+
|
|
194
|
+
var emptyEl = document.getElementById("empty");
|
|
195
|
+
if (!GRAPH.nodes.length) { emptyEl.classList.add("show"); return; }
|
|
196
|
+
|
|
197
|
+
var hasEngine = typeof tmctViz !== "undefined";
|
|
198
|
+
var FULL_GRAPH = hasEngine ? tmctViz.parseEntities(PAYLOAD) : null;
|
|
199
|
+
|
|
200
|
+
// ---- palette: one hue per class, stable across recentres --------------
|
|
201
|
+
var PALETTE = ["#7aa2f7", "#bb9af7", "#7dcfff", "#9ece6a", "#e0af68", "#f7768e", "#73daca"];
|
|
202
|
+
var classColor = new Map();
|
|
203
|
+
function colorFor(cls) {
|
|
204
|
+
if (!classColor.has(cls)) classColor.set(cls, PALETTE[classColor.size % PALETTE.length]);
|
|
205
|
+
return classColor.get(cls);
|
|
206
|
+
}
|
|
207
|
+
// seed the palette + type-filter checkboxes from every class in the FULL
|
|
208
|
+
// graph (not just the currently-walked subset), so filters stay stable
|
|
209
|
+
// across a recentre that reveals a class not in the initial view.
|
|
210
|
+
var allClasses = [];
|
|
211
|
+
(function () {
|
|
212
|
+
var seen = new Set();
|
|
213
|
+
(FULL_GRAPH ? FULL_GRAPH.individuals : GRAPH.nodes).forEach(function (n) {
|
|
214
|
+
var cls = n.class || (n.cls || "");
|
|
215
|
+
if (cls && !seen.has(cls)) { seen.add(cls); allClasses.push(cls); }
|
|
216
|
+
});
|
|
217
|
+
allClasses.sort();
|
|
218
|
+
allClasses.forEach(colorFor);
|
|
219
|
+
})();
|
|
220
|
+
|
|
221
|
+
var enabledTypes = new Set(allClasses);
|
|
222
|
+
var typeFiltersEl = document.getElementById("typefilters");
|
|
223
|
+
function renderTypeFilters() {
|
|
224
|
+
typeFiltersEl.innerHTML = allClasses.map(function (cls) {
|
|
225
|
+
return '<label class="typechk" data-cls="' + cls + '" title="show/hide ' + cls + ' nodes">'
|
|
226
|
+
+ '<input type="checkbox" checked><span class="swatch" style="background:' + colorFor(cls) + '"></span>' + cls
|
|
227
|
+
+ '</label>';
|
|
228
|
+
}).join("");
|
|
229
|
+
typeFiltersEl.querySelectorAll("label.typechk").forEach(function (lbl) {
|
|
230
|
+
lbl.querySelector("input").addEventListener("change", function (ev) {
|
|
231
|
+
var cls = lbl.dataset.cls;
|
|
232
|
+
if (ev.target.checked) enabledTypes.add(cls); else enabledTypes.delete(cls);
|
|
233
|
+
applyFilters();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
renderTypeFilters();
|
|
238
|
+
|
|
239
|
+
// ---- layout: concentric rings keyed on hop, seed at the centre, RE-runnable on recentre ----
|
|
240
|
+
var RING_GAP = 110;
|
|
241
|
+
var pos = new Map();
|
|
242
|
+
var byHopMax = 0;
|
|
243
|
+
function relayout() {
|
|
244
|
+
pos = new Map();
|
|
245
|
+
var byHop = new Map();
|
|
246
|
+
GRAPH.nodes.forEach(function (n) {
|
|
247
|
+
if (!byHop.has(n.hop)) byHop.set(n.hop, []);
|
|
248
|
+
byHop.get(n.hop).push(n);
|
|
249
|
+
});
|
|
250
|
+
byHop.forEach(function (list) { list.sort(function (a, b) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; }); });
|
|
251
|
+
byHop.forEach(function (list, hop) {
|
|
252
|
+
var r = hop * RING_GAP, n = list.length;
|
|
253
|
+
list.forEach(function (node, i) {
|
|
254
|
+
if (r === 0) { pos.set(node.id, { x: 0, y: 0 }); return; }
|
|
255
|
+
var angle = (2 * Math.PI * i) / n + hop * 0.35;
|
|
256
|
+
pos.set(node.id, { x: r * Math.cos(angle), y: r * Math.sin(angle) });
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
byHopMax = 0;
|
|
260
|
+
GRAPH.nodes.forEach(function (n) { if (n.hop > byHopMax) byHopMax = n.hop; });
|
|
261
|
+
}
|
|
262
|
+
relayout();
|
|
263
|
+
|
|
264
|
+
// ---- depth stepper: hides nodes with hop > depthVal (client-side visibility only,
|
|
265
|
+
// no re-walk needed — spiralExpand already computed every hop up to the walk's own depth) ----
|
|
266
|
+
var depthVal = byHopMax;
|
|
267
|
+
function syncDepthUi() {
|
|
268
|
+
document.getElementById("depthval").textContent = depthVal;
|
|
269
|
+
document.getElementById("depthdown").disabled = depthVal <= 0;
|
|
270
|
+
document.getElementById("depthup").disabled = depthVal >= byHopMax;
|
|
271
|
+
}
|
|
272
|
+
function visibleNodeIds() {
|
|
273
|
+
var vis = new Set();
|
|
274
|
+
GRAPH.nodes.forEach(function (n) {
|
|
275
|
+
if (n.hop <= depthVal && enabledTypes.has(n.class)) vis.add(n.id);
|
|
276
|
+
});
|
|
277
|
+
return vis;
|
|
278
|
+
}
|
|
279
|
+
function applyFilters() { syncDepthUi(); draw(); }
|
|
280
|
+
document.getElementById("depthdown").addEventListener("click", function () { if (depthVal > 0) { depthVal--; applyFilters(); } });
|
|
281
|
+
document.getElementById("depthup").addEventListener("click", function () { if (depthVal < byHopMax) { depthVal++; applyFilters(); } });
|
|
282
|
+
|
|
283
|
+
// ---- depth encoding: paint-order-by-hop + lightness/opacity falloff -----
|
|
284
|
+
function styleForHop(hop, cls) {
|
|
285
|
+
var t = byHopMax > 0 ? hop / byHopMax : 0;
|
|
286
|
+
var alpha = 1 - t * 0.55;
|
|
287
|
+
var radius = Math.max(4, 9 - t * 5);
|
|
288
|
+
return { fill: colorFor(cls), alpha: alpha, radius: radius };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- canvas + view transform (pan/zoom) ----------------------------------
|
|
292
|
+
var canvas = document.getElementById("c");
|
|
293
|
+
var ctx = canvas.getContext("2d");
|
|
294
|
+
var dpr = window.devicePixelRatio || 1;
|
|
295
|
+
var view = { scale: 1, x: 0, y: 0 };
|
|
296
|
+
var selectedId = null;
|
|
297
|
+
|
|
298
|
+
function resize() {
|
|
299
|
+
canvas.width = Math.floor(canvas.clientWidth * dpr);
|
|
300
|
+
canvas.height = Math.floor(canvas.clientHeight * dpr);
|
|
301
|
+
draw();
|
|
302
|
+
}
|
|
303
|
+
window.addEventListener("resize", resize);
|
|
304
|
+
|
|
305
|
+
function worldToScreen(p) {
|
|
306
|
+
var cx = canvas.width / 2 + view.x * dpr, cy = canvas.height / 2 + view.y * dpr;
|
|
307
|
+
return { x: cx + p.x * view.scale * dpr, y: cy + p.y * view.scale * dpr };
|
|
308
|
+
}
|
|
309
|
+
function screenToWorld(sx, sy) {
|
|
310
|
+
var cx = canvas.width / 2 + view.x * dpr, cy = canvas.height / 2 + view.y * dpr;
|
|
311
|
+
return { x: (sx * dpr - cx) / (view.scale * dpr), y: (sy * dpr - cy) / (view.scale * dpr) };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function draw() {
|
|
315
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
316
|
+
ctx.lineWidth = Math.max(1, 1 * dpr);
|
|
317
|
+
var vis = visibleNodeIds();
|
|
318
|
+
ctx.strokeStyle = "rgba(255,255,255,0.14)";
|
|
319
|
+
GRAPH.edges.forEach(function (e) {
|
|
320
|
+
if (!vis.has(e.source) || !vis.has(e.target)) return;
|
|
321
|
+
var a = pos.get(e.source), b = pos.get(e.target);
|
|
322
|
+
if (!a || !b) return;
|
|
323
|
+
var sa = worldToScreen(a), sb = worldToScreen(b);
|
|
324
|
+
ctx.beginPath(); ctx.moveTo(sa.x, sa.y); ctx.lineTo(sb.x, sb.y); ctx.stroke();
|
|
325
|
+
});
|
|
326
|
+
var order = GRAPH.nodes.filter(function (n) { return vis.has(n.id); }).sort(function (a, b) { return a.hop - b.hop; });
|
|
327
|
+
order.forEach(function (n) {
|
|
328
|
+
var p = pos.get(n.id);
|
|
329
|
+
if (!p) return;
|
|
330
|
+
var sp = worldToScreen(p);
|
|
331
|
+
var st = styleForHop(n.hop, n.class);
|
|
332
|
+
var r = st.radius * view.scale * dpr;
|
|
333
|
+
ctx.globalAlpha = st.alpha;
|
|
334
|
+
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r), 0, Math.PI * 2);
|
|
335
|
+
ctx.fillStyle = st.fill; ctx.fill();
|
|
336
|
+
ctx.globalAlpha = 1;
|
|
337
|
+
if (n.id === selectedId) {
|
|
338
|
+
ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#fff"; ctx.stroke();
|
|
339
|
+
}
|
|
340
|
+
if (n.id === GRAPH.focus) {
|
|
341
|
+
ctx.lineWidth = Math.max(1.5, 2.5 * dpr); ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
|
342
|
+
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 3 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
343
|
+
}
|
|
344
|
+
if (view.scale > 0.55) {
|
|
345
|
+
ctx.font = (11 * dpr) + "px -apple-system, sans-serif";
|
|
346
|
+
ctx.fillStyle = "rgba(231,233,238," + Math.min(1, 0.55 + view.scale * 0.3) + ")";
|
|
347
|
+
ctx.textBaseline = "middle";
|
|
348
|
+
ctx.fillText(String(n.label).slice(0, 40), sp.x + Math.max(6, r + 4), sp.y);
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- pan (drag) -----------------------------------------------------------
|
|
354
|
+
var dragging = false, dragMoved = false, dragStart = null, viewStart = null;
|
|
355
|
+
canvas.addEventListener("mousedown", function (ev) {
|
|
356
|
+
dragging = true; dragMoved = false;
|
|
357
|
+
dragStart = { x: ev.clientX, y: ev.clientY }; viewStart = { x: view.x, y: view.y };
|
|
358
|
+
canvas.classList.add("grabbing");
|
|
359
|
+
});
|
|
360
|
+
window.addEventListener("mousemove", function (ev) {
|
|
361
|
+
if (!dragging) return;
|
|
362
|
+
var dx = ev.clientX - dragStart.x, dy = ev.clientY - dragStart.y;
|
|
363
|
+
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
|
|
364
|
+
view.x = viewStart.x + dx; view.y = viewStart.y + dy;
|
|
365
|
+
draw();
|
|
366
|
+
});
|
|
367
|
+
window.addEventListener("mouseup", function () { dragging = false; canvas.classList.remove("grabbing"); });
|
|
368
|
+
|
|
369
|
+
// ---- zoom (wheel), anchored at the pointer --------------------------------
|
|
370
|
+
canvas.addEventListener("wheel", function (ev) {
|
|
371
|
+
ev.preventDefault();
|
|
372
|
+
var rect = canvas.getBoundingClientRect();
|
|
373
|
+
var mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
|
374
|
+
var before = screenToWorld(mx, my);
|
|
375
|
+
var factor = Math.exp(-ev.deltaY * 0.001);
|
|
376
|
+
view.scale = Math.min(8, Math.max(0.08, view.scale * factor));
|
|
377
|
+
var after = screenToWorld(mx, my);
|
|
378
|
+
view.x += (after.x - before.x) * view.scale; view.y += (after.y - before.y) * view.scale;
|
|
379
|
+
draw();
|
|
380
|
+
}, { passive: false });
|
|
381
|
+
|
|
382
|
+
function fitToVisible() {
|
|
383
|
+
var vis = Array.from(visibleNodeIds()).map(function (id) { return pos.get(id); }).filter(Boolean);
|
|
384
|
+
if (!vis.length) return;
|
|
385
|
+
var minX = Math.min.apply(null, vis.map(function (p) { return p.x; })), maxX = Math.max.apply(null, vis.map(function (p) { return p.x; }));
|
|
386
|
+
var minY = Math.min.apply(null, vis.map(function (p) { return p.y; })), maxY = Math.max.apply(null, vis.map(function (p) { return p.y; }));
|
|
387
|
+
var w = Math.max(1, maxX - minX), h = Math.max(1, maxY - minY);
|
|
388
|
+
view.scale = Math.min(4, Math.max(0.1, Math.min(canvas.width / dpr / (w + 160), canvas.height / dpr / (h + 160))));
|
|
389
|
+
view.x = -(minX + maxX) / 2 * view.scale; view.y = -(minY + maxY) / 2 * view.scale;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- recentre: RE-WALK the FULL graph from a new seed via the real,
|
|
393
|
+
// bundled spiralExpand (byte-identical to the CLI's own walk — never a
|
|
394
|
+
// hand-rolled client-side BFS) and rebuild via the real buildVizNodesAndEdges.
|
|
395
|
+
// Used for double-click-to-recentre AND focus-follows-chat-answer. -------
|
|
396
|
+
function recentre(id) {
|
|
397
|
+
if (!hasEngine || !FULL_GRAPH || !FULL_GRAPH.byId.has(id)) return false;
|
|
398
|
+
var walked = tmctViz.spiralExpand(FULL_GRAPH, [], {
|
|
399
|
+
classPredicate: function () { return true; }, idNormalizer: function (i) { return i; }, seeds: [id],
|
|
400
|
+
});
|
|
401
|
+
var built = tmctViz.buildVizNodesAndEdges(FULL_GRAPH, walked);
|
|
402
|
+
GRAPH.nodes = built.nodes; GRAPH.edges = built.edges; GRAPH.focus = id;
|
|
403
|
+
// classes newly reached that weren't in the initial palette still resolve
|
|
404
|
+
// via colorFor()'s own on-demand assignment; the checkbox row itself was
|
|
405
|
+
// already seeded from the FULL graph's classes above, so no rebuild needed.
|
|
406
|
+
relayout();
|
|
407
|
+
depthVal = byHopMax;
|
|
408
|
+
fitToVisible();
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---- click-to-inspect ------------------------------------------------------
|
|
413
|
+
var panel = document.getElementById("panel");
|
|
414
|
+
function fmtTs(v) { return v ? String(v) : "(none)"; }
|
|
415
|
+
function esc(s) {
|
|
416
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
417
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function showPanel(n) {
|
|
422
|
+
selectedId = n.id;
|
|
423
|
+
panel.innerHTML =
|
|
424
|
+
"<h2>" + esc(n.label) + "</h2>" +
|
|
425
|
+
"<dl>" +
|
|
426
|
+
"<dt>id</dt><dd>" + esc(n.id) + "</dd>" +
|
|
427
|
+
"<dt>class</dt><dd>" + (n.class
|
|
428
|
+
? '<button class="lnk" id="classLink" title="isolate this class in the view, and ask the graph where the term appears">' + esc(n.class) + "</button>"
|
|
429
|
+
: "(none)") + "</dd>" +
|
|
430
|
+
"<dt>hop</dt><dd>" + n.hop + "</dd>" +
|
|
431
|
+
"<dt>created</dt><dd>" + esc(fmtTs(n.createdAt)) + "</dd>" +
|
|
432
|
+
"<dt>updated</dt><dd>" + esc(fmtTs(n.updatedAt)) + "</dd>" +
|
|
433
|
+
"</dl>" +
|
|
434
|
+
'<div class="row">' +
|
|
435
|
+
'<button class="act" id="labelLink" title="ask the graph where this specific label is mentioned">search this</button>' +
|
|
436
|
+
'<button class="act" id="panelClose">close</button>' +
|
|
437
|
+
"</div>";
|
|
438
|
+
panel.classList.add("show");
|
|
439
|
+
document.getElementById("panelClose").addEventListener("click", function () {
|
|
440
|
+
panel.classList.remove("show"); selectedId = null; draw();
|
|
441
|
+
});
|
|
442
|
+
// Class badge: a click-to-query affordance (operator directive) — isolate
|
|
443
|
+
// this class in the type-filter row (a REAL "show all of that kind
|
|
444
|
+
// currently in view" action; ask.mjs has no generic "list all X of class
|
|
445
|
+
// Y" shape for memory-graph classes — that richer machinery lives in
|
|
446
|
+
// chat.mjs's factAnswer cascade, out of this bundle's ask.mjs-only scope,
|
|
447
|
+
// see PLAN_BREADTH_FIRST_NLU.md §5's own build notes) AND fire a real
|
|
448
|
+
// "where is X mentioned" query on the class name — an honest attempt,
|
|
449
|
+
// may miss, never faked.
|
|
450
|
+
var classLink = document.getElementById("classLink");
|
|
451
|
+
if (classLink) classLink.addEventListener("click", function () {
|
|
452
|
+
typeFiltersEl.querySelectorAll("label.typechk").forEach(function (lbl) {
|
|
453
|
+
var on = lbl.dataset.cls === n.class;
|
|
454
|
+
lbl.querySelector("input").checked = on;
|
|
455
|
+
if (on) enabledTypes.add(lbl.dataset.cls); else enabledTypes.delete(lbl.dataset.cls);
|
|
456
|
+
});
|
|
457
|
+
applyFilters();
|
|
458
|
+
askAndPopulate('where is "' + n.class + '" mentioned');
|
|
459
|
+
});
|
|
460
|
+
document.getElementById("labelLink").addEventListener("click", function () {
|
|
461
|
+
askAndPopulate('where is "' + n.label + '" mentioned');
|
|
462
|
+
});
|
|
463
|
+
draw();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
canvas.addEventListener("click", function (ev) {
|
|
467
|
+
if (dragMoved) return;
|
|
468
|
+
var rect = canvas.getBoundingClientRect();
|
|
469
|
+
var w = screenToWorld(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
470
|
+
var vis = visibleNodeIds();
|
|
471
|
+
var best = null, bestDist = Infinity;
|
|
472
|
+
GRAPH.nodes.forEach(function (n) {
|
|
473
|
+
if (!vis.has(n.id)) return;
|
|
474
|
+
var p = pos.get(n.id);
|
|
475
|
+
if (!p) return;
|
|
476
|
+
var dx = p.x - w.x, dy = p.y - w.y, d = Math.sqrt(dx * dx + dy * dy);
|
|
477
|
+
var st = styleForHop(n.hop, n.class);
|
|
478
|
+
var hitR = Math.max(st.radius, 10) / view.scale;
|
|
479
|
+
if (d <= hitR && d < bestDist) { best = n; bestDist = d; }
|
|
480
|
+
});
|
|
481
|
+
if (best) showPanel(best);
|
|
482
|
+
});
|
|
483
|
+
canvas.addEventListener("dblclick", function (ev) {
|
|
484
|
+
var rect = canvas.getBoundingClientRect();
|
|
485
|
+
var w = screenToWorld(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
486
|
+
var vis = visibleNodeIds();
|
|
487
|
+
var best = null, bestDist = Infinity;
|
|
488
|
+
GRAPH.nodes.forEach(function (n) {
|
|
489
|
+
if (!vis.has(n.id)) return;
|
|
490
|
+
var p = pos.get(n.id);
|
|
491
|
+
if (!p) return;
|
|
492
|
+
var dx = p.x - w.x, dy = p.y - w.y, d = Math.sqrt(dx * dx + dy * dy);
|
|
493
|
+
if (d < bestDist) { best = n; bestDist = d; }
|
|
494
|
+
});
|
|
495
|
+
if (best && recentre(best.id)) { selectedId = best.id; showPanel(GRAPH.nodes.filter(function (n) { return n.id === best.id; })[0]); }
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ---- Ask the graph: a real ask()-powered chat panel over the FULL graph,
|
|
499
|
+
// never just the currently-displayed subgraph. A resolved answer's own
|
|
500
|
+
// objMatch/matches re-centre the view — focus follows the answer. ---------
|
|
501
|
+
var askInput = document.getElementById("askq");
|
|
502
|
+
var askBtn = document.getElementById("asksubmit");
|
|
503
|
+
var askOut = document.getElementById("askresult");
|
|
504
|
+
|
|
505
|
+
function runAsk(query) {
|
|
506
|
+
if (!hasEngine) return;
|
|
507
|
+
askOut.classList.remove("miss");
|
|
508
|
+
var t = tmctViz.ask(FULL_GRAPH, query, { contextId: selectedId });
|
|
509
|
+
var envelope = t.tmct_ask || {};
|
|
510
|
+
askOut.classList.toggle("miss", !!envelope.miss);
|
|
511
|
+
var canon = envelope.canonical
|
|
512
|
+
? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
|
|
513
|
+
: "";
|
|
514
|
+
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>' + esc(t.content) + canon;
|
|
515
|
+
// Focus-follows-answer: prefer the resolved objMatch (the term the
|
|
516
|
+
// question was actually ABOUT), else the first real match — either way,
|
|
517
|
+
// only if it's a genuine individual in the graph, never a guess.
|
|
518
|
+
var targetId = (envelope.parsed && envelope.parsed.object && (envelope.matches || [])[0] && envelope.matches[0].id)
|
|
519
|
+
|| (envelope.matches && envelope.matches[0] && envelope.matches[0].id)
|
|
520
|
+
|| null;
|
|
521
|
+
if (targetId && recentre(targetId)) { selectedId = targetId; }
|
|
522
|
+
draw();
|
|
523
|
+
}
|
|
524
|
+
function askAndPopulate(query) {
|
|
525
|
+
askInput.value = query;
|
|
526
|
+
runAsk(query);
|
|
527
|
+
}
|
|
528
|
+
if (hasEngine) {
|
|
529
|
+
askBtn.addEventListener("click", function () { var q = askInput.value.trim(); if (q) runAsk(q); });
|
|
530
|
+
askInput.addEventListener("keydown", function (ev) { if (ev.key === "Enter") { var q = askInput.value.trim(); if (q) runAsk(q); } });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
syncDepthUi();
|
|
534
|
+
resize();
|
|
535
|
+
draw();
|
|
536
|
+
})();
|
|
537
|
+
</script>
|
|
538
|
+
</body>
|
|
539
|
+
</html>
|
|
540
|
+
`;
|
|
541
|
+
}
|