Add Go HTTP/SSE API server with embedded frontend
Implements the API routes (jobs, progress events, snapshots, search, entities, source delivery with release) to net/http and serves the static frontend from the same binary on the same origin.
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
export function apiFetch(url, options = {}) {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('ngrok-skip-browser-warning', 'true');
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
|
||||
// Read SSE over fetch so progress requests can carry the same headers as JSON.
|
||||
export async function* progressEvents(response) {
|
||||
if (!response.ok || !response.body || !response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
throw new Error('Could not connect to indexing progress.');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', event = '', data = [];
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done });
|
||||
let end;
|
||||
while ((end = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, end).replace(/\r$/, '');
|
||||
buffer = buffer.slice(end + 1);
|
||||
if (!line) {
|
||||
if (event === 'progress' && data.length) yield JSON.parse(data.join('\n'));
|
||||
event = ''; data = [];
|
||||
} else if (line.startsWith('event:')) event = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
import { LandscapeRenderer } from './renderer.js';
|
||||
import { apiFetch, progressEvents } from './api.mjs';
|
||||
|
||||
const configuredBackend=window.CODENAVIGATOR_CONFIG?.backendUrl?.trim()||'';
|
||||
const API_BASE=configuredBackend.replace(/\/+$/,'');
|
||||
const apiUrl=path=>`${API_BASE}${path}`;
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
document.addEventListener('pointerdown',()=>{document.documentElement.dataset.inputMethod='pointer';},true);
|
||||
document.addEventListener('keydown',()=>{document.documentElement.dataset.inputMethod='keyboard';},true);
|
||||
const glCanvas = $('#glCanvas');
|
||||
const overlay = $('#overlayCanvas');
|
||||
const overlayCtx = overlay.getContext('2d');
|
||||
const viewport = $('#viewport');
|
||||
const MAX_2D_ZOOM=260;
|
||||
const MIN_3D_DISTANCE=.52;
|
||||
let renderer;
|
||||
try { renderer = new LandscapeRenderer(glCanvas); }
|
||||
catch (error) { $('#emptyState').hidden=false; $('#emptyState strong').textContent='WebGL2 is unavailable'; $('#emptyState span').textContent=error.message; throw error; }
|
||||
|
||||
const palettes = [
|
||||
{ application:'#1fbf8c', library:'#28aa8b', test:'#c7a62e', generated:'#cc7741', vendor:'#4e83bc', platform:'#d05492', docs:'#8d9561', unknown:'#65706a' },
|
||||
{ application:'#48a9e6', library:'#7874db', test:'#e6a04b', generated:'#d6627c', vendor:'#45b39d', platform:'#b781d4', docs:'#9ca35c', unknown:'#697277' },
|
||||
{ application:'#87b95d', library:'#39a5a0', test:'#e2bf52', generated:'#e37d52', vendor:'#527ac2', platform:'#c76699', docs:'#a4a4a4', unknown:'#6b706d' },
|
||||
];
|
||||
let paletteIndex=0;
|
||||
let files=[];
|
||||
let edges=[];
|
||||
let layoutItems=[];
|
||||
let directoryRects=[];
|
||||
let fileById=new Map();
|
||||
let layoutById=new Map();
|
||||
let referenceDegree=new Map();
|
||||
let edgesByFile=new Map();
|
||||
let sceneStats={definitions:0,totalLines:0,known:0,inferred:0,layers:new Map()};
|
||||
let spatialGrid=new Map();
|
||||
let visibleScreenItems=[];
|
||||
let overviewCanvas=null,overviewContext=null,overviewIndex=0;
|
||||
let codeTextureBytes=0;
|
||||
const codeTextureLru=new Map(),codeTextureBudget=192*1024*1024,overviewScale=3;
|
||||
let selected=null;
|
||||
let history=[];
|
||||
let searchHits=[];
|
||||
let expandedCoverageLayer=null;
|
||||
let coverageActiveIndex=-1;
|
||||
let cameraAnimation=null;
|
||||
let currentSnapshot=null;
|
||||
let currentName='';
|
||||
let currentMetric='lines';
|
||||
let showCode=true;
|
||||
let showLegend=false;
|
||||
let dirty=true;
|
||||
let pointer={down:false,x:0,y:0,startX:0,startY:0,action:'orbit',id:null};
|
||||
let lastFrame=performance.now(), frameSamples=[];
|
||||
|
||||
const layerLabels = { application:'Application',library:'Libraries',test:'Tests',generated:'Generated',vendor:'Vendor',platform:'Platform',docs:'Documentation',unknown:'Other' };
|
||||
|
||||
function toRgb(hex){const value=parseInt(hex.slice(1),16);return[((value>>16)&255)/255,((value>>8)&255)/255,(value&255)/255];}
|
||||
function fileWeight(file){
|
||||
if(currentMetric==='references'){
|
||||
const degree=referenceDegree.get(file.id)||0;
|
||||
return Math.max(1,degree*55+(file.symbolCount??file.symbols?.length??0)*7);
|
||||
}
|
||||
return Math.max(1,file.lines);
|
||||
}
|
||||
|
||||
function makeTree(list){
|
||||
// Repositories frequently keep source files at their root. Root participates
|
||||
// in the same layout contract as every child directory.
|
||||
const root={name:currentName,path:'',children:new Map(),files:[],weight:0,depth:0};
|
||||
for(const file of list){
|
||||
const parts=file.path.split('/'), filename=parts.pop(); let node=root;
|
||||
for(const part of parts){
|
||||
if(!node.children.has(part)) node.children.set(part,{name:part,path:node.path?`${node.path}/${part}`:part,children:new Map(),files:[],weight:0,depth:node.depth+1});
|
||||
node=node.children.get(part);
|
||||
}
|
||||
node.files.push({name:filename,file,weight:fileWeight(file)});
|
||||
}
|
||||
function weigh(node){
|
||||
let weight=0;const layers=new Map();
|
||||
for(const item of node.files||[]){weight+=item.weight;const layer=item.file.layer in layerLabels?item.file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+item.weight);}
|
||||
for(const child of node.children.values()){const childWeight=weigh(child);weight+=childWeight;layers.set(child.layer,(layers.get(child.layer)||0)+childWeight);}
|
||||
node.weight=weight;node.layer=[...layers.entries()].sort((a,b)=>b[1]-a[1])[0]?.[0]||'unknown';return weight;
|
||||
}
|
||||
weigh(root); return root;
|
||||
}
|
||||
|
||||
function squarifiedLayout(entries,x,y,w,h,depth,output){
|
||||
if(!entries.length||w<=0||h<=0)return;
|
||||
const total=entries.reduce((sum,entry)=>sum+Math.max(.0001,entry.weight),0),scale=w*h/total;
|
||||
const remaining=entries.map(entry=>({entry,area:Math.max(.0001,entry.weight)*scale}));
|
||||
const worst=(row,side)=>{
|
||||
if(!row.length)return Infinity;
|
||||
const sum=row.reduce((value,item)=>value+item.area,0),largest=Math.max(...row.map(item=>item.area)),smallest=Math.min(...row.map(item=>item.area)),sideSquared=side*side;
|
||||
return Math.max(sideSquared*largest/(sum*sum),(sum*sum)/(sideSquared*smallest));
|
||||
};
|
||||
const place=row=>{
|
||||
const area=row.reduce((value,item)=>value+item.area,0);
|
||||
if(w>=h){
|
||||
const rowWidth=Math.min(w,area/Math.max(.0001,h));let rowY=y;
|
||||
for(const item of row){const itemHeight=item.area/Math.max(.0001,rowWidth);output(item.entry,x,rowY,rowWidth,itemHeight,depth);rowY+=itemHeight;}
|
||||
x+=rowWidth;w=Math.max(0,w-rowWidth);
|
||||
}else{
|
||||
const rowHeight=Math.min(h,area/Math.max(.0001,w));let rowX=x;
|
||||
for(const item of row){const itemWidth=item.area/Math.max(.0001,rowHeight);output(item.entry,rowX,y,itemWidth,rowHeight,depth);rowX+=itemWidth;}
|
||||
y+=rowHeight;h=Math.max(0,h-rowHeight);
|
||||
}
|
||||
};
|
||||
let row=[];
|
||||
while(remaining.length){
|
||||
const next=remaining[0],side=Math.max(.0001,Math.min(w,h));
|
||||
if(!row.length||worst([...row,next],side)<=worst(row,side)){row.push(remaining.shift());}
|
||||
else{place(row);row=[];}
|
||||
}
|
||||
if(row.length)place(row);
|
||||
}
|
||||
|
||||
function computeLayout(){
|
||||
const root=makeTree(files); layoutItems=[];directoryRects=[];
|
||||
function visit(node,x,y,w,h,depth){
|
||||
directoryRects.push({node,x,y,w,h,depth});
|
||||
const pad=Math.min(3.2,1.2+depth*.35,w*.07,h*.07),header=Math.min(8,Math.max(0,h*.05)),ix=x+pad,iy=y+pad+header,iw=Math.max(.01,w-pad*2),ih=Math.max(.01,h-pad*2-header);
|
||||
const values=[...node.children.values(),...(node.files||[])],total=values.reduce((sum,value)=>sum+value.weight,0);
|
||||
// A file should be compared with its siblings, not visually erased by the
|
||||
// aggregate weight of a huge neighboring subtree. The local floor keeps
|
||||
// every entry readable while leaving at least 87.5% of each region fully
|
||||
// proportional to the selected metric.
|
||||
const floor=total/Math.max(1,values.length*8);
|
||||
const entries=values.map(value=>({value,weight:Math.max(value.weight,floor)})).sort((a,b)=>b.weight-a.weight);
|
||||
squarifiedLayout(entries,ix,iy,iw,ih,depth,(wrapped,rx,ry,rw,rh)=>{
|
||||
const entry=wrapped.value;
|
||||
if(entry.file){
|
||||
const inset=Math.min(1,Math.min(rw,rh)*.08),layer=entry.file.layer in palettes[paletteIndex]?entry.file.layer:'unknown';
|
||||
layoutItems.push({...entry.file,x:rx+inset,y:ry+inset,w:Math.max(.1,rw-inset*2),h:Math.max(.1,rh-inset*2),color:toRgb(palettes[paletteIndex][layer]),height:4+Math.log2(entry.file.lines+entry.file.complexity*5)*5});
|
||||
}else visit(entry,rx,ry,rw,rh,depth+1);
|
||||
});
|
||||
}
|
||||
visit(root,0,0,1000,680,0);
|
||||
layoutById=new Map(layoutItems.map(item=>[item.id,item]));buildSpatialGrid();resetCodeCaches();
|
||||
renderer.setData(layoutItems);dirty=true;
|
||||
}
|
||||
|
||||
function resetCodeCaches(){
|
||||
overviewCanvas=document.createElement('canvas');overviewCanvas.width=1000*overviewScale;overviewCanvas.height=680*overviewScale;overviewContext=overviewCanvas.getContext('2d',{alpha:true});overviewIndex=0;codeTextureBytes=0;codeTextureLru.clear();
|
||||
}
|
||||
|
||||
const spatialCell=50;
|
||||
function buildSpatialGrid(){
|
||||
spatialGrid=new Map();
|
||||
for(const item of layoutItems){
|
||||
const x0=Math.floor(item.x/spatialCell),x1=Math.floor((item.x+item.w)/spatialCell),y0=Math.floor(item.y/spatialCell),y1=Math.floor((item.y+item.h)/spatialCell);
|
||||
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++){const key=`${x}:${y}`,cell=spatialGrid.get(key);if(cell)cell.push(item);else spatialGrid.set(key,[item]);}
|
||||
}
|
||||
}
|
||||
function visibleLayout(w,h){
|
||||
let candidates=layoutItems;
|
||||
if(renderer.mode==='2d'&&renderer.camera.zoom>1){
|
||||
const halfW=w/(2*renderer.camera.zoom),halfH=h/(2*renderer.camera.zoom),x0=Math.floor((renderer.camera.x-halfW)/spatialCell),x1=Math.floor((renderer.camera.x+halfW)/spatialCell),y0=Math.floor((renderer.camera.y-halfH)/spatialCell),y1=Math.floor((renderer.camera.y+halfH)/spatialCell),seen=new Set(),near=[];
|
||||
for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)for(const item of spatialGrid.get(`${x}:${y}`)||[])if(!seen.has(item.id)){seen.add(item.id);near.push(item);}
|
||||
candidates=near;
|
||||
}
|
||||
const visible=[];
|
||||
for(const item of candidates){const rect=renderer.screenRect(item);if(rect.x>w||rect.y>h||rect.x+rect.w<0||rect.y+rect.h<0)continue;visible.push({item,rect});}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot){
|
||||
sourceQueue.length=0;queuedSources.clear();
|
||||
files=snapshot.files||[];for(const file of files)file.symbols||=[];edges=snapshot.edges||[];currentName=snapshot.name||'codebase';currentSnapshot=snapshot.id||null;selected=null;history=[];searchHits=[];expandedCoverageLayer=null;coverageActiveIndex=-1;cameraAnimation=null;
|
||||
fileById=new Map(files.map(file=>[file.id,file]));referenceDegree=new Map();edgesByFile=new Map();let known=0,inferred=0;for(const edge of edges){referenceDegree.set(edge.from,(referenceDegree.get(edge.from)||0)+1);referenceDegree.set(edge.to,(referenceDegree.get(edge.to)||0)+1);for(const id of [edge.from,edge.to]){const list=edgesByFile.get(id);if(list)list.push(edge);else edgesByFile.set(id,[edge]);}if(edge.confidence==='known')known++;else inferred++;}
|
||||
const layers=new Map();for(const file of files){const layer=file.layer in layerLabels?file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+1);}sceneStats={definitions:snapshot.definitions??files.reduce((n,file)=>n+(file.symbolCount||0),0),totalLines:snapshot.totalLines??files.reduce((n,file)=>n+file.lines,0),known,inferred,layers};
|
||||
computeLayout();fitScene();updateStats(snapshot);renderInspector();renderResults([]);renderHistory();
|
||||
$('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;
|
||||
}
|
||||
|
||||
function fitScene(){
|
||||
cameraAnimation=null;
|
||||
renderer.camera.x=500;renderer.camera.y=340;
|
||||
renderer.camera.zoom=Math.min((viewport.clientWidth-24)/1000,(viewport.clientHeight-24)/680);
|
||||
renderer.camera.distance=3.5;renderer.camera.yaw=-.12;renderer.camera.pitch=.76;dirty=true;
|
||||
}
|
||||
|
||||
function pan3d(dx,dy){
|
||||
const {yaw,distance}=renderer.camera;
|
||||
const scale=Math.max(.18,distance*.29);
|
||||
const cy=Math.cos(yaw),sy=Math.sin(yaw);
|
||||
// Keep the grabbed ground point beneath the pointer in screen space.
|
||||
renderer.camera.x+=(-dx*cy-dy*sy)*scale;
|
||||
renderer.camera.y+=( dx*sy-dy*cy)*scale;
|
||||
}
|
||||
|
||||
function dolly3d(amount){
|
||||
renderer.camera.distance=Math.max(MIN_3D_DISTANCE,Math.min(14,renderer.camera.distance*Math.exp(amount)));
|
||||
}
|
||||
|
||||
function updateStats(snapshot={}){
|
||||
const definitions=snapshot.definitions??files.reduce((n,file)=>n+file.symbols.length,0);
|
||||
const references=snapshot.references??edges.length;
|
||||
$('#headlineStats').textContent=`${format(definitions)} definitions · ${format(references)} references`;
|
||||
$('#fileStats').textContent=`${format(files.length)} files · ${format(snapshot.totalLines??files.reduce((n,f)=>n+f.lines,0))} lines`;
|
||||
}
|
||||
|
||||
function format(value){return Number(value||0).toLocaleString();}
|
||||
function escapeHtml(value=''){return String(value).replace(/[&<>"]/g,char=>({'&':'&','<':'<','>':'>','"':'"'}[char]));}
|
||||
|
||||
const syntaxPalette={plain:'#aab5af',comment:'#64726b',string:'#89b88f',number:'#83a8d8',keyword:'#d8a75b',type:'#79b8d8',function:'#d6cf8b',property:'#b397d5',operator:'#8d9c95',constant:'#d98585',tag:'#65b9aa'};
|
||||
const syntaxKeywords=new Set(`abstract as async await break case catch class const continue crate def default defer delete do else enum export extends extern false final finally fn for from func function get go if impl import in instanceof interface is lambda let loop match mod module mut namespace new nil None null of override package private protected pub public raise readonly require return self set static struct super switch this throw trait true try type typeof undefined union unsafe use using var virtual void where while with yield`.split(' '));
|
||||
const syntaxConstants=new Set('true false null nil none undefined nan infinity self this super'.split(' '));
|
||||
const tokenPattern=/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`|\b0x[\da-f]+\b|\b0b[01]+\b|#[\da-f]{3,8}\b|\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b|[A-Za-z_$][\w$-]*|\s+|./gi;
|
||||
|
||||
function syntaxKind(file={}){
|
||||
const ext=(file.extension||file.name?.split('.').pop()||'').toLowerCase(),language=(file.language||'').toLowerCase();
|
||||
if(['html','htm','xml','svg','vue','svelte'].includes(ext))return'markup';
|
||||
if(['css','scss','sass','less'].includes(ext))return'css';
|
||||
if(['json','jsonc','toml','yaml','yml'].includes(ext))return'data';
|
||||
if(['md','mdx','rst','txt'].includes(ext)||language==='docs')return'docs';
|
||||
if(['py','pyi','rb','r','pl','sh','bash','zsh','fish'].includes(ext))return'hash-comment';
|
||||
if(['sql','lua','hs'].includes(ext))return'dash-comment';
|
||||
return language==='other'?'plain':'code';
|
||||
}
|
||||
|
||||
function commentStart(line,kind){
|
||||
const markers=kind==='markup'?['<!--']:kind==='css'?['/*']:kind==='hash-comment'?['#']:kind==='dash-comment'?['--']:kind==='data'?['//','#']:kind==='docs'?[]:['//','/*'];
|
||||
let quote='',escaped=false;
|
||||
for(let i=0;i<line.length;i++){
|
||||
const char=line[i];
|
||||
if(quote){if(escaped)escaped=false;else if(char==='\\')escaped=true;else if(char===quote)quote='';continue;}
|
||||
if(char==='"'||char==="'"||char==='`'){quote=char;continue;}
|
||||
for(const marker of markers)if(line.startsWith(marker,i))return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function syntaxTokens(line,file){
|
||||
const kind=syntaxKind(file);
|
||||
if(kind==='docs'&&/^\s{0,3}#{1,6}\s/.test(line))return[{text:line,type:'keyword'}];
|
||||
const commentAt=commentStart(line,kind),code=commentAt<0?line:line.slice(0,commentAt),tokens=[];
|
||||
tokenPattern.lastIndex=0;let match;
|
||||
while((match=tokenPattern.exec(code))){
|
||||
const text=match[0],lower=text.toLowerCase(),rest=code.slice(tokenPattern.lastIndex),before=code.slice(0,match.index),quoted=/^["'`]/.test(text);
|
||||
let type='plain';
|
||||
if(quoted)type='string';
|
||||
else if(/^(?:0x[\da-f]+|0b[01]+|#[\da-f]{3,8}|\d+(?:\.\d+)?(?:e[+-]?\d+)?)$/i.test(text))type='number';
|
||||
else if(syntaxConstants.has(lower))type='constant';
|
||||
else if(syntaxKeywords.has(text))type='keyword';
|
||||
else if(/^[A-Za-z_$]/.test(text)){
|
||||
if(kind==='markup'&&(/<\/?\s*$/.test(before)||/^\s*>/.test(rest)))type='tag';
|
||||
else if((kind==='data'||kind==='css')&&/^\s*[:=]/.test(rest))type='property';
|
||||
else if(/^\s*\(/.test(rest))type='function';
|
||||
else if(/^[A-Z]/.test(text))type='type';
|
||||
else if(/\.\s*$/.test(before))type='property';
|
||||
}else if(!/^\s+$/.test(text))type='operator';
|
||||
tokens.push({text,type});
|
||||
}
|
||||
if(commentAt>=0)tokens.push({text:line.slice(commentAt),type:'comment'});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function highlightSource(source,file){
|
||||
if(!source)return'<span class="tok-comment">No preview available.</span>';
|
||||
return source.split('\n').map(line=>syntaxTokens(line,file).map(token=>`<span class="tok-${token.type}">${escapeHtml(token.text)}</span>`).join('')).join('\n');
|
||||
}
|
||||
|
||||
async function loadFileDetails(file){
|
||||
if(!file||!currentSnapshot||file.detailsLoaded||file.sourceLoading)return;
|
||||
const snapshotId=currentSnapshot;file.sourceLoading=true;
|
||||
try{
|
||||
const response=await apiFetch(apiUrl(`/api/snapshots/${snapshotId}/entities/${file.id}`));
|
||||
if(!response.ok)throw new Error('Could not load complete file');
|
||||
const payload=await response.json();if(currentSnapshot!==snapshotId)return;
|
||||
for(const record of [fileById.get(file.id),layoutById.get(file.id)])if(record){record.symbols=payload.symbols||record.symbols||[];record.symbolCount=payload.symbols?.length??record.symbolCount??0;record.detailsLoaded=true;}
|
||||
}catch(error){file.sourceError=error.message;file.source=file.preview??'';}
|
||||
finally{file.sourceLoading=false;if(selected?.id===file.id)renderInspector();dirty=true;}
|
||||
}
|
||||
|
||||
async function loadSourceChunk(file,start,limit=600){
|
||||
if(!currentSnapshot)return[];file._sourceChunks||=new Map();file._sourceRequests||=new Map();
|
||||
if(file._sourceChunks.has(start))return file._sourceChunks.get(start);
|
||||
if(file._sourceRequests.has(start))return file._sourceRequests.get(start);
|
||||
const snapshotId=currentSnapshot,promise=apiFetch(apiUrl(`/api/snapshots/${snapshotId}/entities/${file.id}/source?start=${start}&limit=${limit}`)).then(response=>{if(!response.ok)throw new Error('Could not load source lines');return response.json();}).then(payload=>{if(currentSnapshot!==snapshotId)return[];file._sourceChunks.set(start,payload.lines||[]);return payload.lines||[];}).catch(()=>[]).finally(()=>file._sourceRequests.delete(start));
|
||||
file._sourceRequests.set(start,promise);return promise;
|
||||
}
|
||||
|
||||
const sourceQueue=[],queuedSources=new Set();let activeSourceLoads=0;
|
||||
function queueFullSource(file){
|
||||
if(!file||!currentSnapshot||Object.hasOwn(file,'source')||file.sourceLoading||queuedSources.has(file.id))return;
|
||||
queuedSources.add(file.id);sourceQueue.push(file);pumpSourceQueue();
|
||||
}
|
||||
function pumpSourceQueue(){
|
||||
while(activeSourceLoads<4&&sourceQueue.length){
|
||||
const file=sourceQueue.shift();queuedSources.delete(file.id);activeSourceLoads++;
|
||||
loadSourceChunk(file,0,4096).then(lines=>{const source=lines.join('\n');for(const record of [fileById.get(file.id),layoutById.get(file.id)])if(record){record.source=source;record._tileSource='';record._tileLines=null;}dirty=true;}).finally(()=>{activeSourceLoads--;pumpSourceQueue();});
|
||||
}
|
||||
}
|
||||
|
||||
function mountSourceViewer(file){
|
||||
const viewer=$('#sourceViewer'),windowElement=$('#sourceWindow'),spacer=$('#sourceSpacer');if(!viewer||!windowElement||!spacer)return;
|
||||
const lineHeight=16,total=Math.max(1,file.lines);file._sourceChunks||=new Map();
|
||||
spacer.style.height=`${Math.max(lineHeight,total*lineHeight)}px`;spacer.style.width='8000px';
|
||||
let frame=0,drawVersion=0;
|
||||
const draw=async()=>{
|
||||
frame=0;const version=++drawVersion,start=Math.max(0,Math.floor(viewer.scrollTop/lineHeight)-12),count=Math.ceil(viewer.clientHeight/lineHeight)+24,chunkStart=Math.floor(start/600)*600;
|
||||
const lines=await loadSourceChunk(file,chunkStart,600);if(version!==drawVersion||!viewer.isConnected)return;
|
||||
const offset=start-chunkStart,end=Math.min(lines.length,offset+count);windowElement.style.transform=`translateY(${start*lineHeight}px)`;
|
||||
windowElement.innerHTML=lines.slice(offset,end).map((line,index)=>`<span class="source-line" data-line="${start+index+1}">${highlightSource(line,file)||' '}</span>`).join('');
|
||||
};
|
||||
viewer.addEventListener('scroll',()=>{if(!frame)frame=requestAnimationFrame(draw);},{passive:true});draw();
|
||||
}
|
||||
|
||||
function selectFile(file,addHistory=true){
|
||||
selected=file||null;
|
||||
if(!file)coverageActiveIndex=-1;
|
||||
if(file&&addHistory){history=history.filter(item=>item.id!==file.id);history.unshift(file);history=history.slice(0,30);renderHistory();}
|
||||
$('#breadcrumbText').textContent=file?`${currentName} › ${file.path}`:currentName;
|
||||
renderInspector();if(file)loadFileDetails(file);dirty=true;
|
||||
}
|
||||
|
||||
function renderInspector(){
|
||||
const hint=$('#inspectHint'),content=$('#inspectContent');
|
||||
if(!files.length){hint.hidden=false;hint.textContent='Open a local folder or public GitHub repository to begin.';content.innerHTML='';return;}
|
||||
if(!selected){
|
||||
hint.hidden=false;hint.textContent=expandedCoverageLayer?'Click a file or use ↑ and ↓ to move around the map':'Select an entity on the map';
|
||||
const coverageRows=Object.entries(layerLabels).map(([key,label])=>`<button class="coverage-row${expandedCoverageLayer===key?' active':''}" type="button" data-coverage-layer="${key}" aria-expanded="${expandedCoverageLayer===key}"><i style="background:${palettes[paletteIndex][key]}"></i><span>${label}</span><small>${format(sceneStats.layers.get(key)||0)}</small><b aria-hidden="true">${expandedCoverageLayer===key?'−':'+'}</b></button>`).join('');
|
||||
const expandedLabel=layerLabels[expandedCoverageLayer];
|
||||
content.innerHTML=`<div class="entity-title"><small>Coverage · ${escapeHtml(currentName)}</small><h2>${format(files.length)} indexed files</h2></div><div class="meta-grid"><span>known links</span><b>${format(sceneStats.known)}</b><span>inferred links</span><b>${format(sceneStats.inferred)}</b><span>definitions</span><b>${format(sceneStats.definitions)}</b><span>source lines</span><b>${format(sceneStats.totalLines)}</b></div><div class="section-title coverage-title"><span>Semantic coverage</span><small>Click a section</small></div><div class="coverage-menu">${coverageRows}</div>${expandedCoverageLayer?`<div class="coverage-list-head"><span>${escapeHtml(expandedLabel)}</span><small>Click or ↑ ↓ to navigate</small></div><div class="coverage-file-list" id="coverageFileList" tabindex="0" role="listbox" aria-label="${escapeHtml(expandedLabel)} files"><div class="coverage-list-spacer" id="coverageListSpacer"></div><div class="coverage-list-window" id="coverageListWindow"></div></div>`:''}`;
|
||||
content.querySelectorAll('[data-coverage-layer]').forEach(row=>row.addEventListener('click',()=>toggleCoverageLayer(row.dataset.coverageLayer)));
|
||||
if(expandedCoverageLayer)mountCoverageList(expandedCoverageLayer);
|
||||
return;
|
||||
}
|
||||
hint.hidden=true;
|
||||
const connections=(edgesByFile.get(selected.id)||[]).map(edge=>({edge,file:fileById.get(edge.from===selected.id?edge.to:edge.from)})).filter(item=>item.file);
|
||||
const related=connections.slice(0,30);
|
||||
const sourceStatus=selected.sourceLoading?'Loading complete file…':selected.sourceError?'Preview only':`${format(selected.lines)} lines`;
|
||||
content.innerHTML=`<div class="entity-title"><small>${escapeHtml(selected.path)}</small><h2>${escapeHtml(selected.name)}</h2></div><div class="meta-grid"><span>language</span><b>${escapeHtml(selected.language)}</b><span>semantic layer</span><b>${escapeHtml(layerLabels[selected.layer]||'Other')}</b><span>source lines</span><b>${format(selected.lines)}</b><span>definitions</span><b>${format(selected.symbols.length)}</b><span>complexity</span><b>${format(selected.complexity)}</b><span>connections</span><b>${format(connections.length)}</b></div>${related.length?`<div class="section-title">Relationships · ${related.length}</div>${related.map(({edge,file})=>`<div class="relation" data-id="${file.id}"><i></i><span>${escapeHtml(file.path)}</span><small>${edge.confidence}</small></div>`).join('')}`:'<p class="panel-hint">No indexed file connections. External dependencies and unresolved references are not shown.</p>'}<div class="section-title source-title"><span>Source</span><small>${sourceStatus}</small></div><div class="code-preview" id="sourceViewer" tabindex="0" aria-label="Scrollable source for ${escapeHtml(selected.name)}"><div class="source-spacer" id="sourceSpacer"></div><pre class="source-window" id="sourceWindow"></pre></div>`;
|
||||
content.querySelectorAll('.relation[data-id]').forEach(row=>row.addEventListener('click',()=>selectFile(fileById.get(Number(row.dataset.id)))));
|
||||
mountSourceViewer(selected);
|
||||
}
|
||||
|
||||
function normalizedLayer(file){return file.layer in layerLabels?file.layer:'unknown';}
|
||||
|
||||
function toggleCoverageLayer(layer){
|
||||
cancelCameraAnimation();expandedCoverageLayer=expandedCoverageLayer===layer?null:layer;coverageActiveIndex=-1;renderInspector();dirty=true;
|
||||
if(expandedCoverageLayer)requestAnimationFrame(()=>$('#coverageFileList')?.focus({preventScroll:true}));
|
||||
}
|
||||
|
||||
function mountCoverageList(layer){
|
||||
const list=$('#coverageFileList'),spacer=$('#coverageListSpacer'),windowElement=$('#coverageListWindow');if(!list||!spacer||!windowElement)return;
|
||||
const layerFiles=files.filter(file=>normalizedLayer(file)===layer).sort((a,b)=>a.path.localeCompare(b.path,undefined,{numeric:true,sensitivity:'base'}));
|
||||
const rowHeight=43;spacer.style.height=`${layerFiles.length*rowHeight}px`;
|
||||
let frame=0;
|
||||
const renderWindow=()=>{
|
||||
frame=0;const start=Math.max(0,Math.floor(list.scrollTop/rowHeight)-4),count=Math.ceil(list.clientHeight/rowHeight)+8,end=Math.min(layerFiles.length,start+count);
|
||||
windowElement.style.transform=`translateY(${start*rowHeight}px)`;
|
||||
windowElement.innerHTML=layerFiles.slice(start,end).map((file,offset)=>{const index=start+offset;return`<button type="button" class="coverage-file${coverageActiveIndex===index?' active':''}" id="coverage-file-${file.id}" data-coverage-index="${index}" role="option" aria-selected="${coverageActiveIndex===index}"><i style="background:${palettes[paletteIndex][normalizedLayer(file)]}"></i><span><strong>${escapeHtml(file.name)}</strong><small>${escapeHtml(file.path)}</small></span><em>${format(file.lines)}</em></button>`;}).join('')||'<div class="coverage-empty">No files in this section.</div>';
|
||||
};
|
||||
const preview=index=>{
|
||||
if(!layerFiles.length)return;coverageActiveIndex=Math.max(0,Math.min(layerFiles.length-1,index));const file=layerFiles[coverageActiveIndex];list.setAttribute('aria-activedescendant',`coverage-file-${file.id}`);
|
||||
const top=coverageActiveIndex*rowHeight,bottom=top+rowHeight;if(top<list.scrollTop)list.scrollTop=top;else if(bottom>list.scrollTop+list.clientHeight)list.scrollTop=bottom-list.clientHeight;
|
||||
focusFile(file);renderWindow();dirty=true;
|
||||
};
|
||||
list.addEventListener('scroll',()=>{if(!frame)frame=requestAnimationFrame(renderWindow);},{passive:true});
|
||||
list.addEventListener('click',event=>{const row=event.target.closest('[data-coverage-index]');if(row)preview(Number(row.dataset.coverageIndex));});
|
||||
list.addEventListener('keydown',event=>{
|
||||
let next=coverageActiveIndex;
|
||||
if(event.key==='ArrowDown')next=next<0?0:next+1;
|
||||
else if(event.key==='ArrowUp')next=next<0?layerFiles.length-1:next-1;
|
||||
else if(event.key==='Home')next=0;
|
||||
else if(event.key==='End')next=layerFiles.length-1;
|
||||
else if(event.key==='Enter')next=next<0?0:next;
|
||||
else return;
|
||||
event.preventDefault();preview(next);
|
||||
});
|
||||
renderWindow();
|
||||
}
|
||||
|
||||
function renderResults(hits){
|
||||
searchHits=hits;$('#resultBadge').textContent=hits.length||'';$('#resultSummary').textContent=hits.length?`${hits.length} matching files and definitions`:'Type in the filter to search files and symbols.';
|
||||
$('#resultList').innerHTML=hits.map(hit=>`<div class="result-row" data-id="${hit.entityId??hit.id}"><i></i><span><strong>${escapeHtml(hit.name)}</strong><small>${escapeHtml(hit.path)}${hit.line?` · ${hit.line}`:''}</small></span></div>`).join('');
|
||||
$('#resultList').querySelectorAll('.result-row').forEach(row=>row.addEventListener('click',()=>{const file=fileById.get(Number(row.dataset.id));if(file){selectFile(file);focusFile(file);} }));dirty=true;
|
||||
}
|
||||
|
||||
function renderHistory(){
|
||||
$('#historyList').innerHTML=history.map(file=>`<div class="result-row" data-id="${file.id}"><i style="background:${palettes[paletteIndex][file.layer]||palettes[paletteIndex].unknown}"></i><span><strong>${escapeHtml(file.name)}</strong><small>${escapeHtml(file.path)}</small></span></div>`).join('')||'<div class="result-summary">Selections will appear here.</div>';
|
||||
$('#historyList').querySelectorAll('.result-row').forEach(row=>row.addEventListener('click',()=>{const file=fileById.get(Number(row.dataset.id));selectFile(file,false);focusFile(file);}));
|
||||
}
|
||||
|
||||
function focusFile(file){
|
||||
const item=layoutById.get(file.id);if(!item)return;
|
||||
const target={x:item.x+item.w/2,y:item.y+item.h/2};
|
||||
if(renderer.mode==='2d'){const fit=Math.min(viewport.clientWidth/Math.max(.2,item.w*1.35),viewport.clientHeight/Math.max(.2,item.h*1.35),MAX_2D_ZOOM);target.zoom=Math.min(MAX_2D_ZOOM,Math.max(fit,9/codeWorldFont(item)));}
|
||||
else target.distance=2.25;
|
||||
animateCameraTo(target);
|
||||
}
|
||||
|
||||
function animateCameraTo(target){
|
||||
const from={},to={};for(const [key,value] of Object.entries(target)){from[key]=renderer.camera[key];to[key]=value;}
|
||||
if(matchMedia('(prefers-reduced-motion: reduce)').matches){Object.assign(renderer.camera,to);cameraAnimation=null;dirty=true;return;}
|
||||
cameraAnimation={from,to,start:performance.now(),duration:620};dirty=true;
|
||||
}
|
||||
|
||||
function cancelCameraAnimation(){cameraAnimation=null;}
|
||||
|
||||
function switchTab(name){$$('.tab').forEach(tab=>{const active=tab.dataset.tab===name;tab.classList.toggle('active',active);tab.setAttribute('aria-selected',String(active));tab.tabIndex=active?0:-1;});$$('.panel').forEach(panel=>panel.classList.toggle('active',panel.id===`panel-${name}`));}
|
||||
$$('.tab').forEach(tab=>tab.addEventListener('click',()=>switchTab(tab.dataset.tab)));
|
||||
|
||||
function resizeOverlay(){const dpr=Math.min(devicePixelRatio||1,2),w=Math.floor(overlay.clientWidth*dpr),h=Math.floor(overlay.clientHeight*dpr);if(overlay.width!==w||overlay.height!==h){overlay.width=w;overlay.height=h;}overlayCtx.setTransform(dpr,0,0,dpr,0,0);}
|
||||
function drawChip(ctx,text,x,y,maxWidth,accent,occupied,force=false,align='left'){
|
||||
if(maxWidth<24)return false;
|
||||
ctx.font='600 11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
let label=text;
|
||||
if(ctx.measureText(label).width>maxWidth-10){let low=1,high=label.length;while(low<high){const mid=Math.ceil((low+high)/2);if(ctx.measureText(`${label.slice(0,mid)}…`).width<=maxWidth-10)low=mid;else high=mid-1;}label=`${label.slice(0,low)}…`;}
|
||||
const width=Math.min(maxWidth,ctx.measureText(label).width+10);
|
||||
if(align==='center')x-=width/2;
|
||||
const box={x:x-3,y:y-2,w:width+6,h:21};
|
||||
if(!force&&occupied?.some(other=>box.x<other.x+other.w&&box.x+box.w>other.x&&box.y<other.y+other.h&&box.y+box.h>other.y))return false;
|
||||
occupied?.push(box);
|
||||
ctx.fillStyle='rgba(27,30,28,.9)';ctx.fillRect(x,y,width,17);
|
||||
if(accent){ctx.fillStyle=accent;ctx.fillRect(x,y,2,17);}
|
||||
ctx.fillStyle='rgba(237,241,238,.94)';ctx.fillText(label,x+5,y+2);
|
||||
return true;
|
||||
}
|
||||
function drawFileChip(ctx,item,rect,accent,occupied,force=false){
|
||||
if(renderer.mode==='2d')return drawChip(ctx,item.name,rect.x+2,rect.y+2,Math.max(28,rect.w-4),accent,occupied,force);
|
||||
const anchor=renderer.project([item.x+item.w/2,item.y+item.h/2,item.height+2]);
|
||||
const maxWidth=Math.max(46,Math.min(160,rect.w*.9));
|
||||
return drawChip(ctx,item.name,anchor.x,anchor.y-8,maxWidth,accent,occupied,force,'center');
|
||||
}
|
||||
function drawOverlay(){
|
||||
resizeOverlay();const ctx=overlayCtx,w=overlay.clientWidth,h=overlay.clientHeight;ctx.clearRect(0,0,w,h);ctx.save();ctx.font='600 11px -apple-system, BlinkMacSystemFont, sans-serif';ctx.textBaseline='top';
|
||||
codeTexturesPending=false;
|
||||
codeTextureDeadline=performance.now()+5;
|
||||
const hitIds=new Set(searchHits.map(hit=>hit.entityId??hit.id)),occupied=[],visible=visibleLayout(w,h);visibleScreenItems=visible;
|
||||
if(renderer.mode==='3d')drawGroundGrid(ctx);
|
||||
let labelCount=0;
|
||||
if(showCode&&renderer.mode==='2d'){
|
||||
if(renderer.camera.zoom<=2.75)drawCodeOverview(ctx);
|
||||
else for(const {item,rect} of visible)drawCodeTexture(ctx,item,rect);
|
||||
}
|
||||
if(showCode&&renderer.mode==='3d')draw3dCode(ctx,visible);
|
||||
if(renderer.mode==='2d'){
|
||||
const labelDepth=renderer.camera.zoom<1.15?1:renderer.camera.zoom<1.8?2:renderer.camera.zoom<3?3:4;
|
||||
for(const dir of directoryRects){
|
||||
if(dir.depth<1||dir.depth>4)continue;const rect=renderer.screenRect(dir);if(rect.w<42||rect.h<22||rect.x>w||rect.y>h||rect.x+rect.w<0||rect.y+rect.h<0)continue;
|
||||
const color=palettes[paletteIndex][dir.node.layer]||palettes[paletteIndex].unknown;
|
||||
ctx.strokeStyle=color;ctx.globalAlpha=dir.depth===1?.88:dir.depth===2?.58:.32;ctx.lineWidth=dir.depth===1?1.6:1;ctx.strokeRect(rect.x+.5,rect.y+.5,rect.w-1,rect.h-1);ctx.globalAlpha=1;
|
||||
if(dir.depth<=labelDepth&&rect.w>82&&rect.h>30&&labelCount<72&&drawChip(ctx,`${dir.node.name}/`,rect.x+3,rect.y+3,rect.w-6,color,occupied))labelCount++;
|
||||
}
|
||||
}else{
|
||||
const labelDepth=renderer.camera.distance>4.1?1:2;
|
||||
for(const dir of directoryRects){if(dir.depth<1||dir.depth>labelDepth)continue;const point=renderer.project([dir.x+dir.w/2,dir.y+dir.h/2,1]),rect=renderer.screenRect(dir);if(point.x<0||point.x>w||point.y<0||point.y>h)continue;if(labelCount<48&&drawChip(ctx,`${dir.node.name}/`,point.x,point.y-8,Math.min(150,Math.max(60,rect.w*.65)),palettes[paletteIndex][dir.node.layer],occupied,false,'center'))labelCount++;}
|
||||
}
|
||||
const showFileLabels=renderer.mode==='2d'?renderer.camera.zoom>1.05:renderer.camera.distance<4.1;
|
||||
if(showFileLabels)for(const {item,rect} of visible){
|
||||
if(rect.w>46&&rect.h>18&&codeScreenFont(item)<5.2&&labelCount<110&&drawFileChip(ctx,item,rect,null,occupied))labelCount++;
|
||||
}
|
||||
if(hitIds.size){
|
||||
ctx.fillStyle='rgba(11,13,12,.48)';ctx.fillRect(0,0,w,h);
|
||||
for(const {item,rect} of visible){if(!hitIds.has(item.id))continue;ctx.strokeStyle='#e8c74b';ctx.lineWidth=2;ctx.strokeRect(rect.x-.5,rect.y-.5,rect.w+1,rect.h+1);drawFileChip(ctx,item,rect,'#e8c74b',null,true);}
|
||||
}
|
||||
drawSelectedConnections(ctx);
|
||||
if(selected){const item=layoutById.get(selected.id);if(item){const rect=renderer.screenRect(item);ctx.strokeStyle='#fff08a';ctx.lineWidth=2.5;if(renderer.mode==='3d'){ctx.beginPath();tileCorners(item).forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.stroke();}else ctx.strokeRect(rect.x-1,rect.y-1,rect.w+2,rect.h+2);drawFileChip(ctx,item,rect,'#fff08a',null,true);}}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function tileCorners(item){return [[item.x,item.y],[item.x+item.w,item.y],[item.x+item.w,item.y+item.h],[item.x,item.y+item.h]].map(([x,y])=>renderer.project([x,y,item.height||0]));}
|
||||
function drawGroundGrid(ctx){
|
||||
if(!layoutItems.length)return;
|
||||
ctx.save();ctx.globalCompositeOperation='destination-over';ctx.strokeStyle='rgba(180,180,180,.12)';ctx.lineWidth=.75;ctx.beginPath();
|
||||
for(let x=-200;x<=1200;x+=100){const a=renderer.project([x,-200,0]),b=renderer.project([x,900,0]);ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}
|
||||
for(let y=-200;y<=900;y+=100){const a=renderer.project([-200,y,0]),b=renderer.project([1200,y,0]);ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);}
|
||||
ctx.stroke();
|
||||
// Mask the grid underneath opaque tile tops so it never crosses source surfaces.
|
||||
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000';
|
||||
for(const {item} of visibleScreenItems){ctx.beginPath();tileCorners(item).forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.fill();}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawSelectedConnections(ctx){
|
||||
if(!selected)return;
|
||||
const origin=layoutById.get(selected.id);if(!origin)return;
|
||||
const center=item=>{if(renderer.mode==='3d')return renderer.project([item.x+item.w/2,item.y+item.h/2,item.height||0]);const rect=renderer.screenRect(item);return{x:rect.x+rect.w/2,y:rect.y+rect.h/2};};
|
||||
const a=center(origin);if(!Number.isFinite(a.x)||!Number.isFinite(a.y))return;
|
||||
const seen=new Set();ctx.save();ctx.beginPath();
|
||||
for(const edge of edgesByFile.get(selected.id)||[]){
|
||||
const id=edge.from===selected.id?edge.to:edge.from;
|
||||
if(id===selected.id||seen.has(id))continue;seen.add(id);
|
||||
const target=layoutById.get(id);if(!target)continue;
|
||||
const b=center(target);if(!Number.isFinite(b.x)||!Number.isFinite(b.y))continue;
|
||||
ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);
|
||||
}
|
||||
// Batch every connection into two strokes: a dark outline keeps them legible over source text.
|
||||
ctx.lineCap='round';ctx.strokeStyle='rgba(12,12,12,.85)';ctx.lineWidth=3.5;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(255,229,142,.9)';ctx.lineWidth=1.5;ctx.stroke();ctx.restore();
|
||||
}
|
||||
|
||||
function codeWorldFont(item){
|
||||
// Code has a stable physical scale inside each file tile. Width establishes
|
||||
// an 80-column page; height determines how many real source lines fit.
|
||||
return Math.max(.004,Math.min(item.w/80,item.h/1.34));
|
||||
}
|
||||
function codeScreenFont(item){return codeWorldFont(item)*renderer.camera.zoom;}
|
||||
function tileSourceLines(item){
|
||||
const source=item.source??item.preview??'';
|
||||
if(item._tileSource!==source||!item._tileLines){item._tileSource=source;item._tileLines=source.split('\n');item._tileTokens=[];}
|
||||
return item._tileLines;
|
||||
}
|
||||
function drawSyntaxLine(ctx,line,file,x,y,maxX,alpha,tokens=syntaxTokens(line,file)){
|
||||
let screenX=x;
|
||||
for(const token of tokens){if(screenX>maxX)break;ctx.fillStyle=syntaxPalette[token.type]||syntaxPalette.plain;ctx.globalAlpha=alpha;ctx.fillText(token.text,screenX,y);screenX+=ctx.measureText(token.text).width;}
|
||||
ctx.globalAlpha=1;
|
||||
}
|
||||
let codeTextureDeadline=0,codeTexturesPending=false;
|
||||
function paintCodeSurface(ctx,item,width,height){
|
||||
const source=tileSourceLines(item),scale=width/Math.max(.001,item.w),fontSize=Math.max(.1,codeWorldFont(item)*scale),lineHeight=fontSize*1.34,pad=Math.max(.1,fontSize*.72);
|
||||
const count=Math.min(source.length,Math.max(1,Math.floor((height-pad*2)/lineHeight)));
|
||||
ctx.textBaseline='top';ctx.font=`${fontSize}px SFMono-Regular, Consolas, monospace`;
|
||||
for(let index=0;index<count;index++){
|
||||
const tokens=item._tileTokens[index]||(item._tileTokens[index]=syntaxTokens(source[index]||'',item));
|
||||
drawSyntaxLine(ctx,source[index]||'',item,pad,pad+index*lineHeight,width-pad,.92,tokens);
|
||||
}
|
||||
}
|
||||
function drawCodeOverview(ctx){
|
||||
if(!overviewCanvas)resetCodeCaches();
|
||||
while(overviewIndex<layoutItems.length&&performance.now()<codeTextureDeadline){
|
||||
const item=layoutItems[overviewIndex++],x=Math.round(item.x*overviewScale),y=Math.round(item.y*overviewScale),width=Math.max(1,Math.round(item.w*overviewScale)),height=Math.max(1,Math.round(item.h*overviewScale));
|
||||
overviewContext.save();overviewContext.translate(x,y);overviewContext.beginPath();overviewContext.rect(0,0,width,height);overviewContext.clip();paintCodeSurface(overviewContext,item,width,height);overviewContext.restore();
|
||||
}
|
||||
if(overviewIndex<layoutItems.length)codeTexturesPending=true;
|
||||
const zoom=renderer.camera.zoom;
|
||||
ctx.save();ctx.imageSmoothingEnabled=true;ctx.globalAlpha=.96;ctx.drawImage(overviewCanvas,viewport.clientWidth/2-renderer.camera.x*zoom,viewport.clientHeight/2-renderer.camera.y*zoom,1000*zoom,680*zoom);ctx.restore();
|
||||
}
|
||||
function rememberCodeTexture(item,texture){
|
||||
if(item._codeTexture)codeTextureBytes-=item._codeTexture.width*item._codeTexture.height*4;
|
||||
item._codeTexture=texture;codeTextureBytes+=texture.width*texture.height*4;codeTextureLru.delete(item.id);codeTextureLru.set(item.id,item);
|
||||
while(codeTextureBytes>codeTextureBudget&&codeTextureLru.size>1){const oldest=codeTextureLru.entries().next().value;if(!oldest)break;const [id,victim]=oldest;codeTextureLru.delete(id);if(victim!==item&&victim._codeTexture){codeTextureBytes-=victim._codeTexture.width*victim._codeTexture.height*4;victim._codeTexture=null;}}
|
||||
}
|
||||
function buildCodeTexture(item,rect){
|
||||
const source=tileSourceLines(item),sourceKey=item._tileSource;if(!source.length)return null;
|
||||
const worldFont=codeWorldFont(item),lineCapacity=Math.max(1,Math.floor((item.h-worldFont*1.44)/(worldFont*1.34)));
|
||||
if(item.lines>source.length&&lineCapacity>source.length)queueFullSource(item);
|
||||
const dpr=Math.min(devicePixelRatio||1,2),screenWidth=Math.max(1,rect.w*dpr);
|
||||
const target=Math.max(32,Math.min(2048,2**Math.ceil(Math.log2(screenWidth))));
|
||||
const cached=item._codeTexture;
|
||||
if(cached&&item._codeSource===sourceKey&&item._codeResolution>=target/1.6&&item._codeResolution<=target*2.6){codeTextureLru.delete(item.id);codeTextureLru.set(item.id,item);return cached;}
|
||||
if(performance.now()>codeTextureDeadline&&cached){codeTexturesPending=true;return cached;}
|
||||
if(performance.now()>codeTextureDeadline){codeTexturesPending=true;return null;}
|
||||
const scale=Math.max(.01,Math.min(target/Math.max(.001,item.w),2048/Math.max(.001,item.h)));
|
||||
const width=Math.max(1,Math.round(item.w*scale)),height=Math.max(1,Math.round(item.h*scale));
|
||||
const texture=document.createElement('canvas');texture.width=width;texture.height=height;
|
||||
paintCodeSurface(texture.getContext('2d',{alpha:true}),item,width,height);
|
||||
rememberCodeTexture(item,texture);item._codeSource=sourceKey;item._codeResolution=target;return texture;
|
||||
}
|
||||
function draw3dCode(ctx,visible){
|
||||
// Draw far faces first; opaque top faces occlude text on faces behind them.
|
||||
const faces=visible.map(entry=>({...entry,depth:renderer.project([entry.item.x+entry.item.w/2,entry.item.y+entry.item.h/2,entry.item.height]).depth})).sort((a,b)=>b.depth-a.depth);
|
||||
for(const {item,rect} of faces){
|
||||
const corners=tileCorners(item);if(corners.some(p=>!Number.isFinite(p.x)||!Number.isFinite(p.y)))continue;
|
||||
ctx.save();ctx.beginPath();corners.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.clip();
|
||||
ctx.fillStyle='#1c1c1c';ctx.fillRect(rect.x-1,rect.y-1,rect.w+2,rect.h+2);
|
||||
const texture=buildCodeTexture(item,rect);
|
||||
if(texture){
|
||||
// Match the two triangles used by the WebGL top face exactly.
|
||||
const [a,b,c,d]=corners,w=texture.width,h=texture.height;
|
||||
for(const second of [false,true]){
|
||||
ctx.save();ctx.beginPath();const triangle=second?[a,c,d]:[a,b,c];triangle.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.clip();
|
||||
const x=second?{x:c.x-d.x,y:c.y-d.y}:{x:b.x-a.x,y:b.y-a.y};
|
||||
const y=second?{x:d.x-a.x,y:d.y-a.y}:{x:c.x-b.x,y:c.y-b.y};
|
||||
ctx.transform(x.x/w,x.y/w,y.x/h,y.y/h,a.x,a.y);ctx.drawImage(texture,0,0);ctx.restore();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.save();ctx.strokeStyle=palettes[paletteIndex][item.layer]||palettes[paletteIndex].unknown;ctx.lineWidth=1;ctx.beginPath();corners.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.closePath();ctx.stroke();ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawCodeTexture(ctx,item,rect){
|
||||
const texture=buildCodeTexture(item,rect);if(!texture)return;
|
||||
ctx.save();ctx.imageSmoothingEnabled=true;ctx.globalAlpha=.96;ctx.drawImage(texture,rect.x,rect.y,rect.w,rect.h);ctx.restore();
|
||||
}
|
||||
|
||||
function animate(now){
|
||||
if(cameraAnimation){
|
||||
const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4;
|
||||
for(const key of Object.keys(cameraAnimation.to))renderer.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased;
|
||||
if(progress>=1)cameraAnimation=null;dirty=true;
|
||||
}
|
||||
if(dirty){renderer.render();drawOverlay();dirty=codeTexturesPending;}
|
||||
const delta=now-lastFrame;lastFrame=now;if(delta<100){frameSamples.push(delta);if(frameSamples.length>45)frameSamples.shift();if(frameSamples.length&&Math.floor(now/500)!==Math.floor((now-delta)/500)){const average=frameSamples.reduce((a,b)=>a+b,0)/frameSamples.length;$('#fps').textContent=`${Math.round(1000/average)} fps · ${(average).toFixed(1)} ms`;}}
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function pickAt(x,y){
|
||||
if(renderer.mode==='2d'){const world=renderer.worldAt(x,y),cell=spatialGrid.get(`${Math.floor(world.x/spatialCell)}:${Math.floor(world.y/spatialCell)}`)||[];let match=null,area=Infinity;for(const item of cell){if(world.x>=item.x&&world.x<=item.x+item.w&&world.y>=item.y&&world.y<=item.y+item.h){const next=item.w*item.h;if(next<area){match=item;area=next;}}}return match;}
|
||||
let best=null,depth=Infinity;
|
||||
for(const {item,rect} of visibleScreenItems){
|
||||
if(x<rect.x||x>rect.x+rect.w||y<rect.y||y>rect.y+rect.h)continue;
|
||||
const points=tileCorners(item);let inside=false;
|
||||
for(let i=0,j=points.length-1;i<points.length;j=i++){
|
||||
const a=points[i],b=points[j];if((a.y>y)!==(b.y>y)&&x<(b.x-a.x)*(y-a.y)/(b.y-a.y)+a.x)inside=!inside;
|
||||
}
|
||||
const d=renderer.project([item.x+item.w/2,item.y+item.h/2,item.height||0]).depth;
|
||||
if(inside&&d<depth){depth=d;best=item;}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
viewport.addEventListener('pointerdown',event=>{
|
||||
if(event.button>2)return;
|
||||
const pan=renderer.mode==='3d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey);
|
||||
if(renderer.mode==='2d'&&event.button!==0)return;
|
||||
event.preventDefault();
|
||||
cancelCameraAnimation();
|
||||
pointer={down:true,x:event.clientX,y:event.clientY,startX:event.clientX,startY:event.clientY,action:pan?'pan':'orbit',id:event.pointerId};
|
||||
viewport.setPointerCapture(event.pointerId);viewport.classList.add('dragging',pan?'panning':'orbiting');
|
||||
});
|
||||
viewport.addEventListener('pointermove',event=>{
|
||||
const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top;
|
||||
if(pointer.down&&event.pointerId===pointer.id){
|
||||
const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY;
|
||||
if(renderer.mode==='2d'){renderer.camera.x-=dx/renderer.camera.zoom;renderer.camera.y-=dy/renderer.camera.zoom;}
|
||||
else if(pointer.action==='pan')pan3d(dx,dy);
|
||||
else{renderer.camera.yaw-=dx*.007;renderer.camera.pitch=Math.max(.045,Math.min(1.525,renderer.camera.pitch+dy*.006));}
|
||||
dirty=true;$('#tooltip').hidden=true;return;
|
||||
}
|
||||
const item=pickAt(x,y),tip=$('#tooltip');if(item){tip.hidden=false;tip.style.left=`${Math.min(viewport.clientWidth-320,x+13)}px`;tip.style.top=`${Math.min(viewport.clientHeight-70,y+13)}px`;tip.innerHTML=`${escapeHtml(item.name)}<small>${escapeHtml(item.path)} · ${format(item.lines)} lines</small>`;}else tip.hidden=true;
|
||||
});
|
||||
function finishPointer(event){
|
||||
if(!pointer.down||event.pointerId!==pointer.id)return;
|
||||
viewport.classList.remove('dragging','panning','orbiting');
|
||||
if(event.type==='pointerup'&&event.button===0&&Math.hypot(event.clientX-pointer.startX,event.clientY-pointer.startY)<4){const rect=viewport.getBoundingClientRect();selectFile(pickAt(event.clientX-rect.left,event.clientY-rect.top));}
|
||||
pointer.down=false;pointer.id=null;
|
||||
}
|
||||
viewport.addEventListener('pointerup',finishPointer);
|
||||
viewport.addEventListener('pointercancel',finishPointer);
|
||||
viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;});
|
||||
viewport.addEventListener('contextmenu',event=>{if(renderer.mode==='3d')event.preventDefault();});
|
||||
viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false});
|
||||
viewport.addEventListener('dblclick',event=>{const rect=viewport.getBoundingClientRect(),file=pickAt(event.clientX-rect.left,event.clientY-rect.top);if(file){selectFile(file);focusFile(file);}});
|
||||
|
||||
$$('[data-view]').forEach(button=>button.addEventListener('click',()=>{cancelCameraAnimation();$$('[data-view]').forEach(v=>v.classList.toggle('active',v===button));renderer.mode=button.dataset.view;if(renderer.mode==='3d')renderer.camera.zoom=1;$('#cameraHelp').hidden=renderer.mode!=='3d';dirty=true;}));
|
||||
$$('[data-metric]').forEach(button=>button.addEventListener('click',()=>{$$('[data-metric]').forEach(v=>v.classList.toggle('active',v===button));currentMetric=button.dataset.metric;computeLayout();}));
|
||||
$('#codeButton').classList.toggle('active',showCode);$('#codeButton').addEventListener('click',event=>{showCode=!showCode;event.currentTarget.classList.toggle('active',showCode);dirty=true;});
|
||||
$('#homeButton').addEventListener('click',()=>{selectFile(null);fitScene();});
|
||||
$('#mapButton').addEventListener('click',fitScene);
|
||||
$('#layersButton').addEventListener('click',()=>{showLegend=!showLegend;$('#legend').classList.toggle('open',showLegend);$('#layersButton').classList.toggle('active',showLegend);});
|
||||
$('#paletteButton').addEventListener('click',()=>{paletteIndex=(paletteIndex+1)%palettes.length;computeLayout();buildLegend();renderInspector();renderHistory();});
|
||||
const settingsDialog=$('#settingsDialog');
|
||||
$('#settingsButton').addEventListener('click',()=>{
|
||||
$('#settingsContent').innerHTML=`<div class="settings-status"><span><i></i>Workspace preferences</span><small>${renderer.mode.toUpperCase()} VIEW</small></div>`;
|
||||
$('#paletteSelect').value=String(paletteIndex);$('#sourceToggle').checked=showCode;$('#fpsToggle').checked=!$('#fps').hidden;
|
||||
if(!settingsDialog.open)settingsDialog.showModal();
|
||||
});
|
||||
settingsDialog.addEventListener('click',event=>{if(event.target===settingsDialog)settingsDialog.close();});
|
||||
|
||||
function buildLegend(){const legend=$('#legend');legend.innerHTML=Object.entries(layerLabels).map(([key,label])=>`<button type="button" data-legend-layer="${key}"><span><i style="background:${palettes[paletteIndex][key]}"></i>${label}</span><b>${format(sceneStats.layers.get(key)||0)}</b></button>`).join('');legend.querySelectorAll('[data-legend-layer]').forEach(row=>row.addEventListener('click',()=>{selectFile(null);expandedCoverageLayer=row.dataset.legendLayer;coverageActiveIndex=-1;showLegend=false;legend.classList.remove('open');$('#layersButton').classList.remove('active');switchTab('inspector');renderInspector();requestAnimationFrame(()=>$('#coverageFileList')?.focus({preventScroll:true}));}));}
|
||||
|
||||
let searchTimer;
|
||||
$('#searchInput').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(runSearch,140);});
|
||||
async function runSearch(){
|
||||
const query=$('#searchInput').value.trim();if(query.length<2){renderResults([]);return;}
|
||||
let hits=[];
|
||||
if(currentSnapshot){try{const response=await apiFetch(apiUrl(`/api/snapshots/${currentSnapshot}/search?q=${encodeURIComponent(query)}&limit=180`));hits=await response.json();}catch{} }
|
||||
else{const q=query.toLowerCase();for(const file of files){if(hits.length>=180)break;if(file.path.toLowerCase().includes(q))hits.push({entityId:file.id,path:file.path,name:file.name,line:1,kind:'file'});for(const symbol of file.symbols){if(hits.length>=180)break;if(symbol.name.toLowerCase().includes(q))hits.push({entityId:file.id,path:file.path,name:symbol.name,line:symbol.line,kind:'definition'});}}}
|
||||
renderResults(hits);switchTab('results');
|
||||
}
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.defaultPrevented)return;
|
||||
if(document.querySelector('dialog[open]'))return;
|
||||
const editing=/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName)||document.activeElement?.isContentEditable;
|
||||
if(editing&&event.key!=='Escape')return;
|
||||
if(event.key.toLowerCase()==='f'&&!event.metaKey&&!event.ctrlKey){event.preventDefault();fitScene();return;}
|
||||
if(event.key==='/'&&document.activeElement!==$('#searchInput')){event.preventDefault();$('#searchInput').focus();}
|
||||
if(event.key==='Escape'){$('#searchInput').blur();$('#tooltip').hidden=true;}
|
||||
if((event.metaKey||event.ctrlKey)&&event.key==='o'){event.preventDefault();$('#loadDialog').showModal();}
|
||||
if(renderer.mode!=='3d'||/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return;
|
||||
cancelCameraAnimation();
|
||||
const key=event.key.toLowerCase(),orbitStep=.065,panStep=22/Math.max(.8,renderer.camera.distance);
|
||||
if(key==='arrowleft')renderer.camera.yaw+=orbitStep;
|
||||
else if(key==='arrowright')renderer.camera.yaw-=orbitStep;
|
||||
else if(key==='arrowup')renderer.camera.pitch=Math.max(.045,renderer.camera.pitch-orbitStep);
|
||||
else if(key==='arrowdown')renderer.camera.pitch=Math.min(1.525,renderer.camera.pitch+orbitStep);
|
||||
else if(key==='a')pan3d(panStep,0);
|
||||
else if(key==='d')pan3d(-panStep,0);
|
||||
else if(key==='w')pan3d(0,panStep);
|
||||
else if(key==='s')pan3d(0,-panStep);
|
||||
else if(key==='+'||key==='=')dolly3d(-.12);
|
||||
else if(key==='-'||key==='_')dolly3d(.12);
|
||||
else if(key==='f')fitScene();
|
||||
else return;
|
||||
event.preventDefault();dirty=true;
|
||||
});
|
||||
window.addEventListener('resize',()=>{dirty=true;});
|
||||
|
||||
const dialog=$('#loadDialog');$('#loadButton').addEventListener('click',()=>dialog.showModal());
|
||||
$('#localFolderButton').addEventListener('click',async()=>{
|
||||
dialog.close();
|
||||
if('showDirectoryPicker' in window){try{const handle=await window.showDirectoryPicker();const chosen=[];await collectHandles(handle,'',chosen);await uploadLocal(handle.name,chosen);}catch(error){if(error.name!=='AbortError')showError(error.message);}}
|
||||
else $('#folderFallback').click();
|
||||
});
|
||||
$('#folderFallback').addEventListener('change',async event=>{const chosen=[...event.target.files].map(file=>({file,path:file.webkitRelativePath||file.name}));const name=chosen[0]?.path.split('/')[0]||'Local folder';await uploadLocal(name,chosen);event.target.value='';});
|
||||
|
||||
async function collectHandles(directory,prefix,output){for await(const [name,handle] of directory.entries()){if(name==='.git'||name==='node_modules'||name==='target')continue;const path=prefix?`${prefix}/${name}`:name;if(handle.kind==='directory')await collectHandles(handle,path,output);else{const file=await handle.getFile();output.push({file,path});}}}
|
||||
async function uploadLocal(name,chosen){
|
||||
if(!chosen.length)return;showProgress('Preparing local codebase',`Sending ${format(chosen.length)} files…`,0,chosen.length);
|
||||
try{
|
||||
const response=await apiFetch(apiUrl('/api/jobs'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'local',name})});const {jobId,error}=await response.json();if(error)throw new Error(error);
|
||||
let completed=0,cursor=0;const workers=Array.from({length:Math.min(4,chosen.length)},async()=>{while(cursor<chosen.length){const index=cursor++,entry=chosen[index];if(entry.file.size>8*1024*1024){completed++;continue;}const upload=await apiFetch(apiUrl(`/api/jobs/${jobId}/files?path=${encodeURIComponent(entry.path)}`),{method:'POST',body:entry.file});if(!upload.ok)throw new Error(`Could not read ${entry.path}`);completed++;showProgress('Preparing local codebase',entry.path,completed,chosen.length);}});await Promise.all(workers);await apiFetch(apiUrl(`/api/jobs/${jobId}/commit`),{method:'POST'});watchJob(jobId);
|
||||
}catch(error){showError(error.message);}
|
||||
}
|
||||
|
||||
$('#githubButton').addEventListener('click',startGithub);$('#githubInput').addEventListener('keydown',event=>{if(event.key==='Enter'){event.preventDefault();startGithub();}});
|
||||
async function startGithub(){const url=$('#githubInput').value.trim();$('#githubError').textContent='';if(!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?(?:\.git)?$/.test(url)){ $('#githubError').textContent='Enter a public GitHub owner/repository URL.';return;}dialog.close();showProgress('Cloning repository','Connecting to GitHub…',0,0);try{const response=await apiFetch(apiUrl('/api/jobs'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'github',url,name:url.split('/').filter(Boolean).pop()?.replace(/\.git$/,'')})});const payload=await response.json();if(!response.ok)throw new Error(payload.error||'Could not start indexing');watchJob(payload.jobId);}catch(error){showError(error.message);}}
|
||||
|
||||
let activeJobStream=null;
|
||||
async function watchJob(jobId){
|
||||
activeJobStream?.abort();
|
||||
const controller=new AbortController();activeJobStream=controller;
|
||||
try{
|
||||
const response=await apiFetch(apiUrl(`/api/jobs/${jobId}/events`),{signal:controller.signal});
|
||||
for await(const job of progressEvents(response)){
|
||||
if(controller.signal.aborted)return;
|
||||
showProgress(job.phase==='ready'?'Landscape ready':'Indexing codebase',job.message,job.completed,job.total);
|
||||
if(job.error){showError(job.error);return;}
|
||||
if(job.snapshotId){
|
||||
const scene=await apiFetch(apiUrl(`/api/snapshots/${job.snapshotId}/scene`),{signal:controller.signal});
|
||||
if(!scene.ok)throw new Error('Could not load the landscape.');
|
||||
const snapshot=await scene.json();if(controller.signal.aborted)return;
|
||||
applySnapshot(snapshot);buildLegend();setTimeout(()=>{if(activeJobStream===null)$('#progressCard').hidden=true;},350);return;
|
||||
}
|
||||
}
|
||||
throw new Error('The indexer connection closed before the landscape was ready.');
|
||||
}catch(error){if(!controller.signal.aborted)showError(error.message);}
|
||||
finally{if(activeJobStream===controller)activeJobStream=null;controller.abort();}
|
||||
}
|
||||
function showProgress(title,message,completed,total){$('#progressCard').hidden=false;$('#progressTitle').textContent=title;$('#progressMessage').textContent=message||'';const percent=total?Math.max(3,Math.round(completed/total*100)):12;$('#progressFill').style.width=`${percent}%`;$('#progressFill').style.background='';$('#progressCount').textContent=total?`${format(completed)} / ${format(total)} · ${percent}%`:'Working…';}
|
||||
function showError(message){$('#progressCard').hidden=false;$('#progressTitle').textContent='Could not build landscape';$('#progressMessage').textContent=message;$('#progressFill').style.width='100%';$('#progressFill').style.background='var(--danger)';$('#progressCount').textContent='Check the repository or folder and try again.';}
|
||||
|
||||
async function checkBackend(){
|
||||
const indicator=$('.live-dot');
|
||||
try{
|
||||
const response=await apiFetch(apiUrl('/api/health'));
|
||||
if(!response.ok)throw new Error(`HTTP ${response.status}`);
|
||||
indicator.classList.add('connected');indicator.title='Indexer connected';indicator.setAttribute('aria-label','Indexer connected');
|
||||
}catch{
|
||||
indicator.classList.remove('connected');indicator.title='Indexer unavailable';indicator.setAttribute('aria-label','Indexer unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
// Preferences stay on this device and never contain repository or server details.
|
||||
function savePreferences(){try{localStorage.setItem('codenavigator.preferences',JSON.stringify({palette:paletteIndex,source:showCode,fps:!$('#fps').hidden}));}catch{}}
|
||||
try{const saved=JSON.parse(localStorage.getItem('codenavigator.preferences')||'{}');if(Number.isInteger(saved.palette)&&saved.palette>=0&&saved.palette<palettes.length)paletteIndex=saved.palette;if(typeof saved.source==='boolean')showCode=saved.source;$('#fps').hidden=saved.fps!==true;}catch{$('#fps').hidden=true;}
|
||||
$('#codeButton').classList.toggle('active',showCode);
|
||||
$('#paletteSelect').addEventListener('change',event=>{paletteIndex=Number(event.target.value);computeLayout();buildLegend();renderInspector();renderHistory();savePreferences();});
|
||||
$('#sourceToggle').addEventListener('change',event=>{showCode=event.target.checked;$('#codeButton').classList.toggle('active',showCode);dirty=true;savePreferences();});
|
||||
$('#fpsToggle').addEventListener('change',event=>{$('#fps').hidden=!event.target.checked;savePreferences();});
|
||||
$('#paletteButton').addEventListener('click',savePreferences);$('#codeButton').addEventListener('click',savePreferences);
|
||||
$('#dismissProgress').addEventListener('click',()=>{$('#progressCard').hidden=true;});
|
||||
$('#fitButton').addEventListener('click',fitScene);
|
||||
function zoomStep(direction){cancelCameraAnimation();if(renderer.mode==='3d')dolly3d(-direction*.22);else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*Math.exp(direction*.3)));dirty=true;}
|
||||
$('#zoomInButton').addEventListener('click',()=>zoomStep(1));$('#zoomOutButton').addEventListener('click',()=>zoomStep(-1));
|
||||
$$('.canvas-controls, .breadcrumb, .progress-card, .legend, .camera-help').forEach(control=>{['pointerdown','dblclick','wheel'].forEach(type=>control.addEventListener(type,event=>event.stopPropagation()));});
|
||||
$$('[data-camera-preset]').forEach(button=>button.addEventListener('click',()=>{
|
||||
const poses={isometric:{yaw:-.35,pitch:.78},top:{yaw:0,pitch:.045},front:{yaw:0,pitch:1.35}};
|
||||
const from={...renderer.camera},to={...from,...poses[button.dataset.cameraPreset]};
|
||||
cancelCameraAnimation();
|
||||
if(matchMedia('(prefers-reduced-motion: reduce)').matches)Object.assign(renderer.camera,to);
|
||||
else cameraAnimation={from,to,start:performance.now(),duration:450};
|
||||
dirty=true;
|
||||
}));
|
||||
dialog.addEventListener('click',event=>{if(event.target===dialog)dialog.close();});
|
||||
$$('.tab').forEach((tab,index)=>{tab.id=`tab-${tab.dataset.tab}`;tab.setAttribute('aria-controls',`panel-${tab.dataset.tab}`);$(`#panel-${tab.dataset.tab}`).setAttribute('aria-labelledby',tab.id);tab.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;event.preventDefault();const tabs=$$('.tab'),next=event.key==='Home'?0:event.key==='End'?tabs.length-1:(index+(event.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length;switchTab(tabs[next].dataset.tab);tabs[next].focus();});});
|
||||
switchTab('inspector');
|
||||
new ResizeObserver(()=>{dirty=true;}).observe(viewport);
|
||||
$('#settingsButton').title='Workspace settings';$('#settingsButton').setAttribute('aria-label','Workspace settings');
|
||||
$('#settingsButton svg').innerHTML='<circle cx="12" cy="12" r="3"/><path d="m9.5 3-.5 2-2 1-2-.5L3 9l1.5 1.5v3L3 15l2 3.5 2-.5 2 1 .5 2h5l.5-2 2-1 2 .5 2-3.5-1.5-1.5v-3L21 9l-2-3.5-2 .5-2-1-.5-2z"/>';
|
||||
renderer.setData([]);fitScene();renderInspector();renderHistory();requestAnimationFrame(animate);checkBackend();
|
||||
@@ -0,0 +1,4 @@
|
||||
window.CODENAVIGATOR_CONFIG = {
|
||||
// Empty means same origin: the Go server serves both the frontend and the API.
|
||||
backendUrl: '',
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#171918"/>
|
||||
<path d="M10 13h44v38H10z" fill="#202b28" stroke="#65b3ff" stroke-width="3"/>
|
||||
<path d="M10 29h44M29 13v38M29 40h25" fill="none" stroke="#29c997" stroke-width="3"/>
|
||||
<path d="M15 19h9M15 23h7M34 19h14M34 23h10M15 35h9M15 39h7M34 46h13" fill="none" stroke="#d6dad7" stroke-linecap="round" stroke-width="2" opacity=".82"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 449 B |
+151
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#1b1b1b">
|
||||
<meta name="description" content="Explore an entire codebase as a fast, searchable 2D and 3D landscape.">
|
||||
<title>CodeNavigator</title>
|
||||
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="./styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<header class="toolbar" aria-label="Codebase controls">
|
||||
<div class="brand" aria-label="CodeNavigator"><svg viewBox="0 0 28 28" aria-hidden="true"><rect x="3" y="3" width="9" height="22" rx="2"/><rect x="16" y="3" width="9" height="9" rx="2"/><rect x="16" y="16" width="9" height="9" rx="2"/></svg><span>Codebase<span class="brand-sub">VIEWER</span></span></div>
|
||||
<div class="tool-group">
|
||||
<button class="tool primary" id="loadButton" title="Open codebase" aria-label="Open codebase"><svg viewBox="0 0 24 24"><path d="M3.5 7.5h6l2-2h4l2 2h3v11h-17z"/><path d="M3.5 9.5h17"/></svg></button>
|
||||
<button class="tool active" id="mapButton" title="Code landscape" aria-label="Code landscape"><svg viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M3.5 10h17M10 3.5v17M10 15h10.5"/></svg></button>
|
||||
<button class="tool" id="layersButton" title="Semantic layers" aria-label="Semantic layers"><svg viewBox="0 0 24 24"><path d="m4 8 8-4 8 4-8 4zM4 12l8 4 8-4M4 16l8 4 8-4"/></svg></button>
|
||||
</div>
|
||||
<span class="separator"></span>
|
||||
<div class="tool-group view-switch" role="group" aria-label="View mode">
|
||||
<button class="tool active" data-view="2d" title="Flat map" aria-label="Flat map"><svg viewBox="0 0 24 24"><rect x="3.5" y="3.5" width="17" height="17" rx="1.5"/><path d="M3.5 11h17M12 3.5v17"/></svg></button>
|
||||
<button class="tool" data-view="3d" title="3D landscape" aria-label="3D landscape"><svg viewBox="0 0 24 24"><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9zM4 7.5l8 4.5 8-4.5M12 12v9"/></svg></button>
|
||||
<button class="tool" id="codeButton" title="Toggle source texture" aria-label="Toggle source texture"><svg viewBox="0 0 24 24"><path d="M4 18 9 6l4 8 2-5 5 9z"/><path d="M4 20h16"/></svg></button>
|
||||
</div>
|
||||
<span class="separator"></span>
|
||||
<div class="metric-switch" role="group" aria-label="Map metric">
|
||||
<button class="metric active" data-metric="lines"><b>#</b> <span>Lines</span></button>
|
||||
<button class="metric" data-metric="references"><b>⌁</b> <span>References</span></button>
|
||||
</div>
|
||||
<button class="tool palette" id="paletteButton" title="Change palette" aria-label="Change palette"><svg viewBox="0 0 24 24"><path d="M20 13a8 8 0 1 0-8 7h1.5a1.5 1.5 0 0 0 0-3H12a2 2 0 0 1 0-4z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7.5" r="1"/><circle cx="14.5" cy="7.5" r="1"/><circle cx="17" cy="11" r="1"/></svg></button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div class="scene-stats" id="sceneStats" aria-live="polite">
|
||||
<span id="headlineStats">No codebase loaded</span>
|
||||
<small id="fileStats">Open a folder or public repository</small>
|
||||
</div>
|
||||
<label class="filter-wrap">
|
||||
<span class="sr-only">Filter files and symbols</span>
|
||||
<input id="searchInput" type="search" placeholder="Find a file or symbol…" autocomplete="off" spellcheck="false">
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
<button class="tool" id="settingsButton" title="Performance details" aria-label="Performance details" aria-haspopup="dialog" aria-controls="settingsDialog"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19 13.5v-3l-2-.7-.7-1.7.9-1.9-2.1-2.1-1.9.9-1.7-.7-.7-2h-3l-.7 2-1.7.7-1.9-.9-2.1 2.1.9 1.9-.7 1.7-2 .7v3l2 .7.7 1.7-.9 1.9 2.1 2.1 1.9-.9 1.7.7.7 2h3l.7-2 1.7-.7 1.9.9 2.1-2.1-.9-1.9.7-1.7z"/></svg></button>
|
||||
<span class="live-dot" role="status" aria-label="Connecting to indexer" title="Connecting to indexer"></span>
|
||||
</header>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="viewport" id="viewport">
|
||||
<canvas id="glCanvas"></canvas>
|
||||
<canvas id="overlayCanvas"></canvas>
|
||||
<div class="empty-state" id="emptyState" hidden>
|
||||
<strong>No source files found</strong>
|
||||
<span>Try another folder or public GitHub repository.</span>
|
||||
</div>
|
||||
<div class="progress-card" id="progressCard" role="status" aria-live="polite" hidden>
|
||||
<button type="button" class="progress-dismiss close-button" id="dismissProgress" aria-label="Dismiss notification"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
<div class="progress-copy">
|
||||
<strong id="progressTitle">Indexing codebase</strong>
|
||||
<span id="progressMessage">Discovering files…</span>
|
||||
</div>
|
||||
<div class="progress-track"><i id="progressFill"></i></div>
|
||||
<span class="progress-count" id="progressCount"></span>
|
||||
</div>
|
||||
<div class="tooltip" id="tooltip" hidden></div>
|
||||
<div class="camera-help" id="cameraHelp" hidden>
|
||||
<button type="button" data-camera-preset="isometric">Isometric</button>
|
||||
<button type="button" data-camera-preset="top">Top</button>
|
||||
<button type="button" data-camera-preset="front">Front</button>
|
||||
<span><b>Drag</b> orbit</span>
|
||||
<span><b>Shift + drag</b> pan</span>
|
||||
<span><b>Scroll</b> dolly</span>
|
||||
<span><b>F</b> reset</span>
|
||||
</div>
|
||||
<nav class="breadcrumb" id="breadcrumb" aria-label="Selected file path">
|
||||
<button id="homeButton" aria-label="Fit entire codebase">←</button>
|
||||
<span id="breadcrumbText">No codebase loaded</span>
|
||||
</nav>
|
||||
<div class="fps" id="fps">— fps</div>
|
||||
<div class="legend" id="legend"></div>
|
||||
<div class="canvas-controls" aria-label="Landscape navigation"><button type="button" id="zoomOutButton" title="Zoom out" aria-label="Zoom out">−</button><button type="button" id="fitButton" title="Fit codebase (F)" aria-label="Fit codebase"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 4H4v4m12-4h4v4M4 16v4h4m12-4v4h-4"/></svg></button><button type="button" id="zoomInButton" title="Zoom in" aria-label="Zoom in">+</button></div>
|
||||
</div>
|
||||
|
||||
<aside class="inspector" id="inspector">
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="inspector" role="tab">Inspector</button>
|
||||
<button class="tab" data-tab="results" role="tab">Results <span id="resultBadge"></span></button>
|
||||
<button class="tab" data-tab="history" role="tab">History</button>
|
||||
</div>
|
||||
<section class="panel active" id="panel-inspector" role="tabpanel">
|
||||
<p class="panel-hint" id="inspectHint">Select an entity on the map</p>
|
||||
<div id="inspectContent"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-results" role="tabpanel">
|
||||
<div class="result-summary" id="resultSummary">Type in the filter to search files and symbols.</div>
|
||||
<div class="result-list" id="resultList"></div>
|
||||
</section>
|
||||
<section class="panel" id="panel-history" role="tabpanel">
|
||||
<div class="result-summary">Recently inspected</div>
|
||||
<div class="result-list" id="historyList"></div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="loadDialog" aria-labelledby="loadTitle">
|
||||
<form method="dialog" class="dialog-shell">
|
||||
<div class="dialog-head">
|
||||
<div><small>YOUR NEXT PERSPECTIVE</small><h1 id="loadTitle">Open a codebase</h1><p class="dialog-description">See the big picture. Explore every detail.</p></div>
|
||||
<button class="close-button" value="cancel" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
</div>
|
||||
<button type="button" class="source-card" id="localFolderButton">
|
||||
<span class="source-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h7l2-2h4l2 2h3v12H3zM3 10h18"/></svg></span>
|
||||
<span><strong>Local folder</strong><small>Temporarily indexed by this server</small></span>
|
||||
<b>→</b>
|
||||
</button>
|
||||
<div class="source-card github-card">
|
||||
<span class="source-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="7" cy="5" r="2"/><circle cx="7" cy="19" r="2"/><circle cx="17" cy="5" r="2"/><path d="M7 7v10m10-10v3a4 4 0 0 1-4 4H7"/></svg></span>
|
||||
<label for="githubInput"><strong>Public GitHub repository</strong><small>Explore a repository from its URL</small></label>
|
||||
<div class="github-row"><input id="githubInput" type="url" autocomplete="off" spellcheck="false" aria-describedby="githubError" placeholder="https://github.com/owner/repo"><button type="button" id="githubButton">Explore →</button></div>
|
||||
<p class="form-error" id="githubError" role="alert"></p>
|
||||
</div>
|
||||
<p class="dialog-foot">Files are held temporarily by this server and cleared when it restarts. Do not upload private code to a public deployment you do not control.</p>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settingsDialog" class="settings-dialog" aria-labelledby="settingsTitle">
|
||||
<form method="dialog" class="dialog-shell settings-shell">
|
||||
<div class="dialog-head">
|
||||
<div><small>MAKE IT YOURS</small><h1 id="settingsTitle">Workspace settings</h1><p class="dialog-description">A workspace that feels right for you.</p></div>
|
||||
<button class="close-button" value="cancel" aria-label="Close settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
</div>
|
||||
<div id="settingsContent"></div>
|
||||
<div class="preference-row"><label for="paletteSelect">Landscape palette<small>Color-code the layers of your codebase</small></label><select id="paletteSelect"><option value="0">Botanical</option><option value="1">Prismatic</option><option value="2">Autumn</option></select></div>
|
||||
<div class="preference-row"><label for="sourceToggle">Source text<small>Show code directly in the landscape</small></label><input type="checkbox" class="switch" id="sourceToggle" checked></div>
|
||||
<div class="preference-row"><label for="fpsToggle">Performance monitor<small>Display frame rate on the canvas</small></label><input type="checkbox" class="switch" id="fpsToggle"></div>
|
||||
<div class="shortcut-guide"><span>Keyboard essentials</span><div><kbd>/</kbd> Search <kbd>F</kbd> Fit landscape <kbd>Esc</kbd> Close</div></div>
|
||||
<div class="settings-note">
|
||||
<span class="settings-note-icon" aria-hidden="true">⌁</span>
|
||||
<p><strong>Room to explore</strong><small>Move from the whole codebase to a single line. Scroll to zoom; drag to move. In 3D, hold Shift while dragging to pan.</small></p>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button class="dialog-button" value="close">Done</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<input id="folderFallback" type="file" webkitdirectory multiple hidden>
|
||||
|
||||
<script src="./config.js"></script>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
const VERTICES = new Float32Array([
|
||||
// top
|
||||
0,0,1, 1,0,1, 1,1,1, 0,0,1, 1,1,1, 0,1,1,
|
||||
// front
|
||||
0,1,0, 1,1,0, 1,1,1, 0,1,0, 1,1,1, 0,1,1,
|
||||
// back
|
||||
1,0,0, 0,0,0, 0,0,1, 1,0,0, 0,0,1, 1,0,1,
|
||||
// left
|
||||
0,0,0, 0,1,0, 0,1,1, 0,0,0, 0,1,1, 0,0,1,
|
||||
// right
|
||||
1,1,0, 1,0,0, 1,0,1, 1,1,0, 1,0,1, 1,1,1,
|
||||
// bottom
|
||||
0,1,0, 0,0,0, 1,0,0, 0,1,0, 1,0,0, 1,1,0,
|
||||
]);
|
||||
|
||||
const VS = `#version 300 es
|
||||
precision highp float;
|
||||
layout(location=0) in vec3 aUnit;
|
||||
layout(location=1) in vec4 aRect;
|
||||
layout(location=2) in vec4 aColorHeight;
|
||||
uniform vec2 uCenter;
|
||||
uniform vec2 uViewport;
|
||||
uniform float uZoom;
|
||||
uniform float uMode;
|
||||
uniform vec2 uOrbit;
|
||||
uniform float uDistance;
|
||||
out vec4 vColor;
|
||||
out vec3 vUnit;
|
||||
out float vFace;
|
||||
void main(){
|
||||
float height = max(1.0, aColorHeight.a);
|
||||
vec3 world = vec3(aRect.xy + aUnit.xy * aRect.zw, aUnit.z * height);
|
||||
vColor = vec4(aColorHeight.rgb, 1.0);
|
||||
vUnit = aUnit;
|
||||
vFace = float(gl_VertexID / 6);
|
||||
if(uMode < .5){
|
||||
vec2 p = (world.xy - uCenter) * uZoom;
|
||||
gl_Position = vec4(p.x * 2.0 / uViewport.x, -p.y * 2.0 / uViewport.y, aUnit.z * .001, 1.0);
|
||||
} else {
|
||||
vec3 p = vec3((world.xy - uCenter) / 420.0, world.z / 170.0);
|
||||
float cy=cos(uOrbit.x), sy=sin(uOrbit.x), cp=cos(uOrbit.y), sp=sin(uOrbit.y);
|
||||
p = vec3(cy*p.x-sy*p.y, sy*p.x+cy*p.y, p.z);
|
||||
// Positive pitch places the camera above the ground plane. The previous
|
||||
// signs put the eye below it, making every useful orbit an underside view.
|
||||
p = vec3(p.x, cp*p.y+sp*p.z, -sp*p.y+cp*p.z);
|
||||
float depth = max(.5, uDistance - p.y);
|
||||
float aspect = uViewport.x / uViewport.y;
|
||||
gl_Position = vec4(p.x/(depth*aspect)*2.7, (p.z-.35)/depth*2.7, (depth-1.0)/10.0, 1.0);
|
||||
}
|
||||
}`;
|
||||
|
||||
const FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec4 vColor;
|
||||
in vec3 vUnit;
|
||||
in float vFace;
|
||||
uniform float uMode;
|
||||
uniform float uAlpha;
|
||||
out vec4 outColor;
|
||||
void main(){
|
||||
if(uMode < .5 && vFace > .5) discard;
|
||||
vec2 face = vFace < .5 || vFace > 4.5 ? vUnit.xy : vFace < 2.5 ? vUnit.xz : vUnit.yz;
|
||||
float edge = min(min(face.x, 1.0-face.x), min(face.y, 1.0-face.y));
|
||||
float border = smoothstep(0.0, fwidth(edge)*1.35 + .009, edge);
|
||||
vec3 base = vec3(.11);
|
||||
vec3 stroke = mix(vColor.rgb, vec3(1.0), .07);
|
||||
outColor = vec4(mix(stroke, base, border), uAlpha);
|
||||
}`;
|
||||
|
||||
function shader(gl, type, source) {
|
||||
const value = gl.createShader(type); gl.shaderSource(value, source); gl.compileShader(value);
|
||||
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
export class LandscapeRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
const gl = canvas.getContext('webgl2', { antialias: true, alpha: true, depth: true, powerPreference: 'high-performance' });
|
||||
if (!gl) throw new Error('WebGL2 is required');
|
||||
this.gl = gl;
|
||||
this.program = gl.createProgram();
|
||||
gl.attachShader(this.program, shader(gl, gl.VERTEX_SHADER, VS));
|
||||
gl.attachShader(this.program, shader(gl, gl.FRAGMENT_SHADER, FS));
|
||||
gl.linkProgram(this.program);
|
||||
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(this.program));
|
||||
this.vao = gl.createVertexArray(); gl.bindVertexArray(this.vao);
|
||||
const vertices = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vertices); gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
|
||||
this.instances = gl.createBuffer();
|
||||
this.camera = { x: 500, y: 350, zoom: 1, yaw: -.1, pitch: .78, distance: 3.5 };
|
||||
this.mode = '2d'; this.count = 0;
|
||||
this.resize();
|
||||
}
|
||||
|
||||
setData(items) {
|
||||
this.items = items;
|
||||
const packed = new Float32Array(items.length * 8);
|
||||
for (let i=0;i<items.length;i++) {
|
||||
const item=items[i], o=i*8;
|
||||
packed.set([item.x,item.y,item.w,item.h,item.color[0],item.color[1],item.color[2],item.height],o);
|
||||
}
|
||||
const gl=this.gl; gl.bindVertexArray(this.vao); gl.bindBuffer(gl.ARRAY_BUFFER,this.instances); gl.bufferData(gl.ARRAY_BUFFER,packed,gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1,4,gl.FLOAT,false,32,0); gl.vertexAttribDivisor(1,1);
|
||||
gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2,4,gl.FLOAT,false,32,16); gl.vertexAttribDivisor(2,1);
|
||||
this.count=items.length;
|
||||
}
|
||||
|
||||
resize() {
|
||||
const dpr=Math.min(devicePixelRatio||1,2), w=Math.max(1,Math.floor(this.canvas.clientWidth*dpr)), h=Math.max(1,Math.floor(this.canvas.clientHeight*dpr));
|
||||
if(this.canvas.width!==w||this.canvas.height!==h){this.canvas.width=w;this.canvas.height=h;}
|
||||
this.dpr=dpr; this.gl.viewport(0,0,w,h);
|
||||
}
|
||||
|
||||
render(alpha=1) {
|
||||
const gl=this.gl; this.resize();
|
||||
gl.clearColor(.11,.11,.11,1); gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
|
||||
gl.enable(gl.DEPTH_TEST); gl.depthFunc(gl.LEQUAL); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.useProgram(this.program); gl.bindVertexArray(this.vao);
|
||||
const u=n=>gl.getUniformLocation(this.program,n);
|
||||
gl.uniform2f(u('uCenter'),this.camera.x,this.camera.y);
|
||||
gl.uniform2f(u('uViewport'),this.canvas.width/this.dpr,this.canvas.height/this.dpr);
|
||||
gl.uniform1f(u('uZoom'),this.camera.zoom);
|
||||
gl.uniform1f(u('uMode'),this.mode==='3d'?1:0);
|
||||
gl.uniform2f(u('uOrbit'),this.camera.yaw,this.camera.pitch);
|
||||
gl.uniform1f(u('uDistance'),this.camera.distance);
|
||||
gl.uniform1f(u('uAlpha'),alpha);
|
||||
gl.drawArraysInstanced(gl.TRIANGLES,0,36,this.count);
|
||||
}
|
||||
|
||||
screenRect(item) {
|
||||
if(this.mode==='2d') return { x:(item.x-this.camera.x)*this.camera.zoom+this.canvas.clientWidth/2, y:(item.y-this.camera.y)*this.camera.zoom+this.canvas.clientHeight/2, w:item.w*this.camera.zoom, h:item.h*this.camera.zoom };
|
||||
const corners=[[item.x,item.y,item.height],[item.x+item.w,item.y,item.height],[item.x,item.y+item.h,item.height],[item.x+item.w,item.y+item.h,item.height]].map(p=>this.project(p));
|
||||
const xs=corners.map(p=>p.x), ys=corners.map(p=>p.y);
|
||||
return {x:Math.min(...xs),y:Math.min(...ys),w:Math.max(...xs)-Math.min(...xs),h:Math.max(...ys)-Math.min(...ys)};
|
||||
}
|
||||
|
||||
project([x,y,z=0]) {
|
||||
let px=(x-this.camera.x)/420, py=(y-this.camera.y)/420, pz=z/170;
|
||||
const cy=Math.cos(this.camera.yaw),sy=Math.sin(this.camera.yaw),cp=Math.cos(this.camera.pitch),sp=Math.sin(this.camera.pitch);
|
||||
[px,py]=[cy*px-sy*py,sy*px+cy*py]; [py,pz]=[cp*py+sp*pz,-sp*py+cp*pz];
|
||||
const depth=Math.max(.5,this.camera.distance-py), aspect=this.canvas.clientWidth/this.canvas.clientHeight;
|
||||
return {x:this.canvas.clientWidth/2+(px/(depth*aspect)*2.7)*this.canvas.clientWidth/2,y:this.canvas.clientHeight/2-((pz-.35)/depth*2.7)*this.canvas.clientHeight/2,depth};
|
||||
}
|
||||
|
||||
worldAt(clientX,clientY) {
|
||||
return {x:this.camera.x+(clientX-this.canvas.clientWidth/2)/this.camera.zoom,y:this.camera.y+(clientY-this.canvas.clientHeight/2)/this.camera.zoom};
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #212121;
|
||||
--bg-deep: #161616;
|
||||
--panel: #1e1e1e;
|
||||
--panel-2: #262626;
|
||||
--line: #3d3d3d;
|
||||
--line-soft: #2f2f2f;
|
||||
--text: #e2e2e2;
|
||||
--muted: #a3a3a3;
|
||||
--dim: #626262;
|
||||
--cyan: #65b3ff;
|
||||
--green: #29c997;
|
||||
--yellow: #d6b23d;
|
||||
--orange: #d67d42;
|
||||
--pink: #cf6fa4;
|
||||
--blue: #488ad2;
|
||||
--danger: #ef6b69;
|
||||
--panel-width: 280px;
|
||||
--accent: #cdcdcd;
|
||||
--toolbar-height: 46px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
html, body, #app { width: 100%; height: 100%; overflow: hidden; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-deep);
|
||||
color: var(--text);
|
||||
font: 12px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
letter-spacing: .005em;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
.toolbar {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
height: var(--toolbar-height);
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: linear-gradient(180deg, #222222, #1d1d1d);
|
||||
border-bottom: 1px solid #383838;
|
||||
box-shadow: 0 4px 20px #0002;
|
||||
}
|
||||
.tool-group { display: flex; align-items: center; gap: 3px; }
|
||||
.tool, .metric {
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #a9a9a9;
|
||||
cursor: pointer;
|
||||
transition: 100ms ease;
|
||||
}
|
||||
.tool { width: 28px; padding: 0; display: grid; place-items: center; }
|
||||
.tool svg { width: 15px; height: 15px; display: block; margin: 0; fill: none; stroke: currentColor; stroke-width: 1.65; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.tool-link { display: grid; place-items: center; text-decoration: none; }
|
||||
.github-link svg { width: 16px; height: 16px; margin: 0; fill: currentColor; stroke: none; }
|
||||
.metric b { font: 500 15px/1 "SFMono-Regular", Consolas, monospace; }
|
||||
.tool:hover, .metric:hover { color: #e8e8e8; background: #313131; }
|
||||
.tool.active, .metric.active { color: #dadada; border-color: #525252; background: #333333; box-shadow: inset 0 1px #ffffff09; }
|
||||
.tool.primary { border-color: #c7c7c7; color: #262626; background: #cdcdcd; }
|
||||
.tool.primary:hover { background: #dedede; color: #1a1a1a; }
|
||||
.tool.palette { border-radius: 50%; }
|
||||
.separator { width: 1px; height: 22px; background: #3c3c3c; margin: 0 4px; }
|
||||
.metric-switch { display: flex; align-items: center; }
|
||||
.metric { padding: 0 8px; display: flex; gap: 5px; align-items: center; }
|
||||
.toolbar-spacer { flex: 1; }
|
||||
.scene-stats { text-align: right; color: #b0b0b0; line-height: 1.25; min-width: 210px; }
|
||||
.scene-stats small { display: block; color: #6b6b6b; }
|
||||
.filter-wrap { position: relative; width: min(230px, 20vw); }
|
||||
.filter-wrap input {
|
||||
width: 100%; height: 29px; padding: 0 30px 0 10px;
|
||||
border: 1px solid #424242; border-radius: 7px;
|
||||
outline: none; color: #e3e3e3; background: #313131;
|
||||
}
|
||||
.filter-wrap input:focus { border-color: #999999; box-shadow: 0 0 0 2px rgba(71,145,210,.13); }
|
||||
.filter-wrap input::placeholder { color: #848484; }
|
||||
.filter-wrap kbd { position: absolute; right: 8px; top: 6px; color: #727272; font: 10px monospace; }
|
||||
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: #868686; box-shadow: 0 0 0 2px rgba(237,69,96,.12); }
|
||||
.live-dot.connected { background: var(--green); box-shadow: 0 0 0 2px rgba(41,201,151,.12); }
|
||||
|
||||
.workspace { position: absolute; inset: var(--toolbar-height) 0 0; display: grid; grid-template-columns: minmax(0,1fr) var(--panel-width); }
|
||||
.viewport { position: relative; min-width: 0; overflow: hidden; touch-action: none; user-select: none; background-color: #212121; background-image: linear-gradient(45deg,rgba(255,255,255,.012) 25%,transparent 25%),linear-gradient(-45deg,rgba(255,255,255,.012) 25%,transparent 25%),linear-gradient(45deg,transparent 75%,rgba(255,255,255,.012) 75%),linear-gradient(-45deg,transparent 75%,rgba(255,255,255,.012) 75%); background-size: 24px 24px; background-position: 0 0,0 12px,12px -12px,-12px 0; cursor: grab; }
|
||||
.viewport.dragging { cursor: grabbing; }
|
||||
.viewport.panning { cursor: move; }
|
||||
#glCanvas, #overlayCanvas { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
#overlayCanvas { pointer-events: none; }
|
||||
.inspector { background: linear-gradient(160deg,#222222,#1d1d1d); border-left: 1px solid #393939; min-width: 0; overflow: hidden; }
|
||||
.tabs { height: 40px; display: flex; align-items: center; gap: 4px; padding: 0 10px; border-bottom: 1px solid var(--line-soft); }
|
||||
.tab { height: 27px; padding: 0 9px; border: 0; border-radius: 7px; background: transparent; color: #9c9c9c; cursor: pointer; }
|
||||
.tab:hover { color: #bbbbbb; }
|
||||
.tab.active { color: #dedede; background: #333333; box-shadow: inset 0 0 0 1px #454545; }
|
||||
#resultBadge:not(:empty) { margin-left: 3px; padding: 1px 5px; border-radius: 8px; background: #404040; font-size: 9px; }
|
||||
.panel { display: none; height: calc(100% - 40px); overflow: auto; padding: 13px 12px 20px; scrollbar-color: #545454 transparent; scrollbar-width: thin; }
|
||||
.panel.active { display: block; }
|
||||
.panel-hint, .result-summary { color: #888888; margin: 0 0 12px; }
|
||||
.entity-title { margin-bottom: 12px; }
|
||||
.entity-title small { display: block; color: #787878; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.entity-title h2 { margin: 6px 0 0; color: #ededed; font-size: 16px; font-weight: 550; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
||||
.meta-grid { display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; padding: 10px 0; border-top: 1px solid var(--line-soft); border-bottom: 1px solid var(--line-soft); }
|
||||
.meta-grid span { color: #888888; }
|
||||
.meta-grid b { color: #d4d4d4; font-weight: 500; text-align: right; }
|
||||
.section-title { margin: 14px 0 7px; color: #e4e4e4; font-weight: 600; }
|
||||
.relation { display: flex; gap: 8px; align-items: center; padding: 5px 7px; margin: 0 -7px; border-radius: 4px; cursor: pointer; }
|
||||
.relation:hover { background: #313131; }
|
||||
.relation i { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||
.relation span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.relation small { color: #777777; }
|
||||
.coverage-title { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.coverage-title small, .coverage-list-head small { color: #727272; font-size: 9px; font-weight: 400; }
|
||||
.coverage-menu { display: grid; gap: 2px; }
|
||||
.coverage-row { width: 100%; min-height: 31px; display: grid; grid-template-columns: 7px 1fr auto 12px; gap: 8px; align-items: center; padding: 5px 7px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.coverage-row:hover { background: #313131; }
|
||||
.coverage-row.active { border-color: #444444; background: #2e2e2e; }
|
||||
.coverage-row i { width: 7px; height: 7px; border-radius: 2px; }
|
||||
.coverage-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.coverage-row small { color: #8e8e8e; font-variant-numeric: tabular-nums; }
|
||||
.coverage-row b { color: #767676; font-size: 13px; font-weight: 400; text-align: center; }
|
||||
.coverage-list-head { display: flex; justify-content: space-between; align-items: baseline; margin: 14px 0 6px; color: #dedede; font-weight: 600; }
|
||||
.coverage-file-list { position: relative; height: min(39vh, 350px); min-height: 150px; overflow: auto; border: 1px solid #3b3b3b; border-radius: 6px; outline: none; background: #222222; scrollbar-color: #545454 transparent; scrollbar-width: thin; }
|
||||
.coverage-file-list:focus { border-color: #808080; box-shadow: 0 0 0 2px rgba(75,145,204,.11); }
|
||||
.coverage-list-spacer { width: 1px; }
|
||||
.coverage-list-window { position: absolute; inset: 0 0 auto; }
|
||||
.coverage-file { width: 100%; height: 43px; display: grid; grid-template-columns: 3px minmax(0,1fr) auto; gap: 8px; align-items: stretch; padding: 5px 7px; border: 0; border-bottom: 1px solid #2f2f2f; background: transparent; color: inherit; text-align: left; cursor: pointer; }
|
||||
.coverage-file:hover { background: #2b2b2b; }
|
||||
.coverage-file.active { background: #373737; box-shadow: inset 2px 0 #b2b2b2; }
|
||||
.coverage-file > i { border-radius: 2px; }
|
||||
.coverage-file > span { min-width: 0; align-self: center; }
|
||||
.coverage-file strong, .coverage-file small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.coverage-file strong { color: #dedede; font-weight: 500; }
|
||||
.coverage-file small { color: #747474; font-size: 10px; }
|
||||
.coverage-file em { align-self: center; color: #7a7a7a; font-size: 9px; font-style: normal; font-variant-numeric: tabular-nums; }
|
||||
.coverage-empty { padding: 18px 10px; color: #777777; text-align: center; }
|
||||
.source-title { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.source-title small { color: #767676; font-weight: 400; }
|
||||
.code-preview { position: relative; height: min(46vh,430px); min-height: 220px; overflow: auto; border: 1px solid #3a3a3a; border-radius: 3px; outline: none; background: #171717; color: #afafaf; font: 10px/16px "SFMono-Regular", Consolas, monospace; tab-size: 2; scrollbar-color: #545454 transparent; scrollbar-width: thin; }
|
||||
.code-preview:focus { border-color: #626262; box-shadow: 0 0 0 1px rgba(70,154,123,.15); }
|
||||
.source-spacer { position: relative; min-width: 100%; }
|
||||
.source-window { position: absolute; inset: 0 auto auto 0; min-width: 100%; margin: 0; padding: 0; color: inherit; font: inherit; line-height: 16px; white-space: pre; }
|
||||
.source-line { display: block; height: 16px; padding-right: 10px; }
|
||||
.source-line::before { content: attr(data-line); position: sticky; left: 0; display: inline-block; width: 35px; margin-right: 10px; padding-right: 7px; border-right: 1px solid #303030; background: #171717; color: #555555; text-align: right; user-select: none; }
|
||||
.tok-plain { color: #aab5af; }
|
||||
.tok-comment { color: #64726b; font-style: italic; }
|
||||
.tok-string { color: #89b88f; }
|
||||
.tok-number { color: #83a8d8; }
|
||||
.tok-keyword { color: #d8a75b; }
|
||||
.tok-type { color: #79b8d8; }
|
||||
.tok-function { color: #d6cf8b; }
|
||||
.tok-property { color: #b397d5; }
|
||||
.tok-operator { color: #8d9c95; }
|
||||
.tok-constant { color: #d98585; }
|
||||
.tok-tag { color: #65b9aa; }
|
||||
.result-row { display: grid; grid-template-columns: 3px 1fr; gap: 8px; padding: 7px 3px; border-bottom: 1px solid #313131; cursor: pointer; }
|
||||
.result-row:hover { background: #2c2c2c; }
|
||||
.result-row i { background: var(--yellow); border-radius: 2px; }
|
||||
.result-row strong { display: block; color: #dbdbdb; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.result-row small { display: block; color: #777777; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.breadcrumb { position: absolute; left: 18px; bottom: 18px; max-width: calc(100% - 170px); display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid #414141; border-radius: 9px; background: #1c1c1ced; color: #ababab; box-shadow: 0 4px 16px #0002; }
|
||||
#breadcrumbText { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.breadcrumb button { border: 0; padding: 2px; background: transparent; cursor: pointer; color: #b2b2b2; }
|
||||
.fps { position: absolute; right: 22px; bottom: 65px; color: #a2a2a2; font: 10px monospace; }
|
||||
.legend { position: absolute; right: 9px; top: 10px; display: none; width: 190px; padding: 6px; border: 1px solid #464646; border-radius: 6px; background: rgba(31,33,32,.94); box-shadow: 0 7px 25px rgba(0,0,0,.3); }
|
||||
.legend.open { display: grid; gap: 2px; }
|
||||
.legend button { width: 100%; display: flex; justify-content: space-between; gap: 12px; padding: 5px 6px; border: 0; border-radius: 4px; color: inherit; background: transparent; text-align: left; cursor: pointer; }
|
||||
.legend button:hover { background: #343434; }
|
||||
.legend button b { color: #909090; font-weight: 500; }
|
||||
.legend i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
|
||||
.tooltip { position: absolute; z-index: 3; max-width: 310px; padding: 7px 9px; pointer-events: none; border: 1px solid #515151; border-radius: 5px; background: rgba(27,29,28,.96); box-shadow: 0 5px 20px rgba(0,0,0,.35); color: #dedede; }
|
||||
.camera-help { position: absolute; z-index: 2; left: 18px; top: 18px; width: fit-content; display: flex; gap: 13px; align-items: center; padding: 8px 12px; pointer-events: none; border: 1px solid rgba(110,118,113,.28); border-radius: 8px; background: rgba(27,29,28,.92); color: #a2a2a2; font-size: 10px; }
|
||||
.camera-help[hidden] { display: none; }
|
||||
.camera-help { pointer-events: auto; flex-wrap: wrap; max-width: calc(100% - 36px); }
|
||||
.camera-help button { border: 1px solid #494949; border-radius: 5px; background: #292929; padding: 4px 7px; color: #ccc; cursor: pointer; font-size: 10px; }
|
||||
.camera-help button:hover { background: #414141; color: #fff; }
|
||||
.camera-help b { color: #b9b9b9; font-weight: 600; }
|
||||
.tooltip small { display: block; color: #888888; }
|
||||
.progress-card { position: absolute; z-index: 4; left: 50%; top: 22px; transform: translateX(-50%); width: min(420px, calc(100% - 30px)); padding: 12px 14px; border: 1px solid #474747; border-radius: 8px; background: rgba(37,40,38,.97); box-shadow: 0 12px 35px rgba(0,0,0,.34); }
|
||||
.progress-copy { display: flex; justify-content: space-between; gap: 15px; }
|
||||
.progress-copy span, .progress-count { color: #888888; }
|
||||
.progress-track { height: 3px; margin: 10px 0 5px; overflow: hidden; background: #353535; border-radius: 2px; }
|
||||
.progress-track i { display: block; height: 100%; width: 5%; background: linear-gradient(90deg,#8b8b8b,#818181); transition: width .18s ease; }
|
||||
.progress-count { font-size: 10px; }
|
||||
.empty-state { position: absolute; inset: 0; display: grid; place-content: center; text-align: center; gap: 4px; color: #c7c7c7; }
|
||||
.empty-state span { color: #7a7a7a; }
|
||||
|
||||
dialog { width: min(560px, calc(100vw - 30px)); max-height: calc(100dvh - 32px); padding: 0; border: 1px solid #484848; border-radius: 18px; color: var(--text); background: linear-gradient(145deg,#2a2a2a,#1e1e1e); box-shadow: 0 32px 120px #0009, inset 0 1px #ffffff0c; }
|
||||
dialog::backdrop { background: rgba(8,10,9,.7); backdrop-filter: blur(5px); }
|
||||
.dialog-shell { padding: 30px; }
|
||||
.dialog-head { display: flex; align-items: start; justify-content: space-between; margin-bottom: 17px; }
|
||||
.dialog-head small { color: #b6b6b6; font-size: 9px; font-weight: 600; letter-spacing: .18em; }
|
||||
.dialog-head h1 { margin: 8px 0 0; font-size: 27px; font-weight: 550; letter-spacing: -.04em; }
|
||||
.close-button { width: 30px; height: 30px; flex-shrink: 0; display: grid; place-items: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: #919191; cursor: pointer; }
|
||||
.close-button svg { display: block; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
|
||||
.close-button:hover { background: #313131; color: #fff; }
|
||||
.source-card { width: 100%; margin-top: 12px; padding: 18px; border: 1px solid #454545; border-radius: 12px; background: #ffffff03; color: inherit; text-align: left; }
|
||||
button.source-card { display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
||||
button.source-card:hover { border-color: #787878; background: #2f2f2f; }
|
||||
.source-card > b { margin-left: auto; color: #828282; font-size: 17px; }
|
||||
.source-icon { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #4d4d4d; border-radius: 7px; color: #a9a9a9; background: #242424; font-size: 18px; }
|
||||
.source-card strong, .source-card small { display: block; }
|
||||
.source-card small { margin-top: 2px; color: #808080; font-weight: 400; }
|
||||
.github-card { display: grid; grid-template-columns: 34px 1fr; gap: 0 12px; }
|
||||
.github-row { grid-column: 1 / -1; display: flex; gap: 7px; margin-top: 12px; }
|
||||
.github-row input { flex: 1; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid #474747; border-radius: 6px; outline: none; color: #e3e3e3; background: #212121; }
|
||||
.github-row input:focus { border-color: #979797; }
|
||||
.github-row button { border: 1px solid #c7c7c7; border-radius: 7px; padding: 0 14px; color: #262626; background: #cdcdcd; cursor: pointer; font-weight: 600; }
|
||||
.form-error { grid-column: 1 / -1; min-height: 16px; margin: 5px 0 -5px; color: var(--danger); font-size: 11px; }
|
||||
.dialog-foot { margin: 15px 2px 0; color: #707070; font-size: 10px; }
|
||||
.settings-dialog { width: min(460px, calc(100vw - 30px)); }
|
||||
.settings-shell { padding: 28px; }
|
||||
.settings-status { display: flex; align-items: center; justify-content: space-between; margin: -3px 0 13px; padding: 9px 11px; border: 1px solid #404040; border-radius: 7px; background: #222222; }
|
||||
.settings-status span { display: flex; align-items: center; gap: 7px; color: #dddddd; font-weight: 600; }
|
||||
.settings-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(41,201,151,.1); }
|
||||
.settings-status small { color: #787878; font-size: 9px; letter-spacing: .07em; }
|
||||
.settings-grid { padding: 12px 2px; }
|
||||
.settings-note { display: grid; grid-template-columns: 32px 1fr; gap: 10px; align-items: start; margin-top: 14px; padding: 11px; border: 1px solid #383838; border-radius: 7px; background: #2a2a2a; }
|
||||
.settings-note-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 6px; color: #a3a3a3; background: #222222; font-size: 16px; }
|
||||
.settings-note p { margin: 0; }
|
||||
.settings-note strong, .settings-note small { display: block; }
|
||||
.settings-note strong { color: #dbdbdb; font-size: 11px; }
|
||||
.settings-note small { margin-top: 3px; color: #7a7a7a; line-height: 1.45; }
|
||||
.settings-actions { display: flex; justify-content: flex-end; margin-top: 17px; }
|
||||
.dialog-button { min-width: 84px; height: 36px; padding: 0 18px; border: 1px solid #c7c7c7; border-radius: 8px; color: #262626; background: #cdcdcd; cursor: pointer; font-weight: 600; }
|
||||
.dialog-button:hover, .github-row button:hover { background: #dedede; }
|
||||
|
||||
.brand { display: flex; gap: 10px; align-items: center; padding-right: 11px; margin-right: 4px; border-right: 1px solid var(--line); color: #dbdbdb; font-size: 14px; font-weight: 600; letter-spacing: -.035em; line-height: 1.2; }
|
||||
.brand svg { width: 21px; height: 21px; fill: #bbbbbb; }
|
||||
.brand-sub { display: block; margin-top: 3px; font-size: 8px; letter-spacing: .25em; font-weight: 500; color: #999999; }
|
||||
.view-switch, .metric-switch { padding: 2px; border: 1px solid #363636; border-radius: 10px; background: #15151566; gap: 3px; }
|
||||
.filter-wrap { width: min(240px, 20vw); }
|
||||
.filter-wrap input { height: 30px; border-radius: 9px; border-color: #3d3d3d; background: #181818; padding-left: 13px; }
|
||||
.filter-wrap kbd { top: 6px; border: 1px solid #444444; border-radius: 3px; padding: 0 4px; }
|
||||
.filter-wrap input:focus, .github-row input:focus { border-color: #a4a4a4; box-shadow: 0 0 0 3px #a4a4a415; }
|
||||
.scene-stats { min-width: 0; margin-right: 8px; font-size: 11px; }
|
||||
.scene-stats small { color: #8e8e8e; margin-top: 3px; }
|
||||
.live-dot { flex-shrink: 0; margin-left: 5px; }
|
||||
.panel-hint, .result-summary { color: #a2a2a2; line-height: 1.7; }
|
||||
.meta-grid { gap: 5px 10px; padding: 10px 0; font-variant-numeric: tabular-nums; }
|
||||
.meta-grid span, .entity-title small { color: #9a9a9a; }
|
||||
.section-title { margin-top: 16px; }
|
||||
.coverage-menu { gap: 2px; }
|
||||
.coverage-row { min-height: 29px; border-radius: 6px; padding: 4px 7px; }
|
||||
.coverage-row:hover, .relation:hover, .result-row:hover { background: #ffffff06; }
|
||||
.coverage-row.active { background: #333333; border-color: #525252; }
|
||||
.coverage-title small, .coverage-list-head small, .coverage-file small, .result-row small { color: #989898; }
|
||||
.coverage-file-list { border-radius: 9px; border-color: #3f3f3f; background: #191919; }
|
||||
.coverage-file.active { background: #343434; box-shadow: inset 2px 0 #bbbbbb; }
|
||||
.coverage-file-list:focus { border-color: #aaaaaa; box-shadow: 0 0 0 2px #a4a4a415; }
|
||||
.result-row { min-width: 0; border-radius: 7px; padding: 9px 8px; grid-template-columns: 3px minmax(0,1fr); }
|
||||
.code-preview { border-radius: 9px; }
|
||||
.canvas-controls { position: absolute; right: 18px; bottom: 18px; display: flex; padding: 4px; gap: 3px; border: 1px solid #414141; border-radius: 10px; background: #1c1c1cf5; box-shadow: 0 4px 16px #0002; }
|
||||
.canvas-controls button { display: grid; place-items: center; width: 30px; height: 28px; background: transparent; color: #c9c9c9; border: 0; border-radius: 6px; font-size: 18px; cursor: pointer; }
|
||||
.canvas-controls button:hover { background: #353535; }
|
||||
.canvas-controls svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
|
||||
.progress-card { border-radius: 12px; border-color: #4c4c4c; padding: 18px; padding-right: 40px; background: #262626f5; }
|
||||
.progress-copy { flex-direction: column; gap: 4px; }
|
||||
#progressMessage { overflow-wrap: anywhere; max-height: 100px; overflow: auto; }
|
||||
.progress-dismiss { position: absolute; right: 5px; top: 5px; }
|
||||
.dialog-description { margin: 9px 0 3px; color: #a3a3a3; font-size: 12px; }
|
||||
.source-icon { width: 36px; height: 36px; border-color: #4c4c4c; background: #313131; color: #c5c5c5; }
|
||||
.source-icon svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.source-card small { color: #a1a1a1; }
|
||||
.dialog-foot { color: #9a9a9a; line-height: 1.7; }
|
||||
.github-row input { height: 39px; }
|
||||
.settings-status { border-color: #414141; background: #1b1b1b; margin-bottom: 16px; }
|
||||
.settings-note { background: #ffffff03; border-color: #3d3d3d; border-radius: 10px; }
|
||||
.settings-note small { color: #a0a0a0; }
|
||||
.preference-row { display: flex; gap: 16px; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.preference-row label { color: #dbdbdb; font-weight: 500; }
|
||||
.preference-row small { display: block; font-size: 10px; font-weight: 400; color: #a0a0a0; margin-top: 3px; }
|
||||
.preference-row select { padding: 7px 9px; border: 1px solid #4b4b4b; border-radius: 7px; background: #1e1e1e; color: #d0d0d0; font: inherit; }
|
||||
.switch { appearance: none; flex-shrink: 0; width: 34px; height: 20px; border: 1px solid #575757; border-radius: 20px; background: #323232; cursor: pointer; margin: 0; padding: 2px; }
|
||||
.switch::before { content: ''; display: block; width: 14px; height: 14px; border-radius: 50%; background: #9b9b9b; transition: transform .15s; }
|
||||
.switch:checked { background: #b1b1b1; border-color: #b1b1b1; }
|
||||
.switch:checked::before { background: #272727; transform: translateX(14px); }
|
||||
.shortcut-guide { color: #a0a0a0; margin-top: 18px; font-size: 10px; }
|
||||
.shortcut-guide > span { display: block; margin-bottom: 10px; }
|
||||
.shortcut-guide div { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
kbd { font: 10px/1.5 ui-monospace, monospace; border: 1px solid #4b4b4b; border-radius: 4px; padding: 1px 5px; color: #d2d2d2; background: #1d1d1d; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, [role="tab"]:focus-visible { outline: 2px solid #bbbbbb; outline-offset: 3px; }
|
||||
html[data-input-method="pointer"] button:focus { outline: none; }
|
||||
button { transition: background-color .15s, border-color .15s, color .15s; }
|
||||
button:active { filter: brightness(.93); }
|
||||
::selection { background: #67676766; color: #f1f1f1; }
|
||||
* { scrollbar-width: thin; scrollbar-color: #484848 transparent; }
|
||||
@media (max-width: 1250px) { .scene-stats { display: none; } }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
:root { --panel-width: 250px; }
|
||||
.metric span, .scene-stats { display: none; }
|
||||
.filter-wrap { width: 150px; }
|
||||
.brand > span { display: none; }
|
||||
.brand { padding-right: 10px; margin-right: 0; }
|
||||
.toolbar { gap: 4px; padding: 0 10px; }
|
||||
}
|
||||
@media (max-width: 670px) {
|
||||
:root { --panel-width: 0px; --toolbar-height: 46px; }
|
||||
.workspace { grid-template-columns: 1fr; grid-template-rows: minmax(160px,1fr) minmax(150px,28%); }
|
||||
.inspector { display: block; border-left: 0; border-top: 1px solid var(--line); }
|
||||
.tabs { height: 36px; }
|
||||
.panel { height: calc(100% - 36px); padding: 10px 12px; }
|
||||
.separator, #layersButton, #codeButton, .palette { display: none; }
|
||||
.filter-wrap { width: auto; flex: 1; min-width: 65px; }
|
||||
.brand, .metric-switch, #mapButton, .github-link, .toolbar-spacer { display: none; }
|
||||
.tool { width: 30px; min-width: 30px; }
|
||||
.dialog-shell { padding: 22px; }
|
||||
.camera-help { gap: 8px; font-size: 9px; left: 10px; top: 10px; }
|
||||
.breadcrumb { left: 10px; bottom: 10px; }
|
||||
.canvas-controls { right: 10px; bottom: 10px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; } }
|
||||
Reference in New Issue
Block a user