From f35e751ec839f3e457e9beeb9cd30c2bf91509e6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 16:17:31 +0000 Subject: [PATCH] Add a bottom-right canvas mini-map for Graph and Code views Click or drag the overview to center that region. Nodes, folder hulls, and code cards stay in the one-file app; thickness prefs are unchanged. Co-authored-by: Braedon Saunders --- index.html | 365 +++++++++++++++++++++++++++++++++++++ tests/code-canvas.test.mjs | 69 +++++++ 2 files changed, 434 insertions(+) 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 {