diff --git a/index.html b/index.html
index 244c3a5..26b0cd5 100644
--- a/index.html
+++ b/index.html
@@ -117,6 +117,8 @@
.canvas-area svg{width:100%;height:100%;display:block}
.canvas-toolbar{position:absolute;top:12px;left:12px;display:flex;gap:4px;z-index:50}
.canvas-info{position:absolute;bottom:12px;left:12px;display:flex;gap:8px;z-index:50}
+.canvas-minimap{position:absolute;right:12px;bottom:12px;z-index:50;width:176px;height:118px;background:var(--bg1);border:1px solid var(--border);border-radius:8px;overflow:hidden;cursor:crosshair;touch-action:none;user-select:none;-webkit-user-select:none}
+.canvas-minimap canvas{display:block;width:100%;height:100%;pointer-events:none}
.info-chip{padding:6px 10px;background:var(--bg1);border:1px solid var(--border);border-radius:6px;font-size:10px;color:var(--t2)}
.info-chip strong{color:var(--acc)}
.tool-btn{width:32px;height:32px;background:var(--bg1);border:1px solid var(--border);border-radius:6px;color:var(--t2);font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;touch-action:manipulation}
@@ -611,6 +613,10 @@
.legend{top:auto;bottom:68px;right:12px;max-width:calc(100vw - 24px)}
.legend.with-color-by{top:auto;bottom:68px}
.canvas-info{left:12px;right:12px;bottom:12px;overflow-x:auto;padding-bottom:calc(2px + env(safe-area-inset-bottom))}
+.canvas-minimap{width:132px;height:88px;right:12px;bottom:52px}
+.canvas-area:has(.canvas-minimap) .legend{bottom:148px}
+.canvas-area:has(.canvas-minimap) .legend.with-color-by{bottom:148px}
+.canvas-area:has(.canvas-minimap) .color-by{bottom:198px}
.info-chip{white-space:nowrap;flex:0 0 auto}
.export-options,.pr-grid,.pr-impact-grid,.unused-summary{grid-template-columns:1fr}
.pr-risk{flex-direction:column;align-items:flex-start}
@@ -639,6 +645,9 @@
.color-by{bottom:122px}
.legend{bottom:72px;right:12px}
.legend.with-color-by{bottom:72px}
+.canvas-area:has(.canvas-minimap) .legend{bottom:148px}
+.canvas-area:has(.canvas-minimap) .legend.with-color-by{bottom:148px}
+.canvas-area:has(.canvas-minimap) .color-by{bottom:198px}
.panel-tabs{position:sticky;top:0;z-index:2}
.file-preview-modal{width:100vw;height:100dvh;border-radius:0;border:none}
.file-preview-header{border-radius:0;padding:12px}
@@ -698,6 +707,10 @@
.legend{top:auto;bottom:72px;right:10px;max-width:calc(100vw - 20px)}
.legend.with-color-by{top:auto;bottom:72px}
.canvas-info{left:10px;right:10px;bottom:10px;overflow-x:auto;padding-bottom:0}
+.canvas-minimap{width:132px;height:88px;right:10px;bottom:52px}
+.canvas-area:has(.canvas-minimap) .legend{bottom:148px}
+.canvas-area:has(.canvas-minimap) .legend.with-color-by{bottom:148px}
+.canvas-area:has(.canvas-minimap) .color-by{bottom:198px}
.info-chip{white-space:nowrap;flex:0 0 auto}
}
@media (max-width:420px){
@@ -5993,6 +6006,10 @@
return vizType==='graph'||vizType==='code'||vizType==='graph3d';
}
+function vizHasCanvasMinimap(vizType){
+ return vizType==='graph'||vizType==='code';
+}
+
function collectVisibleCodeFiles(selectedPath,data,folderFilter,limit){
if(!data||!data.files)return[];
var filtered=folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}):data.files;
@@ -6779,6 +6796,229 @@
return {k:k,x:x,y:y};
}
+function minimapWorldFromBoxes(boxes,pad){
+ var union=unionPaddedBoxes(boxes,pad==null?48:pad,0);
+ if(!union)return null;
+ return{
+ minX:union.x,
+ minY:union.y,
+ maxX:union.x+union.width,
+ maxY:union.y+union.height,
+ width:union.width,
+ height:union.height
+ };
+}
+
+function collectMinimapWorldBounds(nodes,sizesByPath,cardPaths,pad){
+ var boxes=[];
+ (nodes||[]).forEach(function(node){
+ var xy=liveGraphNodeXY(node);
+ if(!xy)return;
+ if(cardPaths&&cardPaths.has&&cardPaths.has(node.id)&&sizesByPath&&sizesByPath[node.id]){
+ var size=sizesByPath[node.id];
+ boxes.push({x:xy.x-size.width/2,y:xy.y-size.height/2,width:size.width,height:size.height});
+ return;
+ }
+ boxes.push({x:xy.x-16,y:xy.y-16,width:32,height:32});
+ });
+ return minimapWorldFromBoxes(boxes,pad);
+}
+
+function collectMinimapContent(nodes,sizesByPath,cardPaths,colorOf,pad){
+ var marks=[];
+ var folders=Object.create(null);
+ (nodes||[]).forEach(function(node){
+ var xy=liveGraphNodeXY(node);
+ if(!xy)return;
+ var folder=node.folder||'root';
+ var color=typeof colorOf==='function'?colorOf(node):null;
+ var isCard=!!(cardPaths&&cardPaths.has&&cardPaths.has(node.id)&&sizesByPath&&sizesByPath[node.id]);
+ var box;
+ if(isCard){
+ var size=sizesByPath[node.id];
+ box={x:xy.x-size.width/2,y:xy.y-size.height/2,width:size.width,height:size.height};
+ marks.push({kind:'card',x:box.x,y:box.y,width:box.width,height:box.height,color:color,folder:folder});
+ }else{
+ box={x:xy.x-16,y:xy.y-16,width:32,height:32};
+ marks.push({kind:'node',x:xy.x,y:xy.y,color:color,folder:folder});
+ }
+ if(!folders[folder])folders[folder]={boxes:[],color:color};
+ folders[folder].boxes.push(box);
+ if(color)folders[folder].color=color;
+ });
+ var hulls=[];
+ Object.keys(folders).forEach(function(folder){
+ var union=unionPaddedBoxes(folders[folder].boxes,18,0);
+ if(union)hulls.push({folder:folder,color:folders[folder].color,x:union.x,y:union.y,width:union.width,height:union.height});
+ });
+ return{marks:marks,hulls:hulls,world:minimapWorldFromBoxes(Object.keys(folders).reduce(function(boxes,folder){
+ return boxes.concat(folders[folder].boxes);
+ },[]),pad)};
+}
+
+function viewportWorldRect(transform,viewW,viewH){
+ var t=snapshotZoomTransform(transform);
+ var w=Number(viewW);
+ var h=Number(viewH);
+ if(!isFinite(w)||w<=0)w=800;
+ if(!isFinite(h)||h<=0)h=600;
+ return{x:-t.x/t.k,y:-t.y/t.k,width:w/t.k,height:h/t.k};
+}
+
+function minimapFitRect(world,mapW,mapH,pad){
+ mapW=Number(mapW);
+ mapH=Number(mapH);
+ if(!world||!isFinite(world.width)||!isFinite(world.height)||world.width<=0||world.height<=0)return null;
+ if(!isFinite(mapW)||!isFinite(mapH)||mapW<=0||mapH<=0)return null;
+ pad=pad==null?8:Number(pad);
+ if(!isFinite(pad)||pad<0)pad=0;
+ var innerW=Math.max(1,mapW-pad*2);
+ var innerH=Math.max(1,mapH-pad*2);
+ var scale=Math.min(innerW/world.width,innerH/world.height);
+ if(!isFinite(scale)||scale<=0)scale=1;
+ var usedW=world.width*scale;
+ var usedH=world.height*scale;
+ return{scale:scale,ox:pad+(innerW-usedW)/2,oy:pad+(innerH-usedH)/2};
+}
+
+function worldToMinimap(x,y,world,fit){
+ if(!world||!fit)return{x:0,y:0};
+ return{x:(Number(x)-world.minX)*fit.scale+fit.ox,y:(Number(y)-world.minY)*fit.scale+fit.oy};
+}
+
+function minimapToWorld(mx,my,world,fit){
+ if(!world||!fit||!fit.scale)return{x:0,y:0};
+ return{x:(Number(mx)-fit.ox)/fit.scale+world.minX,y:(Number(my)-fit.oy)/fit.scale+world.minY};
+}
+
+function zoomTransformToCenterWorld(worldX,worldY,scale,viewW,viewH){
+ var k=Number(scale);
+ if(!isFinite(k)||k<=0)k=1;
+ var w=Number(viewW);
+ var h=Number(viewH);
+ if(!isFinite(w)||w<=0)w=800;
+ if(!isFinite(h)||h<=0)h=600;
+ return{k:k,x:w/2-Number(worldX)*k,y:h/2-Number(worldY)*k};
+}
+
+function zoomTransformFromMinimapPoint(mx,my,world,fit,transform,viewW,viewH){
+ var pt=minimapToWorld(mx,my,world,fit);
+ var t=snapshotZoomTransform(transform);
+ return zoomTransformToCenterWorld(pt.x,pt.y,t.k,viewW,viewH);
+}
+
+function minimapPointerXY(clientX,clientY,rect){
+ rect=rect||{};
+ return{x:(Number(clientX)||0)-(Number(rect.left)||0),y:(Number(clientY)||0)-(Number(rect.top)||0)};
+}
+
+function colorWithAlpha(color,alpha){
+ color=String(color||'');
+ var a=Number(alpha);
+ if(!isFinite(a))a=1;
+ if(a<0)a=0;
+ if(a>1)a=1;
+ if(color.charAt(0)==='#'&&(color.length===7||color.length===4)){
+ var r,g,b;
+ if(color.length===4){
+ r=parseInt(color.charAt(1)+color.charAt(1),16);
+ g=parseInt(color.charAt(2)+color.charAt(2),16);
+ b=parseInt(color.charAt(3)+color.charAt(3),16);
+ }else{
+ r=parseInt(color.slice(1,3),16);
+ g=parseInt(color.slice(3,5),16);
+ b=parseInt(color.slice(5,7),16);
+ }
+ if(isFinite(r)&&isFinite(g)&&isFinite(b))return 'rgba('+r+','+g+','+b+','+a+')';
+ }
+ return color;
+}
+
+function readMinimapTheme(el){
+ var styles=el&&typeof getComputedStyle==='function'?getComputedStyle(el):null;
+ function css(name,fallback){
+ var v=styles?String(styles.getPropertyValue(name)||'').trim():'';
+ return v||fallback;
+ }
+ return{
+ bg:css('--bg1','#0f0f12'),
+ acc:css('--acc','#00ff9d'),
+ muted:css('--t3','#5c5c66')
+ };
+}
+
+function clearCanvasMinimap(canvas){
+ if(!canvas||typeof canvas.getContext!=='function')return false;
+ var ctx=canvas.getContext('2d');
+ if(!ctx)return false;
+ ctx.setTransform(1,0,0,1,0,0);
+ ctx.clearRect(0,0,canvas.width||0,canvas.height||0);
+ return true;
+}
+
+function drawCanvasMinimap(canvas,model){
+ if(!canvas||typeof canvas.getContext!=='function'||!model||!model.fit||!model.world)return false;
+ var ctx=canvas.getContext('2d');
+ if(!ctx)return false;
+ var dpr=typeof window!=='undefined'&&window.devicePixelRatio?window.devicePixelRatio:1;
+ if(!isFinite(dpr)||dpr<=0)dpr=1;
+ var w=Number(model.mapW);
+ var h=Number(model.mapH);
+ if(!isFinite(w)||w<=0)w=176;
+ if(!isFinite(h)||h<=0)h=118;
+ var pixelW=Math.max(1,Math.round(w*dpr));
+ var pixelH=Math.max(1,Math.round(h*dpr));
+ if(canvas.width!==pixelW||canvas.height!==pixelH){
+ canvas.width=pixelW;
+ canvas.height=pixelH;
+ }
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ ctx.clearRect(0,0,w,h);
+ var theme=model.theme||{};
+ var acc=theme.acc||'#00ff9d';
+ var muted=theme.muted||'#5c5c66';
+ (model.hulls||[]).forEach(function(hull){
+ if(!hull)return;
+ var p1=worldToMinimap(hull.x,hull.y,model.world,model.fit);
+ var p2=worldToMinimap(hull.x+hull.width,hull.y+hull.height,model.world,model.fit);
+ var hx=p1.x,hy=p1.y,hw=Math.max(2,p2.x-p1.x),hh=Math.max(2,p2.y-p1.y);
+ ctx.beginPath();
+ ctx.fillStyle=colorWithAlpha(hull.color||muted,0.12);
+ ctx.strokeStyle=colorWithAlpha(hull.color||muted,0.4);
+ ctx.lineWidth=1;
+ if(typeof ctx.roundRect==='function')ctx.roundRect(hx,hy,hw,hh,3);
+ else ctx.rect(hx,hy,hw,hh);
+ ctx.fill();
+ ctx.stroke();
+ });
+ (model.marks||[]).forEach(function(mark){
+ if(!mark||mark.kind!=='card')return;
+ var c1=worldToMinimap(mark.x,mark.y,model.world,model.fit);
+ var c2=worldToMinimap(mark.x+mark.width,mark.y+mark.height,model.world,model.fit);
+ ctx.fillStyle=colorWithAlpha(mark.color||acc,0.32);
+ ctx.fillRect(c1.x,c1.y,Math.max(2,c2.x-c1.x),Math.max(2,c2.y-c1.y));
+ });
+ (model.marks||[]).forEach(function(mark){
+ if(!mark||mark.kind!=='node')return;
+ var p=worldToMinimap(mark.x,mark.y,model.world,model.fit);
+ ctx.beginPath();
+ ctx.fillStyle=mark.color||acc;
+ ctx.arc(p.x,p.y,1.7,0,Math.PI*2);
+ ctx.fill();
+ });
+ if(model.viewport){
+ var v=model.viewport;
+ var v1=worldToMinimap(v.x,v.y,model.world,model.fit);
+ var v2=worldToMinimap(v.x+v.width,v.y+v.height,model.world,model.fit);
+ ctx.fillStyle=colorWithAlpha(acc,0.1);
+ ctx.strokeStyle=acc;
+ ctx.lineWidth=1.25;
+ ctx.fillRect(v1.x,v1.y,v2.x-v1.x,v2.y-v1.y);
+ ctx.strokeRect(v1.x,v1.y,v2.x-v1.x,v2.y-v1.y);
+ }
+ return true;
+}
+
function shouldFitCodeCamera(cameraReady,vizType){
return vizType==='code'&&!cameraReady;
}
@@ -7697,6 +7937,12 @@
var codeCanvasRef=useRef(null);
var codeSourceInFlightRef=useRef(Object.create(null));
var updateHullsRef=useRef(null);
+ var drawMinimapRef=useRef(null);
+ var minimapHostRef=useRef(null);
+ var minimapCanvasRef=useRef(null);
+ var minimapModelRef=useRef(null);
+ var minimapDragRef=useRef(null);
+ var minimapRafRef=useRef(0);
var codeFolderCentersRef=useRef(Object.create(null));
var codeCardUserSizeRef=useRef(Object.create(null));
var analysisHydrationIdRef=useRef('');
@@ -9307,6 +9553,86 @@
g3.linkWidth(function(link){return graph3dLinkWidth(link,selectedPathRef.current,thickness);});
}
}
+ function refreshMinimap(){
+ var canvas=minimapCanvasRef.current;
+ if(!canvas||!vizHasCanvasMinimap(graphConfig.vizType))return;
+ var svg=svgRef.current;
+ if(!svg){
+ minimapModelRef.current=null;
+ clearCanvasMinimap(canvas);
+ return;
+ }
+ var mapW=canvas.clientWidth||176;
+ var mapH=canvas.clientHeight||118;
+ var viewW=svg.clientWidth||800;
+ var viewH=svg.clientHeight||600;
+ var nodes=simRef.current?simRef.current.nodes():[];
+ var content=collectMinimapContent(nodes,codeCardSizesRef.current,codeCardPathsRef.current,getNodeColor);
+ if(!content.world||!content.world.width){
+ minimapModelRef.current=null;
+ clearCanvasMinimap(canvas);
+ return;
+ }
+ var transform=snapshotZoomTransform(codeZoomTransformRef.current);
+ var viewport=viewportWorldRect(transform,viewW,viewH);
+ var fit=minimapFitRect(content.world,mapW,mapH,8);
+ var model={
+ mapW:mapW,
+ mapH:mapH,
+ viewW:viewW,
+ viewH:viewH,
+ transform:transform,
+ world:content.world,
+ fit:fit,
+ viewport:viewport,
+ marks:content.marks,
+ hulls:content.hulls,
+ theme:readMinimapTheme(minimapHostRef.current||canvas)
+ };
+ minimapModelRef.current=model;
+ drawCanvasMinimap(canvas,model);
+ }
+ function scheduleMinimapDraw(){
+ if(minimapRafRef.current)return;
+ minimapRafRef.current=requestAnimationFrame(function(){
+ minimapRafRef.current=0;
+ refreshMinimap();
+ });
+ }
+ drawMinimapRef.current=scheduleMinimapDraw;
+ function applyMinimapFocus(mx,my){
+ var model=minimapModelRef.current;
+ if(!model||!model.fit||!zoomRef.current||!svgRef.current)return;
+ var next=zoomTransformFromMinimapPoint(mx,my,model.world,model.fit,codeZoomTransformRef.current,model.viewW,model.viewH);
+ d3.select(svgRef.current).call(zoomRef.current.transform,d3.zoomIdentity.translate(next.x,next.y).scale(next.k));
+ }
+ function handleMinimapPointerDown(e){
+ if(e.button!==undefined&&e.button!==0)return;
+ e.preventDefault();
+ e.stopPropagation();
+ var canvas=minimapCanvasRef.current;
+ if(!canvas)return;
+ var xy=minimapPointerXY(e.clientX,e.clientY,canvas.getBoundingClientRect());
+ minimapDragRef.current={pointerId:e.pointerId};
+ if(e.currentTarget&&e.currentTarget.setPointerCapture){
+ try{e.currentTarget.setPointerCapture(e.pointerId);}catch(err){}
+ }
+ applyMinimapFocus(xy.x,xy.y);
+ }
+ function handleMinimapPointerMove(e){
+ if(!minimapDragRef.current)return;
+ e.preventDefault();
+ var canvas=minimapCanvasRef.current;
+ if(!canvas)return;
+ var xy=minimapPointerXY(e.clientX,e.clientY,canvas.getBoundingClientRect());
+ applyMinimapFocus(xy.x,xy.y);
+ }
+ function handleMinimapPointerUp(e){
+ if(e&&e.currentTarget&&e.currentTarget.releasePointerCapture&&minimapDragRef.current){
+ try{e.currentTarget.releasePointerCapture(minimapDragRef.current.pointerId);}catch(err){}
+ }
+ minimapDragRef.current=null;
+ }
function flyToOpenedCodeCard(place){
if(!svgRef.current||!zoomRef.current||!place||!isFinite(place.x)||!isFinite(place.y))return;
var w=svgRef.current.clientWidth||800;
@@ -9508,6 +9834,7 @@
codeZoomTransformRef.current=e.transform;
applyReadableLabels(e.transform.k);
if(keepReadable)syncCodeCards();
+ if(drawMinimapRef.current)drawMinimapRef.current();
});
svg.call(zoom);
zoomRef.current=zoom;
@@ -9592,6 +9919,7 @@
if(keepReadable){
redrawGraphLinksAndNodes();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}
}).on('end',function(e,d){
if(!keepReadable){
@@ -9602,6 +9930,7 @@
d.fx=d.x;d.fy=d.y;
if(codeCardPathsRef.current.has(d.id))codeCardUserPinnedRef.current.add(d.id);
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}));
node.on('click',function(e,d){
e.stopPropagation();
@@ -9681,6 +10010,7 @@
}
tickCount++;
if(tickCount%hullInterval===0)updateHulls();
+ if(drawMinimapRef.current)drawMinimapRef.current();
});
node.selectAll('text').attr('opacity',(keepReadable||graphConfig.showLabels)?1:0);
if(keepReadable){
@@ -9699,6 +10029,7 @@
sim.alpha(0);
}
updateHullsRef.current=updateHulls;
+ if(drawMinimapRef.current)drawMinimapRef.current();
}catch(e){console.error('Force graph error:',e);svg.selectAll('*').remove();svg.append('text').attr('x',20).attr('y',30).attr('fill','var(--t3)').text('Graph rendering error: '+e.message);}
return function(){if(simRef.current)simRef.current.stop();updateHullsRef.current=null;};
},[graphRebuildKey]);
@@ -9715,12 +10046,31 @@
placeRemainingCodeNodes();
syncCodeCards();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
},[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap]);
useEffect(function(){
applyLinkThickness();
},[lineThickness,selected&&selected.path]);
+ useEffect(function(){
+ if(!vizHasCanvasMinimap(graphConfig.vizType)){
+ minimapModelRef.current=null;
+ if(minimapCanvasRef.current)clearCanvasMinimap(minimapCanvasRef.current);
+ return;
+ }
+ scheduleMinimapDraw();
+ },[graphConfig.vizType,theme,colorMode,viewportWidth,sidebarWidth,rightPanelWidth]);
+
+ useEffect(function(){
+ return function(){
+ if(minimapRafRef.current){
+ cancelAnimationFrame(minimapRafRef.current);
+ minimapRafRef.current=0;
+ }
+ };
+ },[]);
+
// 3D Force Graph Hook
useEffect(function(){
if(!data||!graph3dRef.current||graphConfig.vizType!=='graph3d')return;
@@ -11286,6 +11636,7 @@
syncCodeCards();
redrawGraphLinksAndNodes();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}
function onUp(){
window.removeEventListener('pointermove',onMove);
@@ -11303,6 +11654,7 @@
syncCodeCards();
redrawGraphLinksAndNodes();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}
if(end.select)selectFile(file.path);
}
@@ -11349,6 +11701,7 @@
syncCodeCards();
redrawGraphLinksAndNodes();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}
function onUp(){
window.removeEventListener('pointermove',onMove);
@@ -11359,6 +11712,7 @@
syncCodeCards();
redrawGraphLinksAndNodes();
if(updateHullsRef.current)updateHullsRef.current();
+ if(drawMinimapRef.current)drawMinimapRef.current();
}
}
window.addEventListener('pointermove',onMove);
@@ -11803,6 +12157,17 @@
'Auto-rotate'
)
),
+ vizHasCanvasMinimap(graphConfig.vizType)&&React.createElement('div',{
+ className:'canvas-minimap',
+ ref:minimapHostRef,
+ 'aria-label':'Canvas mini-map. Click or drag to pan.',
+ title:'Click or drag to pan',
+ onPointerDown:handleMinimapPointerDown,
+ onPointerMove:handleMinimapPointerMove,
+ onPointerUp:handleMinimapPointerUp,
+ onPointerCancel:handleMinimapPointerUp,
+ onWheel:function(e){e.preventDefault();e.stopPropagation();}
+ },React.createElement('canvas',{ref:minimapCanvasRef})),
graphConfig.vizType!=='architecture'&&React.createElement('div',{className:'canvas-info'},
React.createElement('div',{className:'info-chip'},React.createElement('strong',null,folderFilter?data.files.filter(function(f){return f.folder===folderFilter||f.folder.startsWith(folderFilter+'/');}).length:data.files.length),' files'),
React.createElement('div',{className:'info-chip'},React.createElement('strong',null,data.connections.length),' links'),
diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs
index 885c50e..44ef541 100644
--- a/tests/code-canvas.test.mjs
+++ b/tests/code-canvas.test.mjs
@@ -1307,6 +1307,75 @@ test('thickness control is offered on every view that draws links', () => {
assert.equal(context.vizHasGraphToolbar('bundle'), false);
});
+test('canvas mini-map is wired for Graph and Code only', () => {
+ assert.equal(context.vizHasCanvasMinimap('graph'), true);
+ assert.equal(context.vizHasCanvasMinimap('code'), true);
+ ['graph3d', 'treemap', 'matrix', 'dendro', 'sankey', 'disjoint', 'bundle', 'architecture', ''].forEach((viz) => {
+ assert.equal(context.vizHasCanvasMinimap(viz), false, viz);
+ });
+ assert.match(htmlSource, /function vizHasCanvasMinimap\(/);
+ assert.match(htmlSource, /vizHasCanvasMinimap\(graphConfig\.vizType\)&&React\.createElement\('div',\{/);
+ assert.match(htmlSource, /className:'canvas-minimap'/);
+ assert.match(htmlSource, /Click or drag to pan/);
+ assert.match(htmlSource, /function zoomTransformFromMinimapPoint\(/);
+ assert.match(htmlSource, /function drawCanvasMinimap\(/);
+ assert.match(htmlSource, /if\(drawMinimapRef\.current\)drawMinimapRef\.current\(\)/);
+ assert.doesNotMatch(htmlSource, /minimap-drag-handle|drag indicator/);
+ assert.match(htmlSource, /'aria-label':'Line thickness'/);
+ assert.match(htmlSource, /config-label'\},'Thickness'/);
+});
+
+test('mini-map world bounds include nodes and code cards', () => {
+ const nodes = [
+ { id: 'src/a.js', folder: 'src', x: 0, y: 0 },
+ { id: 'src/b.js', folder: 'src', x: 200, y: 40 }
+ ];
+ const world = context.collectMinimapWorldBounds(nodes, null, null, 0);
+ assert.ok(world);
+ assert.equal(world.minX, -16);
+ assert.equal(world.maxX, 216);
+ const cards = new Set(['src/a.js']);
+ const sizes = { 'src/a.js': { width: 400, height: 200 } };
+ const cardWorld = context.collectMinimapWorldBounds(nodes, sizes, cards, 0);
+ assert.ok(cardWorld.minX <= -200);
+ assert.ok(cardWorld.maxX >= 216);
+ const content = context.collectMinimapContent(nodes, sizes, cards, (n) => n.id === 'src/a.js' ? '#4d9fff' : '#22c55e', 0);
+ assert.equal(content.marks.some((m) => m.kind === 'card'), true);
+ assert.equal(content.marks.some((m) => m.kind === 'node'), true);
+ assert.equal(content.hulls.length, 1);
+ assert.equal(content.hulls[0].folder, 'src');
+});
+
+test('mini-map click centers the current camera on that world point', () => {
+ const transform = { k: 2, x: 100, y: 50 };
+ const view = context.viewportWorldRect(transform, 800, 600);
+ assert.equal(view.x, -50);
+ assert.equal(view.y, -25);
+ assert.equal(view.width, 400);
+ assert.equal(view.height, 300);
+ const world = { minX: 0, minY: 0, maxX: 400, maxY: 300, width: 400, height: 300 };
+ const fit = context.minimapFitRect(world, 200, 150, 0);
+ assert.ok(fit);
+ assert.equal(fit.scale, 0.5);
+ const mid = context.worldToMinimap(200, 150, world, fit);
+ assert.equal(mid.x, 100);
+ assert.equal(mid.y, 75);
+ const back = context.minimapToWorld(mid.x, mid.y, world, fit);
+ assert.equal(back.x, 200);
+ assert.equal(back.y, 150);
+ const centered = context.zoomTransformToCenterWorld(150, 125, 2, 800, 600);
+ assert.equal(centered.k, 2);
+ assert.equal(centered.x, 100);
+ assert.equal(centered.y, 50);
+ const jumped = context.zoomTransformFromMinimapPoint(100, 75, world, fit, transform, 800, 600);
+ assert.equal(jumped.k, 2);
+ assert.equal(jumped.x, 400 - 200 * 2);
+ assert.equal(jumped.y, 300 - 150 * 2);
+ const ptr = J(context.minimapPointerXY(120, 80, { left: 20, top: 30 }));
+ assert.deepEqual(ptr, { x: 100, y: 50 });
+ assert.equal(context.colorWithAlpha('#00ff9d', 0.1), 'rgba(0,255,157,0.1)');
+});
+
function memoryStorage(seed) {
const data = Object.assign({}, seed || {});
return {