@polycode-projects/the-mechanical-code-talker 1.8.20 → 1.9.1
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 +27 -6
- package/ROADMAP.md +1 -1
- package/bin/tmct.mjs +167 -20
- package/corpus/generated/README.md +2 -2
- package/corpus/namenet/LICENSE-NOTICE +68 -0
- package/corpus/namenet/generate.mjs +309 -0
- package/corpus/namenet/manifest.json +20 -0
- package/corpus/namenet/namenet.jsonl +7260 -0
- package/corpus/wordnet/LICENSE-NOTICE +49 -0
- package/corpus/wordnet/generate.mjs +333 -0
- package/corpus/wordnet/manifest.json +34 -0
- package/corpus/wordnet/wordnet-full.jsonl +192498 -0
- package/corpus/wordnet/wordnet-xl.jsonl +23805 -0
- package/package.json +6 -2
- package/src/ask-browser-entry.mjs +13 -2
- package/src/ask-browser.bundle.js +272 -19
- package/src/chat.mjs +229 -82
- package/src/cli-args.mjs +20 -1
- package/src/codegraph.mjs +306 -1
- package/src/corpus/conceptnet-map.toml +17 -12
- package/src/corpus/conceptnet.mjs +23 -1
- package/src/extensions.mjs +44 -1
- package/src/init.mjs +99 -46
- package/src/interpret/normalize.mjs +1 -3
- package/src/memory/core.mjs +191 -14
- package/src/memory/trust.mjs +27 -6
- package/src/memory-ask-browser-entry.mjs +36 -0
- package/src/memory-ask-browser.bundle.js +5544 -0
- package/src/toml-config.mjs +11 -1
- package/src/viz.mjs +548 -73
package/src/viz.mjs
CHANGED
|
@@ -18,43 +18,108 @@
|
|
|
18
18
|
// reads the checked-in build artifact (scripts/build-ask-bundle.mjs's
|
|
19
19
|
// output). bin/tmct.mjs's `viz` mode wires all three together.
|
|
20
20
|
|
|
21
|
-
import { loadMemory, CREATED_AT_PROP } from "./memory/core.mjs";
|
|
22
|
-
import {
|
|
21
|
+
import { loadMemory, CREATED_AT_PROP, normFactTerm } from "./memory/core.mjs";
|
|
22
|
+
import {
|
|
23
|
+
parseEntities, spiralExpand, mostRecentIndividual, MEMORY_SPIRAL_EXPAND_KINDS, MEMORY_FACT_LINK_KINDS,
|
|
24
|
+
buildVizNodesAndEdges, deriveFactTermGraph, pickLegendDimension, edgeKindsFor,
|
|
25
|
+
} from "./codegraph.mjs";
|
|
23
26
|
import { readFile } from "node:fs/promises";
|
|
24
27
|
import { fileURLToPath } from "node:url";
|
|
25
28
|
import { dirname, join } from "node:path";
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
export
|
|
30
|
+
// PLAN_VIZ_MEMORY.md's page-size strategy: seonix's own three-cap default (200)
|
|
31
|
+
// is the starting point, raised from the code-graph-only SPIRAL_NODE_LIMIT_DEFAULT
|
|
32
|
+
// (12 — a MID-tier digest breadth, not a graph-viewer breadth) now that Bug 2's
|
|
33
|
+
// fix makes real concept-relation edges walkable, and re-tuned against a real
|
|
34
|
+
// `init:large`-seeded repo this session (see HANDOVER.md for the measured numbers).
|
|
35
|
+
export const VIZ_NODE_LIMIT_DEFAULT = 300;
|
|
36
|
+
export const VIZ_HUB_DEGREE_DEFAULT = 40; // seonix's own default, ported verbatim (PLAN_VIZ_MEMORY.md)
|
|
37
|
+
export const VIZ_DEPTH_DEFAULT = 3; // mirrors spiralExpand's own SPIRAL_DEPTH_DEFAULT — named here so client-side re-walks embed/reuse the SAME resolved value, never spiralExpand's smaller code-graph defaults
|
|
38
|
+
|
|
39
|
+
// edgeKindsFor now lives in codegraph.mjs (re-exported here for existing
|
|
40
|
+
// importers) — it moved so the browser bundle's client-side re-walk (which
|
|
41
|
+
// can't import viz.mjs itself, a real-fs-I/O module) can share the SAME
|
|
42
|
+
// kind-combination logic instead of a second hand-rolled copy. See
|
|
43
|
+
// codegraph.mjs's own doc comment on edgeKindsFor for the full reasoning.
|
|
44
|
+
export { edgeKindsFor };
|
|
45
|
+
|
|
46
|
+
/** Load the memory graph under `repoDir`, walk it from a seed, and enrich each
|
|
47
|
+
* walked node with the real label/class/timestamp data a renderer needs.
|
|
48
|
+
* Seed precedence: an explicit `--focus <id>`, then `--term <word>` (Bug 2's
|
|
49
|
+
* companion seed strategy — resolves via `normFactTerm` to the synthetic
|
|
50
|
+
* `term:<word>` node deriveFactTermGraph materializes, so `tmct viz --term
|
|
51
|
+
* dog` reaches "dog"'s WHOLE concept neighbourhood — every Fact mentioning
|
|
52
|
+
* it, and everything THOSE facts connect to — without hunting for a raw
|
|
53
|
+
* `fact:<hash>` id first; a term that matches no Fact falls through to the
|
|
54
|
+
* default below rather than seeding a lone phantom node), then
|
|
55
|
+
* `mostRecentIndividual` by default.
|
|
56
|
+
*
|
|
57
|
+
* Bug 2 fix: the walk runs over BOTH the existing meta/provenance kinds
|
|
58
|
+
* (`MEMORY_SPIRAL_EXPAND_KINDS`) AND the real concept-relation kinds
|
|
59
|
+
* (`deriveFactTermGraph`'s per-predicate + link kinds) together by default
|
|
60
|
+
* (`edgeKindMode: "both"`) — a click on "dog" now reaches "animal"/"tail"/
|
|
61
|
+
* "bark" the same way asking about it in chat would surface those same
|
|
62
|
+
* facts, not just its provenance chain. `edgeKindMode: "meta"` reproduces
|
|
63
|
+
* today's exact byte-identical provenance-only walk (never deleted, just no
|
|
64
|
+
* longer the only option); `"relation"` isolates the concept view alone.
|
|
65
|
+
*
|
|
66
|
+
* `hubDegree` (seonix's third cap, PLAN_VIZ_MEMORY.md): stop expanding
|
|
67
|
+
* THROUGH a node with more than this many connections (still shows the hub
|
|
68
|
+
* itself) — without it a common hypernym could swallow the whole node
|
|
69
|
+
* budget in one hop. `VIZ_HUB_DEGREE_DEFAULT` applies when omitted.
|
|
70
|
+
*
|
|
71
|
+
* Returns `{nodes, edges, focus, payload, legend}` — `focus` is the seed id
|
|
72
|
+
* actually used (null when the graph is empty and no seed could be picked);
|
|
73
|
+
* `payload` is the FULL raw graph (every individual, not just the walked
|
|
74
|
+
* subset) — the embedded "Ask the graph" panel queries the whole graph and
|
|
75
|
+
* can re-walk from a new focus, never just the initially rendered subgraph,
|
|
76
|
+
* mirroring seonix's own "never the depth-limited display sub-graph"
|
|
77
|
+
* precedent; `legend` is `pickLegendDimension`'s precomputed output over the
|
|
78
|
+
* walked node set. Never throws on a missing/empty memory dir: loadMemory's
|
|
79
|
+
* own ENOENT fallback (emptyMemory()) already degrades to zero individuals,
|
|
80
|
+
* which this function turns into `{nodes: [], edges: [], focus: null,
|
|
81
|
+
* payload, legend: null}`. */
|
|
82
|
+
export async function computeVizGraph(repoDir, { focus, term, depth, nodeLimit, hubDegree, edgeKindMode = "both" } = {}) {
|
|
42
83
|
const payload = await loadMemory(repoDir);
|
|
43
84
|
const graph = parseEntities(payload);
|
|
44
|
-
|
|
85
|
+
// walkOpts: the RESOLVED options actually used (defaults filled in) — always
|
|
86
|
+
// present, even on an empty/seedless graph, so renderVizHtml can embed them
|
|
87
|
+
// for the client-side re-walk (recentre/edge-kind toggle) to stay consistent
|
|
88
|
+
// with whatever this page was generated with, rather than silently falling
|
|
89
|
+
// back to spiralExpand's own much-smaller code-graph defaults (nodeLimit 12).
|
|
90
|
+
const walkOpts = {
|
|
91
|
+
depth: depth != null ? depth : VIZ_DEPTH_DEFAULT,
|
|
92
|
+
nodeLimit: nodeLimit != null ? nodeLimit : VIZ_NODE_LIMIT_DEFAULT,
|
|
93
|
+
hubDegree: hubDegree != null ? hubDegree : VIZ_HUB_DEGREE_DEFAULT,
|
|
94
|
+
edgeKindMode,
|
|
95
|
+
};
|
|
96
|
+
if (!graph.individuals.length) return { nodes: [], edges: [], focus: null, payload, legend: null, walkOpts };
|
|
45
97
|
|
|
46
|
-
const
|
|
47
|
-
if (!seedId) return { nodes: [], edges: [], focus: null, payload };
|
|
98
|
+
const { graph: augmented, factRelationKinds } = deriveFactTermGraph(graph);
|
|
48
99
|
|
|
49
|
-
|
|
50
|
-
|
|
100
|
+
let seedId = focus || null;
|
|
101
|
+
if (!seedId && term) {
|
|
102
|
+
const termId = `term:${normFactTerm(term)}`;
|
|
103
|
+
if (augmented.byId.has(termId)) seedId = termId;
|
|
104
|
+
// no match: falls through to the default seed below, exactly as if
|
|
105
|
+
// --term had never been given — an honest miss never seeds a phantom node.
|
|
106
|
+
}
|
|
107
|
+
if (!seedId) seedId = mostRecentIndividual(graph, CREATED_AT_PROP)?.id || null;
|
|
108
|
+
if (!seedId) return { nodes: [], edges: [], focus: null, payload, legend: null, walkOpts };
|
|
109
|
+
|
|
110
|
+
const walked = spiralExpand(augmented, [], {
|
|
111
|
+
kinds: edgeKindsFor(edgeKindMode, factRelationKinds),
|
|
51
112
|
classPredicate: () => true,
|
|
52
113
|
idNormalizer: (id) => id,
|
|
53
114
|
seeds: [seedId],
|
|
115
|
+
depth: walkOpts.depth,
|
|
116
|
+
nodeLimit: walkOpts.nodeLimit,
|
|
117
|
+
hubDegree: walkOpts.hubDegree,
|
|
54
118
|
});
|
|
55
119
|
|
|
56
|
-
const { nodes, edges } = buildVizNodesAndEdges(
|
|
57
|
-
|
|
120
|
+
const { nodes, edges } = buildVizNodesAndEdges(augmented, walked);
|
|
121
|
+
const legend = pickLegendDimension(augmented, nodes);
|
|
122
|
+
return { nodes, edges, focus: seedId, payload, legend, walkOpts };
|
|
58
123
|
}
|
|
59
124
|
|
|
60
125
|
/** Read the checked-in browser ask-engine bundle (scripts/build-ask-bundle.mjs's
|
|
@@ -72,6 +137,22 @@ export async function readAskBundle() {
|
|
|
72
137
|
}
|
|
73
138
|
}
|
|
74
139
|
|
|
140
|
+
/** Read the checked-in browser MEMORY-ask-engine bundle
|
|
141
|
+
* (scripts/build-ask-bundle.mjs's second output,
|
|
142
|
+
* `src/memory-ask-browser.bundle.js`) — Bug 1 fix's real memory-graph answer
|
|
143
|
+
* engine (chat.mjs's factAnswer), alongside the pre-existing code-graph
|
|
144
|
+
* ask.mjs bundle readAskBundle() already reads. Same never-throws contract:
|
|
145
|
+
* `""` when the bundle hasn't been built yet, so renderVizHtml degrades to
|
|
146
|
+
* whichever engine (if any) IS present rather than a broken page. */
|
|
147
|
+
export async function readMemoryAskBundle() {
|
|
148
|
+
try {
|
|
149
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
150
|
+
return await readFile(join(here, "memory-ask-browser.bundle.js"), "utf8");
|
|
151
|
+
} catch {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
75
156
|
/** Escape untrusted text for safe placement inside HTML content/attributes. */
|
|
76
157
|
function escapeHtml(s) {
|
|
77
158
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
@@ -104,11 +185,15 @@ function embedJson(value) {
|
|
|
104
185
|
* class/label in the detail panel are click-to-query affordances. Pure
|
|
105
186
|
* string building — no fs/network, no external <script src>, no CDN, no
|
|
106
187
|
* fonts. */
|
|
107
|
-
export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
|
|
188
|
+
export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryAskBundle, legend, walkOpts }) {
|
|
108
189
|
const graphJson = embedJson({ nodes, edges, focus });
|
|
109
190
|
const payloadJson = embedJson(payload || { individuals: [], objectProperties: [] });
|
|
191
|
+
const legendJson = embedJson(legend || null);
|
|
192
|
+
const walkOptsJson = embedJson(walkOpts || { depth: VIZ_DEPTH_DEFAULT, nodeLimit: VIZ_NODE_LIMIT_DEFAULT, hubDegree: VIZ_HUB_DEGREE_DEFAULT, edgeKindMode: "both" });
|
|
110
193
|
const title = `tmct viz — ${nodes.length} node${nodes.length === 1 ? "" : "s"}${focus ? ` (seed: ${escapeHtml(focus)})` : ""}`;
|
|
111
194
|
const hasChat = Boolean(askBundle);
|
|
195
|
+
const hasMemChat = Boolean(memoryAskBundle);
|
|
196
|
+
const hasAnyChat = hasChat || hasMemChat;
|
|
112
197
|
|
|
113
198
|
return `<!doctype html>
|
|
114
199
|
<html lang="en">
|
|
@@ -122,10 +207,12 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
|
|
|
122
207
|
#wrap { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
|
|
123
208
|
canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
|
|
124
209
|
canvas.grabbing { cursor: grabbing; }
|
|
125
|
-
#hud { position: absolute; top: 12px; left: 12px; max-width:
|
|
210
|
+
#hud { position: absolute; top: 12px; left: 12px; max-width: 34ch; 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
211
|
#hud b { color: #fff; }
|
|
127
|
-
#hud .muted { color: #9aa1b0; }
|
|
128
|
-
#
|
|
212
|
+
#hud .muted { color: #9aa1b0; display: block; margin: 4px 0 8px; }
|
|
213
|
+
#hud button { pointer-events: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
|
|
214
|
+
#hud button:hover { background: rgba(255,255,255,0.18); }
|
|
215
|
+
#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(86vw, 900px); }
|
|
129
216
|
#controls .grp { display: flex; align-items: center; gap: 5px; }
|
|
130
217
|
#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
218
|
#controls button:hover:not(:disabled) { background: rgba(255,255,255,0.18); }
|
|
@@ -134,7 +221,18 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
|
|
|
134
221
|
#controls label.typechk { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 4px; }
|
|
135
222
|
#controls label.typechk:hover { background: rgba(255,255,255,0.08); }
|
|
136
223
|
#controls .swatch { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
|
137
|
-
#
|
|
224
|
+
#controls select, #controls input[type="number"], #controls input[type="text"].search { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 2px 5px; font: inherit; font-size: 12px; }
|
|
225
|
+
#controls input[type="number"] { width: 3.6em; }
|
|
226
|
+
#controls input[type="text"].search { width: 9em; }
|
|
227
|
+
#controls .sep { width: 1px; align-self: stretch; background: rgba(255,255,255,0.14); margin: 0 2px; }
|
|
228
|
+
#legend { position: absolute; bottom: 12px; left: 12px; display: none; flex-wrap: wrap; gap: 6px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 6px 10px; font-size: 11.5px; max-width: min(60vw, 640px); }
|
|
229
|
+
#legend.show { display: flex; }
|
|
230
|
+
#legend select { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 1px 4px; font: inherit; font-size: 11px; }
|
|
231
|
+
#legend .chip { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); }
|
|
232
|
+
#legend .chip.off { opacity: 0.4; }
|
|
233
|
+
#legend .chip .swatch { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
|
234
|
+
#legend .chip .n { color: #9aa1b0; }
|
|
235
|
+
#panel { position: absolute; top: 12px; right: 404px; width: 280px; max-width: calc(100vw - 428px); 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
236
|
#panel.show { display: block; }
|
|
139
237
|
#panel h2 { margin: 0 0 6px; font-size: 14px; word-break: break-word; }
|
|
140
238
|
#panel dl { margin: 8px 0 0; }
|
|
@@ -149,44 +247,65 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle }) {
|
|
|
149
247
|
#empty.show { display: flex; }
|
|
150
248
|
#empty div { max-width: 46ch; color: #9aa1b0; }
|
|
151
249
|
#empty b { color: #e7e9ee; }
|
|
152
|
-
#ask { position: absolute; bottom: 12px; right: 12px; width:
|
|
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; }
|
|
250
|
+
#ask { position: absolute; top: 12px; bottom: 12px; right: 12px; width: 380px; 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; display: flex; flex-direction: column; }
|
|
251
|
+
#ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; flex: 0 0 auto; }
|
|
252
|
+
#ask .row { display: flex; gap: 6px; flex: 0 0 auto; }
|
|
155
253
|
#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
254
|
#askq:focus { outline: none; border-color: #7aa2f7; }
|
|
157
255
|
#askq:disabled { opacity: 0.6; }
|
|
158
256
|
#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
257
|
#asksubmit:hover { background: rgba(122,162,247,0.3); }
|
|
160
|
-
#askresult { margin-top: 8px;
|
|
258
|
+
#askresult { margin-top: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; line-height: 1.55; color: #c0caf5; white-space: pre-wrap; }
|
|
161
259
|
#askresult .q { color: #565f89; font-style: normal; margin-bottom: 3px; }
|
|
162
260
|
#askresult.miss { color: #a9b1d6; font-style: italic; }
|
|
163
261
|
#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
|
-
#
|
|
262
|
+
#askresult .src { margin-top: 4px; color: #565f89; font-size: 10.5px; }
|
|
263
|
+
#ask .hint { color: #6b7189; font-size: 11px; flex: 0 0 auto; }
|
|
165
264
|
</style>
|
|
166
265
|
</head>
|
|
167
266
|
<body>
|
|
168
267
|
<div id="wrap">
|
|
169
268
|
<canvas id="c"></canvas>
|
|
170
|
-
<div id="hud"><b>tmct viz</b><
|
|
269
|
+
<div id="hud"><b>tmct viz</b><span class="muted">drag to pan · scroll to zoom · click a node for details · double-click to re-centre</span><button id="resetview" title="reset pan/zoom to fit the current view">reset view</button></div>
|
|
171
270
|
<div id="controls">
|
|
172
271
|
<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
272
|
<span class="grp" id="typefilters"></span>
|
|
273
|
+
<span class="sep"></span>
|
|
274
|
+
<span class="grp"><span class="muted">edges</span><select id="edgekind" title="which kinds of edge the walk follows">
|
|
275
|
+
<option value="both">both (default)</option>
|
|
276
|
+
<option value="relation">concept relations</option>
|
|
277
|
+
<option value="meta">provenance only</option>
|
|
278
|
+
</select></span>
|
|
279
|
+
<span class="grp"><label title="hide nodes above N connections outright"><input type="checkbox" id="hubhideon"> hub-hide ></label><input type="number" id="hubhideval" min="1"></span>
|
|
280
|
+
<span class="grp"><label title="keep only the top-N neighbours by degree per hop"><input type="checkbox" id="beamon"> beam-prune</label><input type="number" id="beamval" min="1"></span>
|
|
281
|
+
<span class="grp"><span class="muted">labels</span><select id="labelmode">
|
|
282
|
+
<option value="smart">smart</option>
|
|
283
|
+
<option value="all">all names</option>
|
|
284
|
+
<option value="name-source">name + source</option>
|
|
285
|
+
<option value="none">none</option>
|
|
286
|
+
</select></span>
|
|
287
|
+
<span class="sep"></span>
|
|
288
|
+
<span class="grp"><input type="text" class="search" id="search" placeholder="search labels…" autocomplete="off"><b id="searchcount" class="muted"></b></span>
|
|
174
289
|
</div>
|
|
290
|
+
<div id="legend"><span class="muted">legend</span><select id="legenddim"></select><span id="legendchips"></span></div>
|
|
175
291
|
<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>
|
|
292
|
+
<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>/<code>--term</code> wasn't found). Run <code>tmct chat</code> in that repo first, then re-run <code>tmct viz</code>.</div></div>
|
|
177
293
|
<div id="ask">
|
|
178
294
|
<h3>Ask the graph</h3>
|
|
179
|
-
<div class="row"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "
|
|
180
|
-
<div id="askresult">${
|
|
181
|
-
? '<span class="hint">running the real tmct engine, client-side, right here — try "
|
|
295
|
+
<div class="row"><input id="askq" type="text" autocomplete="off" placeholder='ask e.g. "what is a dog"'${hasAnyChat ? "" : " disabled"}><button id="asksubmit"${hasAnyChat ? "" : " disabled"}>ask</button></div>
|
|
296
|
+
<div id="askresult">${hasAnyChat
|
|
297
|
+
? '<span class="hint">running the real tmct engine(s), client-side, right here — try "what is X" or "where is X mentioned". Answers re-centre the graph on what they resolve.</span>'
|
|
182
298
|
: '<span class="hint">chat unavailable — run <code>npm run build:ask-bundle</code> and re-generate this page.</span>'}</div>
|
|
183
299
|
</div>
|
|
184
300
|
</div>
|
|
185
301
|
<script>
|
|
186
302
|
const GRAPH = ${graphJson};
|
|
187
303
|
const PAYLOAD = ${payloadJson};
|
|
304
|
+
const LEGEND = ${legendJson};
|
|
305
|
+
const WALK_OPTS = ${walkOptsJson};
|
|
188
306
|
</script>
|
|
189
307
|
${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
308
|
+
${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
|
|
190
309
|
<script>
|
|
191
310
|
(function () {
|
|
192
311
|
"use strict";
|
|
@@ -195,10 +314,24 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
195
314
|
if (!GRAPH.nodes.length) { emptyEl.classList.add("show"); return; }
|
|
196
315
|
|
|
197
316
|
var hasEngine = typeof tmctViz !== "undefined";
|
|
317
|
+
var hasMemEngine = typeof tmctMemoryAsk !== "undefined";
|
|
198
318
|
var FULL_GRAPH = hasEngine ? tmctViz.parseEntities(PAYLOAD) : null;
|
|
319
|
+
// The term-relation view (Bug 2 fix) over the FULL graph, computed once —
|
|
320
|
+
// recentre()/edge-kind-toggle re-walks reuse it rather than re-deriving it
|
|
321
|
+
// per click. hasEngine-gated: the walk/legend exports only exist in the
|
|
322
|
+
// ask-browser bundle, not the memory-ask one.
|
|
323
|
+
var TERM_GRAPH = hasEngine ? tmctViz.deriveFactTermGraph(FULL_GRAPH) : null;
|
|
324
|
+
// kindsForMode reuses the bundled tmctViz.edgeKindsFor — the SAME function
|
|
325
|
+
// the CLI's own computeVizGraph calls server-side — rather than a second
|
|
326
|
+
// hand-rolled copy of the meta/relation/both combination logic.
|
|
327
|
+
function kindsForMode(mode) {
|
|
328
|
+
return tmctViz.edgeKindsFor(mode, TERM_GRAPH ? TERM_GRAPH.factRelationKinds : []);
|
|
329
|
+
}
|
|
330
|
+
var edgeKindMode = WALK_OPTS.edgeKindMode || "both";
|
|
331
|
+
document.getElementById("edgekind").value = edgeKindMode;
|
|
199
332
|
|
|
200
333
|
// ---- palette: one hue per class, stable across recentres --------------
|
|
201
|
-
var PALETTE = ["#7aa2f7", "#bb9af7", "#7dcfff", "#9ece6a", "#e0af68", "#f7768e", "#73daca"];
|
|
334
|
+
var PALETTE = ["#7aa2f7", "#bb9af7", "#7dcfff", "#9ece6a", "#e0af68", "#f7768e", "#73daca", "#c0caf5"];
|
|
202
335
|
var classColor = new Map();
|
|
203
336
|
function colorFor(cls) {
|
|
204
337
|
if (!classColor.has(cls)) classColor.set(cls, PALETTE[classColor.size % PALETTE.length]);
|
|
@@ -236,11 +369,128 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
236
369
|
}
|
|
237
370
|
renderTypeFilters();
|
|
238
371
|
|
|
372
|
+
// ---- legend-as-filter (PLAN_VIZ_MEMORY.md "Auto-picking the filter/legend
|
|
373
|
+
// dimension"): LEGEND.primary names the server's auto-picked dimension
|
|
374
|
+
// (class/predicate/provenance, scored by normalized Shannon entropy over
|
|
375
|
+
// the INITIAL walk); the dropdown lets a user switch dimension without
|
|
376
|
+
// regenerating the page. Bucket COUNTS are always recomputed live over the
|
|
377
|
+
// CURRENTLY visible walk (never the stale initial-generation counts) via
|
|
378
|
+
// the same legendValueFor derivation the CLI's own pickLegendDimension
|
|
379
|
+
// uses — one shared source of truth, never a second hand-rolled copy. ----
|
|
380
|
+
var legendEl = document.getElementById("legend");
|
|
381
|
+
var legendDimEl = document.getElementById("legenddim");
|
|
382
|
+
var legendChipsEl = document.getElementById("legendchips");
|
|
383
|
+
var legendDim = (LEGEND && LEGEND.primary) || "class";
|
|
384
|
+
var legendEnabled = null; // null = no legend filter active (every value passes)
|
|
385
|
+
// collapseBuckets reuses the bundled tmctViz.collapseToTopN (same top-15 +
|
|
386
|
+
// "Other" rule pickLegendDimension uses server-side) rather than a second
|
|
387
|
+
// hand-rolled copy; a tiny inline fallback covers the (untested-in-practice)
|
|
388
|
+
// no-engine case so the legend never hard-crashes if the bundle is absent.
|
|
389
|
+
function collapseBuckets(buckets) {
|
|
390
|
+
if (hasEngine) return tmctViz.collapseToTopN(buckets);
|
|
391
|
+
if (buckets.length <= 20) return buckets;
|
|
392
|
+
var sorted = buckets.slice().sort(function (a, b) { return b.count - a.count; });
|
|
393
|
+
var kept = sorted.slice(0, 15);
|
|
394
|
+
var restCount = sorted.slice(15).reduce(function (s, b) { return s + b.count; }, 0);
|
|
395
|
+
return restCount ? kept.concat([{ value: "Other", count: restCount }]) : kept;
|
|
396
|
+
}
|
|
397
|
+
function legendValueOf(node, dim) {
|
|
398
|
+
if (!hasEngine) return dim === "class" ? (node.class || "(none)") : null;
|
|
399
|
+
return tmctViz.legendValueFor(FULL_GRAPH, node, dim);
|
|
400
|
+
}
|
|
401
|
+
function computeLegendBuckets(dim) {
|
|
402
|
+
var counts = new Map();
|
|
403
|
+
GRAPH.nodes.forEach(function (n) {
|
|
404
|
+
var v = legendValueOf(n, dim);
|
|
405
|
+
if (v == null || v === "") return;
|
|
406
|
+
counts.set(v, (counts.get(v) || 0) + 1);
|
|
407
|
+
});
|
|
408
|
+
var buckets = Array.from(counts.entries()).map(function (e) { return { value: e[0], count: e[1] }; });
|
|
409
|
+
buckets.sort(function (a, b) { return b.count - a.count; });
|
|
410
|
+
return collapseBuckets(buckets);
|
|
411
|
+
}
|
|
412
|
+
function renderLegend() {
|
|
413
|
+
if (!LEGEND) { legendEl.classList.remove("show"); return; }
|
|
414
|
+
var dims = Object.keys(LEGEND.dimensions || { class: 1 });
|
|
415
|
+
legendDimEl.innerHTML = dims.map(function (d) {
|
|
416
|
+
var info = LEGEND.dimensions[d];
|
|
417
|
+
var tag = info && info.qualifies ? "" : " (low signal)";
|
|
418
|
+
return '<option value="' + d + '"' + (d === legendDim ? " selected" : "") + '>' + d + tag + '</option>';
|
|
419
|
+
}).join("");
|
|
420
|
+
var buckets = computeLegendBuckets(legendDim);
|
|
421
|
+
if (!legendEnabled) legendEnabled = new Set(buckets.map(function (b) { return b.value; }));
|
|
422
|
+
legendEl.classList.toggle("show", buckets.length > 0);
|
|
423
|
+
legendChipsEl.innerHTML = buckets.map(function (b) {
|
|
424
|
+
var on = legendEnabled.has(b.value);
|
|
425
|
+
return '<span class="chip' + (on ? "" : " off") + '" data-v="' + esc(b.value) + '" title="click to toggle">'
|
|
426
|
+
+ '<span class="swatch" style="background:' + colorFor(legendDim === "class" ? b.value : "__" + legendDim) + '"></span>'
|
|
427
|
+
+ esc(b.value) + '<span class="n">' + b.count + '</span></span>';
|
|
428
|
+
}).join("");
|
|
429
|
+
legendChipsEl.querySelectorAll(".chip").forEach(function (chip) {
|
|
430
|
+
chip.addEventListener("click", function () {
|
|
431
|
+
var v = chip.dataset.v;
|
|
432
|
+
if (legendEnabled.has(v)) legendEnabled.delete(v); else legendEnabled.add(v);
|
|
433
|
+
renderLegend();
|
|
434
|
+
applyFilters();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
legendDimEl.addEventListener("change", function () {
|
|
439
|
+
legendDim = legendDimEl.value;
|
|
440
|
+
legendEnabled = null; // fresh "all on" set for the newly selected dimension
|
|
441
|
+
renderLegend();
|
|
442
|
+
applyFilters();
|
|
443
|
+
});
|
|
444
|
+
renderLegend();
|
|
445
|
+
|
|
446
|
+
// ---- hub-hide / beam-prune / search state --------------------------------
|
|
447
|
+
var hubHideOn = false, hubHideVal = WALK_OPTS.hubDegree || 40;
|
|
448
|
+
var beamOn = false, beamVal = 8;
|
|
449
|
+
var labelMode = "smart";
|
|
450
|
+
var searchTerm = "";
|
|
451
|
+
var hubHideOnEl = document.getElementById("hubhideon");
|
|
452
|
+
var hubHideValEl = document.getElementById("hubhideval");
|
|
453
|
+
var beamOnEl = document.getElementById("beamon");
|
|
454
|
+
var beamValEl = document.getElementById("beamval");
|
|
455
|
+
var labelModeEl = document.getElementById("labelmode");
|
|
456
|
+
var searchEl = document.getElementById("search");
|
|
457
|
+
var searchCountEl = document.getElementById("searchcount");
|
|
458
|
+
hubHideValEl.value = hubHideVal;
|
|
459
|
+
beamValEl.value = beamVal;
|
|
460
|
+
hubHideOnEl.addEventListener("change", function () { hubHideOn = hubHideOnEl.checked; applyFilters(); });
|
|
461
|
+
hubHideValEl.addEventListener("change", function () { hubHideVal = Number(hubHideValEl.value) || hubHideVal; applyFilters(); });
|
|
462
|
+
beamOnEl.addEventListener("change", function () { beamOn = beamOnEl.checked; applyFilters(); });
|
|
463
|
+
beamValEl.addEventListener("change", function () { beamVal = Number(beamValEl.value) || beamVal; applyFilters(); });
|
|
464
|
+
labelModeEl.addEventListener("change", function () { labelMode = labelModeEl.value; draw(); });
|
|
465
|
+
searchEl.addEventListener("input", function () { searchTerm = searchEl.value.trim().toLowerCase(); applyFilters(); });
|
|
466
|
+
|
|
467
|
+
// degree over the CURRENTLY displayed edge set (hub-hide/beam-prune are
|
|
468
|
+
// display-time filters, distinct from the generation-time hubDegree cap
|
|
469
|
+
// which only stops the WALK expanding through a hub — both useful, see
|
|
470
|
+
// PLAN_VIZ_MEMORY.md's Controls section). Memoized: draw() runs on every
|
|
471
|
+
// pan/zoom/hover mousemove, and both draw() and visibleNodeIds() (which
|
|
472
|
+
// draw() itself calls) each need it — recomputing an O(edges) map twice per
|
|
473
|
+
// frame during a drag is real, avoidable per-frame cost. Invalidated by
|
|
474
|
+
// relayout() (the ONLY place GRAPH.nodes/edges are mutated, on init and on
|
|
475
|
+
// every recentre()), so a stale cache can never survive a graph change.
|
|
476
|
+
var degCache = null;
|
|
477
|
+
function currentDegrees() {
|
|
478
|
+
if (!degCache) {
|
|
479
|
+
degCache = new Map();
|
|
480
|
+
GRAPH.edges.forEach(function (e) {
|
|
481
|
+
degCache.set(e.source, (degCache.get(e.source) || 0) + 1);
|
|
482
|
+
degCache.set(e.target, (degCache.get(e.target) || 0) + 1);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return degCache;
|
|
486
|
+
}
|
|
487
|
+
|
|
239
488
|
// ---- layout: concentric rings keyed on hop, seed at the centre, RE-runnable on recentre ----
|
|
240
489
|
var RING_GAP = 110;
|
|
241
490
|
var pos = new Map();
|
|
242
491
|
var byHopMax = 0;
|
|
243
492
|
function relayout() {
|
|
493
|
+
degCache = null; // GRAPH.nodes/edges just changed (initial load or recentre()) — invalidate
|
|
244
494
|
pos = new Map();
|
|
245
495
|
var byHop = new Map();
|
|
246
496
|
GRAPH.nodes.forEach(function (n) {
|
|
@@ -270,10 +520,47 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
270
520
|
document.getElementById("depthup").disabled = depthVal >= byHopMax;
|
|
271
521
|
}
|
|
272
522
|
function visibleNodeIds() {
|
|
523
|
+
var deg = (hubHideOn || beamOn) ? currentDegrees() : null;
|
|
273
524
|
var vis = new Set();
|
|
274
525
|
GRAPH.nodes.forEach(function (n) {
|
|
275
|
-
if (n.hop
|
|
526
|
+
if (n.hop > depthVal || !enabledTypes.has(n.class)) return;
|
|
527
|
+
if (legendEnabled) {
|
|
528
|
+
var v = legendValueOf(n, legendDim);
|
|
529
|
+
if (v != null && v !== "" && !legendEnabled.has(v)) return;
|
|
530
|
+
}
|
|
531
|
+
if (hubHideOn && deg && (deg.get(n.id) || 0) > hubHideVal) return;
|
|
532
|
+
vis.add(n.id);
|
|
276
533
|
});
|
|
534
|
+
// beam-prune: BFS-order pruning — per hop (> 0), keep only the top-N
|
|
535
|
+
// (by CURRENT degree) neighbours; hop 0 (the seed(s)) is always kept.
|
|
536
|
+
if (beamOn && deg) {
|
|
537
|
+
var byHop = new Map();
|
|
538
|
+
vis.forEach(function (id) {
|
|
539
|
+
var n = GRAPH.nodes.find(function (x) { return x.id === id; });
|
|
540
|
+
if (!n || n.hop === 0) return;
|
|
541
|
+
if (!byHop.has(n.hop)) byHop.set(n.hop, []);
|
|
542
|
+
byHop.get(n.hop).push(n);
|
|
543
|
+
});
|
|
544
|
+
byHop.forEach(function (list) {
|
|
545
|
+
list.sort(function (a, b) { return (deg.get(b.id) || 0) - (deg.get(a.id) || 0); });
|
|
546
|
+
list.slice(beamVal).forEach(function (n) { vis.delete(n.id); });
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (searchTerm) {
|
|
550
|
+
var hits = new Set();
|
|
551
|
+
GRAPH.nodes.forEach(function (n) { if (vis.has(n.id) && String(n.label).toLowerCase().indexOf(searchTerm) !== -1) hits.add(n.id); });
|
|
552
|
+
searchCountEl.textContent = hits.size ? (hits.size + " match" + (hits.size === 1 ? "" : "es")) : "no match";
|
|
553
|
+
// search NARROWS visibility to matches + their direct neighbours, so the
|
|
554
|
+
// hit's own context stays legible instead of collapsing to lone dots.
|
|
555
|
+
var withNeighbours = new Set(hits);
|
|
556
|
+
GRAPH.edges.forEach(function (e) {
|
|
557
|
+
if (hits.has(e.source) && vis.has(e.target)) withNeighbours.add(e.target);
|
|
558
|
+
if (hits.has(e.target) && vis.has(e.source)) withNeighbours.add(e.source);
|
|
559
|
+
});
|
|
560
|
+
vis = new Set(Array.from(vis).filter(function (id) { return withNeighbours.has(id); }));
|
|
561
|
+
} else {
|
|
562
|
+
searchCountEl.textContent = "";
|
|
563
|
+
}
|
|
277
564
|
return vis;
|
|
278
565
|
}
|
|
279
566
|
function applyFilters() { syncDepthUi(); draw(); }
|
|
@@ -294,6 +581,10 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
294
581
|
var dpr = window.devicePixelRatio || 1;
|
|
295
582
|
var view = { scale: 1, x: 0, y: 0 };
|
|
296
583
|
var selectedId = null;
|
|
584
|
+
// The full set of node ids a query answer actually resolved to (not just
|
|
585
|
+
// the single "primary" selectedId) — draw() rings every one of them so a
|
|
586
|
+
// multi-fact answer shows ALL the nodes it came from, not just one.
|
|
587
|
+
var highlightIds = new Set();
|
|
297
588
|
|
|
298
589
|
function resize() {
|
|
299
590
|
canvas.width = Math.floor(canvas.clientWidth * dpr);
|
|
@@ -311,10 +602,47 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
311
602
|
return { x: (sx * dpr - cx) / (view.scale * dpr), y: (sy * dpr - cy) / (view.scale * dpr) };
|
|
312
603
|
}
|
|
313
604
|
|
|
605
|
+
// Label-density modes (PLAN_VIZ_MEMORY.md Controls): "smart" (seonix's own
|
|
606
|
+
// default) draws a label only for the focus/selection/direct-neighbours/
|
|
607
|
+
// top-20-by-degree; everything else labels on hover only. "all"/"name-source"
|
|
608
|
+
// always draw (name-source appends the Fact's own provenance prefix — trust
|
|
609
|
+
// tier is a first-class concept here unlike seonix's code graph, so this
|
|
610
|
+
// variant has no seonix equivalent). "none" draws no labels at all.
|
|
611
|
+
var hoverId = null;
|
|
612
|
+
canvas.addEventListener("mousemove", function (ev) {
|
|
613
|
+
if (labelMode !== "smart" || dragging) return;
|
|
614
|
+
var rect = canvas.getBoundingClientRect();
|
|
615
|
+
var w = screenToWorld(ev.clientX - rect.left, ev.clientY - rect.top);
|
|
616
|
+
var best = null, bestDist = Infinity;
|
|
617
|
+
GRAPH.nodes.forEach(function (n) {
|
|
618
|
+
var p = pos.get(n.id);
|
|
619
|
+
if (!p) return;
|
|
620
|
+
var dx = p.x - w.x, dy = p.y - w.y, d = Math.sqrt(dx * dx + dy * dy);
|
|
621
|
+
if (d < bestDist) { best = n.id; bestDist = d; }
|
|
622
|
+
});
|
|
623
|
+
var next = bestDist < 24 / view.scale ? best : null;
|
|
624
|
+
if (next !== hoverId) { hoverId = next; draw(); }
|
|
625
|
+
});
|
|
626
|
+
function topDegreeIds(vis, deg, n) {
|
|
627
|
+
var ranked = Array.from(vis).sort(function (a, b) { return (deg.get(b) || 0) - (deg.get(a) || 0); });
|
|
628
|
+
return new Set(ranked.slice(0, n));
|
|
629
|
+
}
|
|
630
|
+
function labelFor(n) {
|
|
631
|
+
var text = String(n.label).slice(0, 40);
|
|
632
|
+
if (labelMode !== "name-source") return text;
|
|
633
|
+
var prov = hasEngine ? tmctViz.legendValueFor(FULL_GRAPH, n, "provenance") : null;
|
|
634
|
+
return prov ? text + " [" + prov + "]" : text;
|
|
635
|
+
}
|
|
636
|
+
|
|
314
637
|
function draw() {
|
|
315
638
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
316
639
|
ctx.lineWidth = Math.max(1, 1 * dpr);
|
|
317
640
|
var vis = visibleNodeIds();
|
|
641
|
+
var deg = currentDegrees();
|
|
642
|
+
var smartLabelIds = labelMode === "smart" ? topDegreeIds(vis, deg, 20) : null;
|
|
643
|
+
var searchHits = searchTerm
|
|
644
|
+
? new Set(GRAPH.nodes.filter(function (n) { return vis.has(n.id) && String(n.label).toLowerCase().indexOf(searchTerm) !== -1; }).map(function (n) { return n.id; }))
|
|
645
|
+
: null;
|
|
318
646
|
ctx.strokeStyle = "rgba(255,255,255,0.14)";
|
|
319
647
|
GRAPH.edges.forEach(function (e) {
|
|
320
648
|
if (!vis.has(e.source) || !vis.has(e.target)) return;
|
|
@@ -341,11 +669,28 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
341
669
|
ctx.lineWidth = Math.max(1.5, 2.5 * dpr); ctx.strokeStyle = "rgba(255,255,255,0.6)";
|
|
342
670
|
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 3 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
343
671
|
}
|
|
344
|
-
if (
|
|
672
|
+
if (searchHits && searchHits.has(n.id)) {
|
|
673
|
+
ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#e0af68";
|
|
674
|
+
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 5 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
675
|
+
}
|
|
676
|
+
// Every node the last "ask the graph" answer actually resolved to
|
|
677
|
+
// (frameQueryResult below) — a distinct green ring so a multi-fact
|
|
678
|
+
// answer's whole result set reads as one highlighted group, not just
|
|
679
|
+
// the single primary node selectedId/focus already mark.
|
|
680
|
+
if (highlightIds.has(n.id) && n.id !== selectedId) {
|
|
681
|
+
ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#9ece6a";
|
|
682
|
+
ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 4 * dpr, 0, Math.PI * 2); ctx.stroke();
|
|
683
|
+
}
|
|
684
|
+
var showLabel = view.scale > 0.55 && labelMode !== "none" && (
|
|
685
|
+
labelMode !== "smart"
|
|
686
|
+
|| n.id === selectedId || n.id === GRAPH.focus || n.id === hoverId
|
|
687
|
+
|| (smartLabelIds && smartLabelIds.has(n.id))
|
|
688
|
+
);
|
|
689
|
+
if (showLabel) {
|
|
345
690
|
ctx.font = (11 * dpr) + "px -apple-system, sans-serif";
|
|
346
691
|
ctx.fillStyle = "rgba(231,233,238," + Math.min(1, 0.55 + view.scale * 0.3) + ")";
|
|
347
692
|
ctx.textBaseline = "middle";
|
|
348
|
-
ctx.fillText(
|
|
693
|
+
ctx.fillText(labelFor(n), sp.x + Math.max(6, r + 4), sp.y);
|
|
349
694
|
}
|
|
350
695
|
});
|
|
351
696
|
}
|
|
@@ -379,36 +724,84 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
379
724
|
draw();
|
|
380
725
|
}, { passive: false });
|
|
381
726
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
727
|
+
// Shared bounding-box fit — pan/zoom so every position in "points" is framed
|
|
728
|
+
// with padding. fitToVisible/fitToIds are both thin wrappers naming WHICH
|
|
729
|
+
// positions to fit; the math itself lives here once.
|
|
730
|
+
function fitToPositions(points) {
|
|
731
|
+
if (!points.length) return;
|
|
732
|
+
var minX = Math.min.apply(null, points.map(function (p) { return p.x; })), maxX = Math.max.apply(null, points.map(function (p) { return p.x; }));
|
|
733
|
+
var minY = Math.min.apply(null, points.map(function (p) { return p.y; })), maxY = Math.max.apply(null, points.map(function (p) { return p.y; }));
|
|
387
734
|
var w = Math.max(1, maxX - minX), h = Math.max(1, maxY - minY);
|
|
388
735
|
view.scale = Math.min(4, Math.max(0.1, Math.min(canvas.width / dpr / (w + 160), canvas.height / dpr / (h + 160))));
|
|
389
736
|
view.x = -(minX + maxX) / 2 * view.scale; view.y = -(minY + maxY) / 2 * view.scale;
|
|
390
737
|
}
|
|
738
|
+
function fitToVisible() {
|
|
739
|
+
fitToPositions(Array.from(visibleNodeIds()).map(function (id) { return pos.get(id); }).filter(Boolean));
|
|
740
|
+
}
|
|
741
|
+
// Fit specifically to a query answer's own result set (not just "whatever
|
|
742
|
+
// recentre's re-walk happened to make visible") — a multi-fact answer's
|
|
743
|
+
// nodes can be spread wider than the default depth/nodeLimit view, so this
|
|
744
|
+
// is the precision framing step frameQueryResult calls after recentre.
|
|
745
|
+
function fitToIds(ids) {
|
|
746
|
+
fitToPositions(ids.map(function (id) { return pos.get(id); }).filter(Boolean));
|
|
747
|
+
}
|
|
748
|
+
document.getElementById("resetview").addEventListener("click", function () { fitToVisible(); draw(); });
|
|
391
749
|
|
|
392
|
-
// ---- recentre: RE-WALK the FULL graph
|
|
750
|
+
// ---- recentre: RE-WALK the FULL graph (via TERM_GRAPH — Bug 2's augmented
|
|
751
|
+
// view, so a recentre reaches real concept-relation edges the same way
|
|
752
|
+
// generation-time computeVizGraph does) from a new seed via the real,
|
|
393
753
|
// bundled spiralExpand (byte-identical to the CLI's own walk — never a
|
|
394
754
|
// hand-rolled client-side BFS) and rebuild via the real buildVizNodesAndEdges.
|
|
395
|
-
// Used for double-click-to-recentre
|
|
755
|
+
// Used for double-click-to-recentre, focus-follows-chat-answer, AND the
|
|
756
|
+
// edge-kind toggle (re-walks the SAME seed under a new kind set). Reuses
|
|
757
|
+
// WALK_OPTS (this page's own generation-time depth/nodeLimit/hubDegree) so a
|
|
758
|
+
// client-side re-walk never silently falls back to spiralExpand's smaller
|
|
759
|
+
// code-graph defaults. -------------------------------------------------
|
|
760
|
+
function walkGraph() { return TERM_GRAPH ? TERM_GRAPH.graph : FULL_GRAPH; }
|
|
396
761
|
function recentre(id) {
|
|
397
|
-
if (!hasEngine || !FULL_GRAPH || !
|
|
398
|
-
var walked = tmctViz.spiralExpand(
|
|
762
|
+
if (!hasEngine || !FULL_GRAPH || !walkGraph().byId.has(id)) return false;
|
|
763
|
+
var walked = tmctViz.spiralExpand(walkGraph(), [], {
|
|
764
|
+
kinds: kindsForMode(edgeKindMode),
|
|
399
765
|
classPredicate: function () { return true; }, idNormalizer: function (i) { return i; }, seeds: [id],
|
|
766
|
+
depth: WALK_OPTS.depth, nodeLimit: WALK_OPTS.nodeLimit, hubDegree: WALK_OPTS.hubDegree,
|
|
400
767
|
});
|
|
401
|
-
var built = tmctViz.buildVizNodesAndEdges(
|
|
768
|
+
var built = tmctViz.buildVizNodesAndEdges(walkGraph(), walked);
|
|
402
769
|
GRAPH.nodes = built.nodes; GRAPH.edges = built.edges; GRAPH.focus = id;
|
|
403
770
|
// classes newly reached that weren't in the initial palette still resolve
|
|
404
771
|
// via colorFor()'s own on-demand assignment; the checkbox row itself was
|
|
405
772
|
// already seeded from the FULL graph's classes above, so no rebuild needed.
|
|
406
773
|
relayout();
|
|
407
774
|
depthVal = byHopMax;
|
|
775
|
+
legendEnabled = null; // re-derive "all on" for the new node set's own buckets
|
|
776
|
+
renderLegend();
|
|
408
777
|
fitToVisible();
|
|
409
778
|
return true;
|
|
410
779
|
}
|
|
411
780
|
|
|
781
|
+
// Focus the graph on a QUERY ANSWER's own result set — every node it
|
|
782
|
+
// actually resolved to, not just one. Re-walks from the first valid id
|
|
783
|
+
// (recentre's existing mechanism, which already reaches most/all closely
|
|
784
|
+
// related result nodes), then fitToIds() precisely frames the full
|
|
785
|
+
// requested set — any id recentre's walk didn't reach simply has no
|
|
786
|
+
// position and drops out of the fit, an honest degrade, never a guess.
|
|
787
|
+
// Sets highlightIds so draw() rings every result node, not just the
|
|
788
|
+
// primary one selectedId/GRAPH.focus already mark.
|
|
789
|
+
function frameQueryResult(ids) {
|
|
790
|
+
var real = (ids || []).filter(function (id) { return walkGraph().byId.has(id); });
|
|
791
|
+
if (!real.length) return false;
|
|
792
|
+
if (!recentre(real[0])) return false;
|
|
793
|
+
fitToIds(real);
|
|
794
|
+
highlightIds = new Set(real);
|
|
795
|
+
selectedId = real[0];
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
document.getElementById("edgekind").addEventListener("change", function (ev) {
|
|
799
|
+
edgeKindMode = ev.target.value;
|
|
800
|
+
var seed = GRAPH.focus;
|
|
801
|
+
if (seed) recentre(seed);
|
|
802
|
+
draw();
|
|
803
|
+
});
|
|
804
|
+
|
|
412
805
|
// ---- click-to-inspect ------------------------------------------------------
|
|
413
806
|
var panel = document.getElementById("panel");
|
|
414
807
|
function fmtTs(v) { return v ? String(v) : "(none)"; }
|
|
@@ -495,37 +888,119 @@ ${hasChat ? `<script>\n${askBundle}\n</script>` : ""}
|
|
|
495
888
|
if (best && recentre(best.id)) { selectedId = best.id; showPanel(GRAPH.nodes.filter(function (n) { return n.id === best.id; })[0]); }
|
|
496
889
|
});
|
|
497
890
|
|
|
498
|
-
// ---- Ask the graph:
|
|
499
|
-
//
|
|
500
|
-
//
|
|
891
|
+
// ---- Ask the graph: TWO real engines over the FULL graph, never just the
|
|
892
|
+
// currently-displayed subgraph (Bug 1 fix, PLAN_VIZ_MEMORY.md) — tried in
|
|
893
|
+
// order per query:
|
|
894
|
+
// 1. tmctMemoryAsk.factAnswer (src/chat.mjs's REAL memory-graph answer
|
|
895
|
+
// engine, the same one 'npm run chat' uses) — a Fact/definition-shaped
|
|
896
|
+
// question ("what is a dog", "what is a horse used for") answers HERE,
|
|
897
|
+
// which the code-graph engine below always missed on (Bug 1's whole
|
|
898
|
+
// point). Given an in-memory Backend-B handle carrying the page's own
|
|
899
|
+
// embedded PAYLOAD — ZERO fs I/O (see memory-ask-browser-entry.mjs's own
|
|
900
|
+
// doc comment) — and envelope:null/miss:true, the exact documented
|
|
901
|
+
// "no parse pipeline available" bootstrap path that arms factAnswer's
|
|
902
|
+
// own bare-question regex fallbacks.
|
|
903
|
+
// 2. tmctViz.ask (tmct's code-graph query engine) — generic "where is X
|
|
904
|
+
// mentioned" navigation and code-graph queries, unchanged from before
|
|
905
|
+
// this session. Only reached when (1) is unavailable or didn't hit.
|
|
906
|
+
// A resolved answer's own target re-centres the view — focus follows the
|
|
907
|
+
// answer — for EITHER engine. --------------------------------------------
|
|
501
908
|
var askInput = document.getElementById("askq");
|
|
502
909
|
var askBtn = document.getElementById("asksubmit");
|
|
503
910
|
var askOut = document.getElementById("askresult");
|
|
911
|
+
var memHandle = hasMemEngine ? tmctMemoryAsk.createInMemoryStore() : null;
|
|
912
|
+
if (memHandle) memHandle.payload = PAYLOAD;
|
|
913
|
+
|
|
914
|
+
// Placeholder is a real term from THIS graph, picked once per page load, so
|
|
915
|
+
// the hint stays honest ("what is X" where X actually resolves here) instead
|
|
916
|
+
// of a static example that may not exist in a given repo's graph. Native
|
|
917
|
+
// <input placeholder> behaviour (disappears on focus/typing, reappears when
|
|
918
|
+
// blank) is untouched — this only changes what text it starts with.
|
|
919
|
+
if (hasEngine && FULL_GRAPH) {
|
|
920
|
+
var termLabels = [];
|
|
921
|
+
walkGraph().byId.forEach(function (ind, id) {
|
|
922
|
+
if (id.indexOf("term:") === 0 && ind.label) termLabels.push(ind.label);
|
|
923
|
+
});
|
|
924
|
+
if (termLabels.length) {
|
|
925
|
+
askInput.placeholder = 'what is ' + termLabels[Math.floor(Math.random() * termLabels.length)];
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Best-effort focus-follow for a memory-engine hit: factAnswer returns
|
|
930
|
+
// rendered TEXT, not a list of resolved entity ids (unlike ask.mjs's
|
|
931
|
+
// envelope/matches). Two passes, both real-graph-checked, never a guessed
|
|
932
|
+
// id that doesn't exist:
|
|
933
|
+
// 1. every term node whose label appears in the ANSWER text — this is
|
|
934
|
+
// "the nodes that come back," e.g. "dog is a kind of animal" surfaces
|
|
935
|
+
// BOTH term:dog and term:animal, not just the one the question asked
|
|
936
|
+
// about, so a multi-fact answer highlights its whole result set.
|
|
937
|
+
// 2. if that finds nothing (e.g. a phrasing that doesn't echo a bare term
|
|
938
|
+
// label), fall back to stripping the QUESTION's own crust and trying
|
|
939
|
+
// the remainder as a single term id — the previous behaviour, kept as
|
|
940
|
+
// a fallback rather than replaced.
|
|
941
|
+
function findAnsweredTermIds(query, answerText) {
|
|
942
|
+
if (!hasEngine || !hasMemEngine) return [];
|
|
943
|
+
var hay = " " + String(answerText).toLowerCase() + " ";
|
|
944
|
+
var found = [];
|
|
945
|
+
walkGraph().byId.forEach(function (ind, id) {
|
|
946
|
+
if (id.indexOf("term:") !== 0) return;
|
|
947
|
+
var label = String(ind.label || "").toLowerCase();
|
|
948
|
+
if (label.length < 3) return; // skip too-short/noisy labels (dedupe/precision, not a real cap)
|
|
949
|
+
if (hay.indexOf(" " + label) !== -1 || hay.indexOf(label + " ") !== -1) found.push(id);
|
|
950
|
+
});
|
|
951
|
+
if (found.length) return found;
|
|
952
|
+
var stripped = String(query).toLowerCase()
|
|
953
|
+
.replace(/^(what|where|who|which|does|do|is|are)\b/, "")
|
|
954
|
+
.replace(/\b(is|are|used for|do|does|mean|means|a|an|the)\b/g, " ")
|
|
955
|
+
.replace(/[?.!]+$/, "")
|
|
956
|
+
.replace(/\s+/g, " ")
|
|
957
|
+
.trim();
|
|
958
|
+
if (!stripped) return [];
|
|
959
|
+
var id = "term:" + tmctMemoryAsk.normFactTerm(stripped);
|
|
960
|
+
return walkGraph().byId.has(id) ? [id] : [];
|
|
961
|
+
}
|
|
504
962
|
|
|
505
963
|
function runAsk(query) {
|
|
506
|
-
if (!hasEngine) return;
|
|
507
964
|
askOut.classList.remove("miss");
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
965
|
+
(async function () {
|
|
966
|
+
if (memHandle) {
|
|
967
|
+
var fact = null;
|
|
968
|
+
try { fact = await tmctMemoryAsk.factAnswer(memHandle, query, null, true, {}); } catch { fact = null; }
|
|
969
|
+
if (fact && fact.text) {
|
|
970
|
+
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>' + esc(fact.text)
|
|
971
|
+
+ '<div class="src">answered from the full embedded memory graph (not just what\\'s currently drawn)</div>';
|
|
972
|
+
frameQueryResult(findAnsweredTermIds(query, fact.text));
|
|
973
|
+
draw();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (!hasEngine) {
|
|
978
|
+
askOut.classList.add("miss");
|
|
979
|
+
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>no answer engine available.';
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
var t = tmctViz.ask(FULL_GRAPH, query, { contextId: selectedId });
|
|
983
|
+
var envelope = t.tmct_ask || {};
|
|
984
|
+
askOut.classList.toggle("miss", !!envelope.miss);
|
|
985
|
+
var canon = envelope.canonical
|
|
986
|
+
? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
|
|
987
|
+
: "";
|
|
988
|
+
askOut.innerHTML = '<div class="q">"' + esc(query) + '"</div>' + esc(t.content) + canon;
|
|
989
|
+
// Focus-follows-answer: frame EVERY real match this answer resolved to
|
|
990
|
+
// (envelope.matches is already the full candidate list ask.mjs itself
|
|
991
|
+
// ranked — previously only matches[0] recentred, silently dropping the
|
|
992
|
+
// rest of a multi-match answer's own result set), never a guess beyond
|
|
993
|
+
// what the engine itself actually returned.
|
|
994
|
+
var targetIds = (envelope.matches || []).map(function (m) { return m.id; }).filter(Boolean);
|
|
995
|
+
frameQueryResult(targetIds);
|
|
996
|
+
draw();
|
|
997
|
+
})();
|
|
523
998
|
}
|
|
524
999
|
function askAndPopulate(query) {
|
|
525
1000
|
askInput.value = query;
|
|
526
1001
|
runAsk(query);
|
|
527
1002
|
}
|
|
528
|
-
if (hasEngine) {
|
|
1003
|
+
if (hasEngine || hasMemEngine) {
|
|
529
1004
|
askBtn.addEventListener("click", function () { var q = askInput.value.trim(); if (q) runAsk(q); });
|
|
530
1005
|
askInput.addEventListener("keydown", function (ev) { if (ev.key === "Enter") { var q = askInput.value.trim(); if (q) runAsk(q); } });
|
|
531
1006
|
}
|