@dotdotgod/cli 0.6.6 → 0.7.0

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.
@@ -1,253 +1,75 @@
1
- (() => {
2
- const COLORS = { seed: '#1857c8', doc: '#6b47a8', test: '#16734b', code: '#42515e', contract: '#c66a12', heading: '#8b9690', other: '#66727b' };
3
- const MORPH_DURATION = 380;
4
- const MOTION_MODES = new Set(['instant', 'layer', 'sequence']);
5
- const savedMotionMode = localStorage.getItem('dotdotgod:graph-motion');
6
- const state = { payload: null, renderer: null, graph: null, selected: null, enabledRelations: new Set(), previousPositions: new Map(), animationFrame: null, positioned: [], firstRender: true, motionMode: MOTION_MODES.has(savedMotionMode) ? savedMotionMode : 'layer', revealToken: 0 };
7
- const elements = {
8
- graph: document.querySelector('#graph'), fallback: document.querySelector('#graph-fallback'), filters: document.querySelector('#relation-filters'), motionMode: document.querySelector('#motion-mode'), skipReveal: document.querySelector('#skip-reveal'), list: document.querySelector('#result-list'), inspection: document.querySelector('#inspection'), status: document.querySelector('#status'), summary: document.querySelector('#plot-summary'), count: document.querySelector('#result-count'), form: document.querySelector('#reroot-form'), input: document.querySelector('#reroot-path'), reset: document.querySelector('#reset-camera'), search: document.querySelector('#node-search-input'), searchResults: document.querySelector('#node-search-results'),
9
- };
10
-
11
- function nodeKind(node) {
12
- if (node.seed) return 'seed';
13
- if (node.type === 'contract') return 'contract';
14
- if (node.type === 'heading' || node.id.startsWith('heading:')) return 'heading';
15
- if (node.path?.startsWith('docs/')) return 'doc';
16
- if (/(^|\/)(test|tests)\//.test(node.path ?? '') || /\.(test|spec)\./.test(node.path ?? '')) return 'test';
17
- if (node.type === 'file') return 'code';
18
- return 'other';
19
- }
20
-
21
- function relationLabel(value) { return value.replace(/^incoming:/, '').replaceAll('_', ' '); }
22
- function safe(value) { const span = document.createElement('span'); span.textContent = value ?? ''; return span.innerHTML; }
23
-
24
- function layoutNodes(nodes, edges) {
25
- const width = Math.max(elements.graph.clientWidth, 600);
26
- const height = Math.max(elements.graph.clientHeight, 500);
27
- const center = { x: width / 2, y: height / 2 };
28
- const goldenAngle = Math.PI * (3 - Math.sqrt(5));
29
- const layerCounts = new Map();
30
- const simulationNodes = [...nodes].sort((left, right) => left.depth - right.depth || left.id.localeCompare(right.id)).map((node) => {
31
- const layerIndex = layerCounts.get(node.depth) ?? 0;
32
- layerCounts.set(node.depth, layerIndex + 1);
33
- const radius = node.depth === 0 ? Math.min(34, nodes.filter((candidate) => candidate.depth === 0).length * 8) : 66 + node.depth * 76;
34
- const angle = layerIndex * goldenAngle + node.depth * .35;
35
- return { ...node, targetX: center.x + Math.cos(angle) * radius, targetY: center.y + Math.sin(angle) * radius, x: center.x + Math.cos(angle) * radius, y: center.y + Math.sin(angle) * radius };
36
- });
37
- const byId = new Map(simulationNodes.map((node) => [node.id, node]));
38
- const simulationEdges = edges.map((edge) => ({ ...edge, source: byId.get(edge.source), target: byId.get(edge.target) })).filter((edge) => edge.source && edge.target);
39
- const simulation = d3.forceSimulation(simulationNodes)
40
- .force('charge', d3.forceManyBody().strength((node) => node.type === 'heading' ? -16 : node.seed ? -220 : -48))
41
- .force('link', d3.forceLink(simulationEdges).id((node) => node.id).distance((edge) => edge.relation === 'contains_heading' ? 18 : edge.source.seed || edge.target.seed ? 72 : 38).strength((edge) => edge.relation === 'contains_heading' ? .72 : .34))
42
- .force('x', d3.forceX((node) => node.targetX).strength(.16))
43
- .force('y', d3.forceY((node) => node.targetY).strength(.16))
44
- .force('center', d3.forceCenter(width / 2, height / 2).strength(.025))
45
- .force('collide', d3.forceCollide().radius((node) => node.type === 'heading' ? 3.5 : 6 + Math.sqrt(node.impactScore || 0)))
46
- .stop();
47
- const ticks = nodes.length > 1400 ? 70 : nodes.length > 700 ? 100 : nodes.length > 300 ? 130 : 180;
48
- for (let index = 0; index < ticks; index += 1) simulation.tick();
49
- return simulationNodes;
50
- }
51
-
52
- function visibleModel() {
53
- const sourceNodes = state.payload.graph.nodes;
54
- const sourceIds = new Set(sourceNodes.map((node) => node.id));
55
- return { nodes: sourceNodes, edges: state.payload.graph.edges.filter((edge) => state.enabledRelations.has(edge.relation) && sourceIds.has(edge.source) && sourceIds.has(edge.target)) };
56
- }
57
-
58
- function revealDelay(node, index, maximumDepth, totalNodes) {
59
- if (!state.firstRender || state.motionMode === 'instant') return 0;
60
- if (state.motionMode === 'sequence') return index * Math.min(32, 1200 / Math.max(totalNodes, 1));
61
- return node.depth * Math.min(70, 650 / Math.max(maximumDepth, 1));
62
- }
63
-
64
- function finishReveal(positioned) {
65
- cancelAnimationFrame(state.animationFrame);
66
- state.revealToken += 1;
67
- for (const node of positioned) {
68
- if (!state.graph?.hasNode(node.id)) continue;
69
- state.graph.setNodeAttribute(node.id, 'x', node.x);
70
- state.graph.setNodeAttribute(node.id, 'y', node.y);
71
- state.graph.setNodeAttribute(node.id, 'size', node.renderSize ?? 4);
72
- }
73
- state.renderer?.refresh();
74
- state.previousPositions = new Map(positioned.map((node) => [node.id, { x: node.x, y: node.y }]));
75
- state.firstRender = false;
76
- elements.skipReveal.hidden = true;
77
- }
78
-
79
- function animatePositions(graph, positioned) {
80
- cancelAnimationFrame(state.animationFrame);
81
- const token = ++state.revealToken;
82
- const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
83
- const mode = reduced ? 'instant' : state.motionMode;
84
- const maximumDepth = Math.max(0, ...positioned.map((node) => node.depth));
85
- const ordered = [...positioned].sort((left, right) => left.depth - right.depth || left.id.localeCompare(right.id));
86
- const orderById = new Map(ordered.map((node, index) => [node.id, index]));
87
- const starts = new Map(positioned.map((node) => {
88
- const prior = state.previousPositions.get(node.id);
89
- const root = positioned.find((candidate) => candidate.id === node.nearestRoot);
90
- return [node.id, prior ?? { x: root?.x ?? node.x, y: root?.y ?? node.y }];
91
- }));
92
- const lastDelay = mode === 'instant' ? 0 : Math.max(0, ...positioned.map((node) => revealDelay(node, orderById.get(node.id), maximumDepth, positioned.length)));
93
- const duration = mode === 'instant' ? 0 : MORPH_DURATION + lastDelay;
94
- const startedAt = performance.now();
95
- elements.skipReveal.hidden = duration === 0;
96
- const tick = (now) => {
97
- if (token !== state.revealToken) return;
98
- const elapsed = now - startedAt;
99
- for (const node of positioned) {
100
- const delay = mode === 'instant' ? 0 : revealDelay(node, orderById.get(node.id), maximumDepth, positioned.length);
101
- const progress = duration === 0 ? 1 : Math.max(0, Math.min(1, (elapsed - delay) / MORPH_DURATION));
102
- const eased = 1 - Math.pow(1 - progress, 4);
103
- const start = starts.get(node.id);
104
- graph.setNodeAttribute(node.id, 'x', start.x + (node.x - start.x) * eased);
105
- graph.setNodeAttribute(node.id, 'y', start.y + (node.y - start.y) * eased);
106
- graph.setNodeAttribute(node.id, 'size', (node.renderSize ?? 4) * (progress === 0 ? 0 : .18 + .82 * eased));
107
- }
108
- state.renderer?.refresh();
109
- if (elapsed < duration) state.animationFrame = requestAnimationFrame(tick);
110
- else finishReveal(positioned);
111
- };
112
- state.animationFrame = requestAnimationFrame(tick);
113
- }
114
-
115
- function renderFilters(edges) {
116
- const relations = [...new Set(edges.map((edge) => edge.relation))].sort();
117
- const previous = state.enabledRelations;
118
- state.enabledRelations = new Set(relations.filter((relation) => previous.size === 0 || previous.has(relation)));
119
- elements.filters.replaceChildren(...relations.map((relation) => {
120
- const label = document.createElement('label'); label.className = 'filter-control';
121
- const input = document.createElement('input'); input.type = 'checkbox'; input.checked = state.enabledRelations.has(relation); input.value = relation;
122
- input.addEventListener('change', () => { input.checked ? state.enabledRelations.add(relation) : state.enabledRelations.delete(relation); renderGraph(); });
123
- const text = document.createElement('span'); text.textContent = relationLabel(relation);
124
- const count = document.createElement('code'); count.textContent = String(edges.filter((edge) => edge.relation === relation).length);
125
- label.append(input, text, count); return label;
126
- }));
127
- }
128
-
129
- function renderGraph() {
130
- if (state.graph) state.previousPositions = new Map(state.graph.nodes().map((id) => [id, { x: state.graph.getNodeAttribute(id, 'x'), y: state.graph.getNodeAttribute(id, 'y') }]));
131
- cancelAnimationFrame(state.animationFrame);
132
- state.renderer?.kill();
133
- const model = visibleModel();
134
- const visibleNodes = model.nodes;
135
- const positioned = layoutNodes(visibleNodes, model.edges);
136
- state.positioned = positioned;
137
- try {
138
- const graph = new graphology.MultiDirectedGraph();
139
- for (const node of positioned) {
140
- node.renderSize = node.seed ? 14 : node.type === 'heading' ? 2 : 4 + Math.sqrt(Math.max(node.impactScore, 1)) * .55;
141
- const start = state.previousPositions.get(node.id) ?? positioned.find((candidate) => candidate.id === node.nearestRoot) ?? node;
142
- graph.addNode(node.id, { label: node.label, x: start.x, y: start.y, size: node.renderSize, color: COLORS[nodeKind(node)], node });
143
- }
144
- model.edges.forEach((edge, index) => graph.addDirectedEdgeWithKey(`edge-${index}`, edge.source, edge.target, { color: edge.relation === 'vector_similarity' ? '#c66a12' : '#9ca8a3', size: edge.weight ? Math.min(3, 1 + edge.weight / 7) : 1, type: 'arrow', relation: edge.relation }));
145
- state.graph = graph;
146
- state.renderer = new Sigma(graph, elements.graph, { renderEdgeLabels: false, labelDensity: visibleNodes.length > 500 ? .03 : .1, labelRenderedSizeThreshold: visibleNodes.length > 500 ? 11 : 7, defaultEdgeType: 'arrow', zIndex: true, nodeReducer: (id, data) => {
147
- if (!state.selected || !state.graph.hasNode(state.selected)) return data;
148
- const neighbors = new Set(state.graph.neighbors(state.selected));
149
- if (id === state.selected || neighbors.has(id)) return { ...data, highlighted: true, zIndex: 2 };
150
- return { ...data, color: '#cbd1ce', label: '', zIndex: 0 };
151
- }, edgeReducer: (key, data) => {
152
- if (!state.selected || !state.graph.hasNode(state.selected)) return data;
153
- const [source, target] = state.graph.extremities(key);
154
- return source === state.selected || target === state.selected ? { ...data, color: '#1857c8', size: 2.4, zIndex: 2 } : { ...data, color: '#d8dedb', zIndex: 0 };
155
- } });
156
- state.renderer.on('clickNode', ({ node }) => selectNode(node, true));
157
- animatePositions(graph, positioned);
158
- elements.fallback.hidden = true;
159
- } catch (error) {
160
- const message = error instanceof Error ? error.message : String(error);
161
- console.error('Impact graph rendering failed:', error);
162
- elements.fallback.textContent = `Graph rendering failed: ${message}`;
163
- elements.fallback.hidden = false;
164
- }
165
- }
166
-
167
- function renderList() {
168
- const related = state.payload.impact.related;
169
- elements.count.textContent = `${related.length} results`;
170
- elements.list.replaceChildren(...related.map((item, index) => {
171
- const entry = document.createElement('li');
172
- const button = document.createElement('button'); button.type = 'button'; button.className = 'result-button'; button.dataset.nodeId = item.id; button.setAttribute('aria-current', item.id === state.selected ? 'true' : 'false');
173
- button.innerHTML = `<span class="rank">${String(index + 1).padStart(2, '0')}</span><span class="result-copy"><span class="result-path">${safe(item.path ?? item.title ?? item.id)}</span><span class="result-reason">${safe((item.reasons ?? []).slice(0, 2).map(relationLabel).join(' · '))}</span></span><span class="score">${item.impactScore}</span>`;
174
- button.addEventListener('click', () => selectNode(item.id, true)); entry.append(button); return entry;
175
- }));
176
- }
177
-
178
- function renderSearch() {
179
- const query = elements.search.value.trim().toLocaleLowerCase();
180
- if (!query) { elements.searchResults.replaceChildren(); return; }
181
- const matches = state.payload.graph.nodes.filter((node) => `${node.label} ${node.path ?? ''} ${node.id}`.toLocaleLowerCase().includes(query)).slice(0, 30);
182
- elements.searchResults.replaceChildren(...matches.map((node) => {
183
- const item = document.createElement('li'); const button = document.createElement('button'); button.type = 'button'; button.textContent = node.label ?? node.id;
184
- button.addEventListener('click', () => { selectNode(node.id); elements.searchResults.replaceChildren(); });
185
- item.append(button); return item;
186
- }));
187
- }
188
-
189
- function renderInspection(node) {
190
- if (!node) return;
191
- const score = node.seed ? 100 : node.impactScore;
192
- const reasons = (node.reasons ?? []).map(relationLabel).join(', ') || 'whole graph node';
193
- const ppr = node.scoreBreakdown?.connection?.ppr;
194
- const memory = node.scoreBreakdown?.memory;
195
- elements.inspection.innerHTML = `<h3>${safe(node.label ?? node.path ?? node.id)}</h3><dl><dt>Type</dt><dd>${safe(node.type)}</dd><dt>Impact score</dt><dd>${safe(String(score))}</dd><dt>Evidence</dt><dd>${safe(reasons)}</dd><dt>Component</dt><dd>${safe(String((node.component ?? 0) + 1))}</dd>${ppr !== undefined ? `<dt>PPR contribution</dt><dd>${safe(String(ppr))}</dd>` : ''}${memory ? `<dt>Memory contribution</dt><dd>${safe(String((memory.priority ?? 0) + (memory.policyAdjustments ?? 0)))}</dd>` : ''}${node.vectorEvidence?.score ? `<dt>Similarity</dt><dd>${safe(String(node.vectorEvidence.score))}</dd>` : ''}<dt>Path</dt><dd>${safe(node.path ?? node.id)}</dd></dl>${node.path && !node.seed ? '<button type="button" class="reroot-selected">Re-root from this file</button>' : ''}`;
196
- elements.inspection.querySelector('.reroot-selected')?.addEventListener('click', () => loadImpact([node.path]));
197
- }
198
-
199
- function selectNode(id, focusList = false) {
200
- state.selected = id;
201
- state.renderer?.refresh();
202
- for (const button of elements.list.querySelectorAll('.result-button')) button.setAttribute('aria-current', button.dataset.nodeId === id ? 'true' : 'false');
203
- if (focusList) elements.list.querySelector(`[data-node-id="${CSS.escape(id)}"]`)?.scrollIntoView({ block: 'nearest' });
204
- renderInspection(state.payload.graph.nodes.find((node) => node.id === id));
205
- history.replaceState(null, '', `#${encodeURIComponent(id)}`);
206
- }
207
-
208
- async function loadImpact(paths) {
209
- elements.status.textContent = 'Calculating impact…';
210
- const query = paths.map((path) => `changed=${encodeURIComponent(path)}`).join('&');
211
- const response = await fetch(`/api/reroot?${query}`);
212
- const payload = await response.json();
213
- if (!response.ok || !payload.ok) { elements.status.textContent = payload.error?.message ?? 'Impact calculation failed.'; return; }
214
- applyPayload(payload);
215
- }
216
-
217
- function applyPayload(payload) {
218
- state.payload = payload;
219
- state.selected = payload.graph.changedFiles.length === 1 ? `file:${payload.graph.changedFiles[0]}` : null;
220
- elements.input.value = payload.graph.changedFiles[0] ?? '';
221
- elements.status.textContent = payload.impact.semantic.status === 'available' ? 'Shared structural graph · semantic ranking available' : 'Shared structural graph · structural ranking';
222
- const omitted = payload.graph.diagnostics?.disconnectedNodesOmitted ?? 0;
223
- const depth = payload.graph.diagnostics?.maximumDepth ?? 0;
224
- elements.summary.textContent = `${payload.graph.nodes.length} connected nodes · ${payload.graph.edges.length} relations · ${depth} hops · ${omitted} disconnected omitted`;
225
- const warnings = payload.impact.warnings ?? { total: 0, omitted: 0, items: [] };
226
- document.querySelector('#reference-warnings').hidden = warnings.total === 0;
227
- document.querySelector('#reference-warning-summary').textContent = `${warnings.total} reference warning(s), ${warnings.omitted} omitted. Broken references are excluded; uncertain access is not proof of breakage.`;
228
- const warningList = document.querySelector('#reference-warning-list');
229
- warningList.replaceChildren(...warnings.items.map((warning) => {
230
- const item = document.createElement('li');
231
- item.textContent = `${warning.source}${warning.line ? `:${warning.line}` : ''} → ${warning.target} [${warning.reason}]`;
232
- return item;
233
- }));
234
- renderFilters(payload.graph.edges); renderGraph(); renderList(); renderSearch();
235
- if (state.selected && payload.graph.nodes.some((node) => node.id === state.selected)) renderInspection(payload.graph.nodes.find((node) => node.id === state.selected));
236
- else if (payload.graph.nodes.length === 0) elements.inspection.innerHTML = '<h3>No shared structural nodes</h3><p>Add indexable shared files or relations to populate this graph. Local memory and headings are intentionally hidden.</p>';
237
- }
238
-
239
- elements.motionMode.value = state.motionMode;
240
- elements.motionMode.addEventListener('change', () => {
241
- state.motionMode = MOTION_MODES.has(elements.motionMode.value) ? elements.motionMode.value : 'layer';
242
- localStorage.setItem('dotdotgod:graph-motion', state.motionMode);
243
- state.firstRender = true;
244
- state.previousPositions.clear();
245
- renderGraph();
246
- });
247
- elements.skipReveal.addEventListener('click', () => finishReveal(state.positioned));
248
- elements.form.addEventListener('submit', (event) => { event.preventDefault(); const path = elements.input.value.trim(); if (path) loadImpact([path]); });
249
- elements.reset.addEventListener('click', () => state.renderer?.getCamera().animatedReset({ duration: matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 250 }));
250
- elements.search.addEventListener('input', renderSearch);
251
- addEventListener('resize', () => state.renderer?.refresh());
252
- fetch('/api/impact').then((response) => response.json()).then(applyPayload).catch((error) => { console.error(error); elements.status.textContent = 'Unable to load impact data.'; });
253
- })();
1
+ // Standalone renderer. No Obsidian application or internal code required.
2
+ const $=id=>document.getElementById(id),ko=navigator.language.startsWith('ko');
3
+ if(ko){document.documentElement.lang='ko';for(const [id,value]of Object.entries({'title':'그래프 옵션','documents-label':'문서만 표시','colors-label':'색상 범례 표시','weights-label':'가중치로 선 두께 표시',reload:'추적관계 새로고침',copy:'경로 복사'}))$(id).textContent=value;}
4
+ const canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
5
+ const fileList = new FileList($('files'), select);
6
+ let data,nodes=[],edges=[],simulation,hover,selected,scale=1,pan=[0,0],drag;
7
+ let disabled=new Set();
8
+ let topology=null;
9
+ function plotArea(){
10
+ const panel=document.querySelector('aside').getBoundingClientRect();
11
+ const header=document.querySelector('header').getBoundingClientRect();
12
+ const left=16,top=header.bottom+16;
13
+ const right=innerWidth>700?panel.left-16:innerWidth-16;
14
+ const bottom=innerWidth>700?innerHeight-16:panel.top-16;
15
+ return {x:(left+right)/2,y:(top+bottom)/2,width:Math.max(1,right-left),height:Math.max(1,bottom-top)};
16
+ }
17
+ function fitGraph(){const fit=graphFit(nodes,plotArea());scale=fit.scale;pan=fit.pan;}
18
+ try{const s=JSON.parse(localStorage.getItem('dd-graph')||'{}');disabled=new Set(s.disabled||[]);for(const id of ['documents','colors','weights'])if(id in s)$(id).checked=s[id];}catch{}
19
+ function save(){try{localStorage.setItem('dd-graph',JSON.stringify({disabled:[...disabled],...Object.fromEntries(['documents','colors','weights'].map(id=>[id,$(id).checked]))}));}catch{}}
20
+ function rebuild(){
21
+ const filtered=selectTraceability(data,{documentsOnly:$('documents').checked,disabledRelations:[...disabled]});
22
+ const ids=new Set(filtered.paths), incoming=data.nodes.filter(n=>ids.has(n.id));
23
+ const nextTopology=layoutKey(incoming,filtered.edges);
24
+ if(topology===nextTopology){
25
+ const updates=new Map(incoming.map(n=>[n.id,n]));
26
+ nodes.forEach(n=>Object.assign(n,updates.get(n.id)));
27
+ const byId=new Map(nodes.map(n=>[n.id,n]));
28
+ edges=filtered.edges.map(e=>({...e,source:byId.get(e.source),target:byId.get(e.target)}));
29
+ }else{
30
+ const previous=new Map(nodes.map(n=>[n.id,n]));
31
+ simulation?.stop();
32
+ nodes=incoming.map(n=>({...n,x:previous.get(n.id)?.x,y:previous.get(n.id)?.y}));
33
+ edges=filtered.edges.map(e=>({...e}));
34
+ simulation=createGraphLayout(d3,nodes,edges);
35
+ simulation.stop();for(let i=0;i<240;i++)simulation.tick();
36
+ fitGraph();
37
+ simulation.on('tick',draw).restart();
38
+ topology=nextTopology;
39
+ }
40
+ $('legend').replaceChildren();for(const t of data.types){const b=document.createElement('button');b.setAttribute('aria-pressed',String(!disabled.has(t.relation)));const s=document.createElement('span');s.className='swatch';s.style.background=t.color;b.append(s,document.createTextNode(t.label));b.onclick=()=>{disabled.has(t.relation)?disabled.delete(t.relation):disabled.add(t.relation);save();rebuild();};$('legend').append(b);}
41
+ for(const [group,color]of new Map(data.nodes.filter(n=>n.group).map(n=>[n.group,n.color]))){const s=document.createElement('span');s.textContent='● '+group;s.style.color=color;$('legend').append(s);}
42
+ $('legend').hidden=!$('colors').checked;hover=null;$('tooltip').hidden=true;selected=null;$('copy').disabled=true;$('path').textContent='';list();$('status').textContent=`${nodes.length} files · ${edges.length} connections`;draw();
43
+ }
44
+ function select(n){selected=n;$('path').textContent=n.id;$('copy').disabled=false;fileList.setSelected(n.id);draw();}
45
+ function list(){if(!data)return;fileList.setItems(nodes.filter(n=>n.id.toLowerCase().includes($('search').value.toLowerCase())));fileList.setSelected(selected?.id);}
46
+ function draw(){const w=innerWidth,h=innerHeight,d=devicePixelRatio||1;if(canvas.width!==w*d||canvas.height!==h*d){canvas.width=w*d;canvas.height=h*d;}ctx.setTransform(d,0,0,d,0,0);ctx.clearRect(0,0,w,h);const area=plotArea();ctx.translate(area.x+pan[0],area.y+pan[1]);ctx.scale(scale,scale);
47
+ for(const e of edges){const a=e.source,b=e.target;ctx.strokeStyle=ctx.fillStyle=e.color;ctx.lineWidth=($('weights').checked?Math.max(1,Math.min(6,e.weight)):1)/scale;ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();const angle=Math.atan2(b.y-a.y,b.x-a.x),x=b.x-Math.cos(angle)*8,y=b.y-Math.sin(angle)*8;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo(x-Math.cos(angle-.45)*8,y-Math.sin(angle-.45)*8);ctx.lineTo(x-Math.cos(angle+.45)*8,y-Math.sin(angle+.45)*8);ctx.fill();}
48
+ for(const n of nodes){ctx.fillStyle=n.color||'#c6ccd8';ctx.beginPath();ctx.arc(n.x,n.y,n===hover||n===selected?8:5,0,Math.PI*2);ctx.fill();if((scale>1.5&&nodes.length<60)||n===hover||n===selected){ctx.font=`${12/scale}px monospace`;ctx.textAlign='center';ctx.fillStyle='#d8dee9';ctx.fillText(n.label,n.x,n.y+20/scale);}}}
49
+ function point(e){const area=plotArea();return[(e.clientX-area.x-pan[0])/scale,(e.clientY-area.y-pan[1])/scale];}
50
+ function hit(e){const[x,y]=point(e);return nodes.find(n=>Math.hypot(n.x-x,n.y-y)<12/scale);}
51
+ canvas.onpointerdown=e=>{drag={node:hit(e),x:e.clientX,y:e.clientY};canvas.setPointerCapture(e.pointerId);};
52
+ canvas.onpointermove=e=>{if(drag){if(drag.node){const[x,y]=point(e);drag.node.fx=x;drag.node.fy=y;simulation.alpha(.2).restart();}else{pan[0]+=e.clientX-drag.x;pan[1]+=e.clientY-drag.y;}drag.x=e.clientX;drag.y=e.clientY;}hover=hit(e);const t=$('tooltip');t.hidden=!hover||!!drag;if(hover){t.textContent=hover.label;t.style.left=Math.max(8,Math.min(e.clientX+12,innerWidth-t.offsetWidth-8))+'px';t.style.top=Math.max(8,Math.min(e.clientY+18,innerHeight-t.offsetHeight-8))+'px';}draw();};
53
+ canvas.onpointerup=()=>{if(drag?.node){select(drag.node);drag.node.fx=null;drag.node.fy=null;}drag=null;};canvas.onpointercancel=()=>{if(drag?.node){drag.node.fx=null;drag.node.fy=null;}drag=null;};canvas.onpointerleave=()=>{$('tooltip').hidden=true;hover=null;draw();};
54
+ canvas.addEventListener('wheel',e=>{
55
+ e.preventDefault();
56
+ const [x,y]=point(e),area=plotArea(),fit=graphFit(nodes,area);
57
+ const minScale=Math.min(.01,fit.scale/10);
58
+ scale=Math.max(minScale,Math.min(6,scale*Math.exp(-e.deltaY*.001)));
59
+ pan=[e.clientX-area.x-x*scale,e.clientY-area.y-y*scale];
60
+ if(e.deltaY>0&&scale<=fit.scale)pan=keepGraphInView(nodes,area,scale,pan);
61
+ draw();
62
+ },{passive:false});
63
+ for(const id of ['documents','colors','weights'])$(id).onchange=()=>{save();if(data)rebuild();};$('search').oninput=list;$('copy').onclick=async()=>{try{await navigator.clipboard.writeText(selected.id);}catch{$('status').textContent=selected.id;}};
64
+ async function load(){
65
+ if($('reload').disabled)return;
66
+ $('reload').disabled=true;
67
+ const previous=data;
68
+ try{data=await fetchGraph();rebuild();}
69
+ catch(error){
70
+ data=previous;
71
+ $('status').textContent=(ko?'새로고침 실패. 서버를 확인한 뒤 다시 시도하세요. ':'Reload failed. Check the server and try again. ')+error.message;
72
+ console.error(error);
73
+ }finally{$('reload').disabled=false;}
74
+ }
75
+ $('reload').onclick=load;addEventListener('resize',()=>{fitGraph();draw();});load();
@@ -0,0 +1,82 @@
1
+ // Fixed-height windowing; the complete filtered set remains keyboard-navigable.
2
+ function fileWindow(count, scrollTop, height, rowHeight = 36, overscan = 5) {
3
+ const start = Math.max(0, Math.min(Math.floor(scrollTop / rowHeight) - overscan, Math.max(0, count - 1)));
4
+ return { start, end: Math.min(count, start + Math.ceil(height / rowHeight) + overscan * 2) };
5
+ }
6
+
7
+ class FileList {
8
+ constructor(element, onSelect) {
9
+ this.element = element;
10
+ this.onSelect = onSelect;
11
+ this.items = [];
12
+ this.focused = 0;
13
+ this.selected = null;
14
+ this.rowHeight = 36;
15
+ element.setAttribute('aria-label', document.documentElement.lang === 'ko' ? '파일 목록' : 'Files');
16
+ element.addEventListener('scroll', () => this.render());
17
+ element.addEventListener('keydown', event => {
18
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
19
+ if (!keys.includes(event.key) || !this.items.length) return;
20
+ event.preventDefault();
21
+ const delta = event.key === 'ArrowDown' ? 1 : -1;
22
+ this.focused = event.key === 'Home' ? 0 : event.key === 'End' ? this.items.length - 1 : Math.max(0, Math.min(this.items.length - 1, this.focused + delta));
23
+ const top = this.focused * this.rowHeight;
24
+ if (top < element.scrollTop) element.scrollTop = top;
25
+ else if (top + this.rowHeight > element.scrollTop + element.clientHeight) element.scrollTop = top + this.rowHeight - element.clientHeight;
26
+ this.render(true);
27
+ });
28
+ this.observer = new ResizeObserver(() => this.render());
29
+ this.observer.observe(element);
30
+ }
31
+
32
+ setItems(items) {
33
+ this.items = items;
34
+ this.focused = 0;
35
+ this.element.scrollTop = 0;
36
+ this.render();
37
+ }
38
+
39
+ setSelected(id) {
40
+ this.selected = id;
41
+ for (const button of this.element.querySelectorAll('button')) {
42
+ button.setAttribute('aria-pressed', String(button.dataset.path === id));
43
+ }
44
+ }
45
+
46
+ render(focus = false) {
47
+ const element = this.element;
48
+ const current = element.ownerDocument.activeElement;
49
+ const restoreFocus = focus || element.contains(current);
50
+ const { start, end } = fileWindow(this.items.length, element.scrollTop, element.clientHeight);
51
+ if (this.focused < start || this.focused >= end) this.focused = start;
52
+ const fragment = document.createDocumentFragment();
53
+ const spacer = height => {
54
+ const li = document.createElement('li');
55
+ li.setAttribute('aria-hidden', 'true');
56
+ li.style.height = `${height}px`;
57
+ fragment.append(li);
58
+ };
59
+ spacer(start * this.rowHeight);
60
+ for (let index = start; index < end; index++) {
61
+ const item = this.items[index];
62
+ const li = document.createElement('li');
63
+ li.setAttribute('aria-posinset', String(index + 1));
64
+ li.setAttribute('aria-setsize', String(this.items.length));
65
+ const button = document.createElement('button');
66
+ button.textContent = item.label;
67
+ button.title = item.id;
68
+ button.dataset.path = item.id;
69
+ button.tabIndex = index === this.focused ? 0 : -1;
70
+ button.setAttribute('aria-pressed', String(item.id === this.selected));
71
+ button.addEventListener('focus', () => { this.focused = index; });
72
+ button.onclick = () => this.onSelect(item);
73
+ li.append(button);
74
+ fragment.append(li);
75
+ }
76
+ spacer((this.items.length - end) * this.rowHeight);
77
+ element.replaceChildren(fragment);
78
+ if (restoreFocus) element.querySelector('button[tabindex="0"]')?.focus({ preventScroll: true });
79
+ }
80
+ }
81
+
82
+ if (typeof module !== 'undefined') module.exports = { fileWindow };
@@ -0,0 +1,21 @@
1
+ async function fetchGraph(fetcher = fetch) {
2
+ let response;
3
+ try { response = await fetcher('/api/graph', { cache: 'no-store' }); }
4
+ catch { throw new Error('NETWORK: graph server is unavailable'); }
5
+ let graph;
6
+ try { graph = await response.json(); }
7
+ catch { throw new Error(`HTTP ${response.status}: invalid graph response; check the server URL and version`); }
8
+ if (!response.ok) {
9
+ const details = typeof graph.error?.message === 'string' ? graph.error.message.slice(0, 800) : 'graph request failed';
10
+ throw new Error(`HTTP ${response.status}: ${details}`);
11
+ }
12
+ if (graph.version !== 1 || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges) || !Array.isArray(graph.types)) {
13
+ throw new Error('INVALID_GRAPH: incompatible graph data; reload the page and server');
14
+ }
15
+ const paths = new Set(graph.nodes.map(node => node.id));
16
+ if (graph.edges.some(edge => !paths.has(edge.source) || !paths.has(edge.target))) {
17
+ throw new Error('INVALID_GRAPH: connection endpoint is missing');
18
+ }
19
+ return graph;
20
+ }
21
+ if (typeof module !== 'undefined') module.exports = { fetchGraph };
@@ -1,78 +1,21 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>dotdotgod structural graph</title>
7
- <link rel="stylesheet" href="/app.css">
8
- </head>
9
- <body>
10
- <header class="command-rail">
11
- <div class="brand"><span class="brand-mark" aria-hidden="true"></span><strong>dotdotgod</strong><span>structural graph</span></div>
12
- <form id="reroot-form" class="reroot-form">
13
- <label for="reroot-path">Re-root graph</label>
14
- <input id="reroot-path" name="changed" autocomplete="off" placeholder="packages/cli/src/core.mjs">
15
- <button type="submit">Inspect file</button>
16
- </form>
17
- <p id="status" role="status">Loading impact data…</p>
18
- </header>
19
-
20
- <main class="workspace">
21
- <aside class="filters" aria-labelledby="filters-title">
22
- <div>
23
- <h1 id="filters-title">Relation signals</h1>
24
- <p>Filter what is drawn without changing the impact ranking.</p>
25
- </div>
26
- <fieldset id="relation-filters"><legend class="sr-only">Visible relation types</legend></fieldset>
27
- <label class="motion-control" for="motion-mode">Node reveal</label>
28
- <select id="motion-mode">
29
- <option value="instant">Instant</option>
30
- <option value="layer">By hop layer</option>
31
- <option value="sequence">One by one</option>
32
- </select>
33
- <button id="skip-reveal" type="button" hidden>Skip reveal</button>
34
- <div class="legend" aria-label="Node legend">
35
- <span><i class="node-key seed"></i>Changed root</span>
36
- <span><i class="node-key doc"></i>Documentation</span>
37
- <span><i class="node-key test"></i>Verification</span>
38
- <span><i class="node-key code"></i>Source</span>
39
- <span><i class="node-key contract"></i>Contract</span>
40
- <span><i class="node-key heading"></i>Heading</span>
41
- </div>
42
- <p class="reading-note">The graph grows outward from changed-file roots by structural hop. Position is layout only, never rank.</p>
43
- </aside>
44
-
45
- <section class="plot" aria-labelledby="plot-title">
46
- <div class="plot-heading">
47
- <div><h2 id="plot-title">Impact field</h2><p id="plot-summary"></p></div>
48
- <button id="reset-camera" type="button">Reset view</button>
49
- </div>
50
- <div id="graph" aria-hidden="true"></div>
51
- <div id="graph-fallback" class="graph-fallback" hidden>WebGL is unavailable. Use the synchronized ranked list.</div>
52
- </section>
53
-
54
- <aside class="evidence" aria-labelledby="evidence-title">
55
- <section class="node-search" aria-labelledby="node-search-title">
56
- <label id="node-search-title" for="node-search-input">Search shared graph nodes</label>
57
- <input id="node-search-input" type="search" autocomplete="off" placeholder="Path, title, or node ID" aria-controls="node-search-results">
58
- <ul id="node-search-results" class="node-search-results"></ul>
59
- </section>
60
- <div class="evidence-heading"><h2 id="evidence-title">Ranked evidence</h2><span id="result-count"></span></div>
61
- <section id="reference-warnings" class="reference-warnings" aria-live="polite" hidden>
62
- <h3>Reference warnings</h3>
63
- <p id="reference-warning-summary"></p>
64
- <ul id="reference-warning-list"></ul>
65
- </section>
66
- <ol id="result-list" class="result-list"></ol>
67
- <section id="inspection" class="inspection" aria-live="polite">
68
- <h3>Select an impacted file</h3>
69
- <p>The graph and ranked list share one selection. Details will appear here.</p>
70
- </section>
71
- </aside>
72
- </main>
73
- <script src="/vendor/graphology.js"></script>
74
- <script src="/vendor/sigma.js"></script>
75
- <script src="/vendor/d3.js"></script>
76
- <script src="/app.js"></script>
77
- </body>
3
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
+ <title>dotdotgod Traceability</title><link rel="stylesheet" href="/app.css">
5
+ <header><h1>dotdotgod · Traceability</h1><div id="legend"></div></header>
6
+ <canvas aria-label="File relationship graph"></canvas>
7
+ <aside>
8
+ <details open><summary id="title">Graph options</summary>
9
+ <label><input id="documents" type="checkbox"><span id="documents-label">Documents only</span></label>
10
+ <label><input id="colors" type="checkbox" checked><span id="colors-label">Show color legend</span></label>
11
+ <label><input id="weights" type="checkbox" checked><span id="weights-label">Weight-based line width</span></label>
12
+ <button id="reload">Reload</button>
13
+ </details>
14
+ <p id="status" role="status"></p>
15
+ <input id="search" aria-label="Search files" placeholder="Search files">
16
+ <ul id="files"></ul><p id="path"></p><button id="copy" disabled>Copy path</button>
17
+ </aside>
18
+ <div id="tooltip" hidden></div>
19
+ <script src="/vendor/d3.js"></script><script src="/policy.js"></script>
20
+ <script src="/file-list.js"></script><script src="/graph-client.js"></script><script src="/layout.js"></script><script src="/app.js"></script>
78
21
  </html>
@@ -0,0 +1,47 @@
1
+ function layoutKey(nodes, edges) {
2
+ return JSON.stringify([
3
+ nodes.map(node => node.id).sort(),
4
+ edges.map(edge => [typeof edge.source === 'string' ? edge.source : edge.source.id,
5
+ typeof edge.target === 'string' ? edge.target : edge.target.id]).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
6
+ ]);
7
+ }
8
+
9
+ function createGraphLayout(d3, nodes, edges) {
10
+ return d3.forceSimulation(nodes)
11
+ .force('link', d3.forceLink(edges).id(node => node.id).distance(110))
12
+ .force('charge', d3.forceManyBody().strength(-180).distanceMax(450))
13
+ .force('center', d3.forceCenter())
14
+ .force('x', d3.forceX(0).strength(.04))
15
+ .force('y', d3.forceY(0).strength(.04))
16
+ .force('collision', d3.forceCollide(18));
17
+ }
18
+
19
+ function graphFit(nodes, viewport) {
20
+ const points = nodes.filter(node => Number.isFinite(node.x) && Number.isFinite(node.y));
21
+ if (!points.length) return { scale: 1, pan: [0, 0] };
22
+ let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
23
+ for (const node of points) {
24
+ left = Math.min(left, node.x); right = Math.max(right, node.x);
25
+ top = Math.min(top, node.y); bottom = Math.max(bottom, node.y);
26
+ }
27
+ const scale = Math.min(1, Math.max(1, viewport.width - 64) / Math.max(1, right - left + 32),
28
+ Math.max(1, viewport.height - 64) / Math.max(1, bottom - top + 32));
29
+ return { scale, pan: [-(left + right) / 2 * scale, -(top + bottom) / 2 * scale] };
30
+ }
31
+
32
+ function keepGraphInView(nodes, viewport, scale, pan) {
33
+ if (!nodes.length) return pan;
34
+ let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
35
+ for (const node of nodes) {
36
+ left = Math.min(left, node.x); right = Math.max(right, node.x);
37
+ top = Math.min(top, node.y); bottom = Math.max(bottom, node.y);
38
+ }
39
+ const constrain = (min, max, size, offset) => {
40
+ const low = -size / 2 + 16 - min * scale;
41
+ const high = size / 2 - 16 - max * scale;
42
+ return low <= high ? Math.max(low, Math.min(high, offset)) : offset;
43
+ };
44
+ return [constrain(left, right, viewport.width, pan[0]), constrain(top, bottom, viewport.height, pan[1])];
45
+ }
46
+
47
+ if (typeof module !== 'undefined') module.exports = { layoutKey, createGraphLayout, graphFit, keepGraphInView };
@@ -0,0 +1,12 @@
1
+ const palette = ['#b9a3ff', '#70c9a2', '#72b9e6', '#d6b879', '#e393a9', '#78c9cd', '#a6c67a', '#e5a475'];
2
+ function describeNode(path, root = 'docs') {
3
+ root = root.replace(/^\.\//, '').replace(/\/$/, '');
4
+ if (!path.startsWith(`${root}/`)) return { label: path, group: null, color: null };
5
+ const parts = path.slice(root.length + 1).split('/');
6
+ const group = parts.length === 1 ? root : parts.shift();
7
+ let hash = 0;
8
+ for (const char of group) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) >>> 0;
9
+ const known = { docs: '#b9a3ff', spec: '#72b9e6', arch: '#d6b879', test: '#70c9a2', concept: '#e393a9', report: '#78c9cd' };
10
+ return { label: parts.join('/'), group, color: known[group] ?? palette[hash % palette.length] };
11
+ }
12
+ module.exports = { describeNode };