diff --git a/index.html b/index.html index c447aa2..244c3a5 100644 --- a/index.html +++ b/index.html @@ -274,6 +274,7 @@ .fn-caller{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:4px;font-size:10px;color:var(--t2);cursor:pointer} .fn-caller:hover{background:var(--hover);color:var(--acc)} .graph-config{position:absolute;top:12px;left:180px;background:var(--bg1);border:1px solid var(--border);border-radius:8px;padding:12px;z-index:50;width:220px} +.graph-config.thickness-only{left:56px} .graph-config-title{font-size:9px;font-weight:600;color:var(--t3);text-transform:uppercase;margin-bottom:10px} .config-row{display:flex;align-items:center;gap:8px;margin-bottom:10px} .config-label{font-size:10px;color:var(--t2);min-width:60px} @@ -5627,6 +5628,94 @@ var ANALYSIS_CACHE_VERSION=2; var ANALYSIS_CACHE_MAX=12; var ANALYSIS_CACHE_MAX_BYTES=18*1024*1024; +var UI_PREFS_STORAGE_KEY='codeflow-ui-prefs'; +var LINE_THICKNESS_MIN=1; +var LINE_THICKNESS_MAX=6; +var LINE_THICKNESS_DEFAULT=1; + +function clampLineThickness(value){ + var n=Number(value); + if(!isFinite(n))return LINE_THICKNESS_DEFAULT; + n=Math.round(n); + if(nLINE_THICKNESS_MAX)return LINE_THICKNESS_MAX; + return n; +} + +function defaultUiPrefs(){ + return{lineThickness:LINE_THICKNESS_DEFAULT}; +} + +function normalizeUiPrefs(prefs){ + prefs=prefs&&typeof prefs==='object'?prefs:{}; + var next=defaultUiPrefs(); + if(prefs.lineThickness!=null)next.lineThickness=clampLineThickness(prefs.lineThickness); + return next; +} + +function resolveUiPrefsStorage(storage){ + try{ + if(storage===undefined){ + if(typeof window==='undefined')return null; + storage=window.localStorage; + } + if(!storage||typeof storage.getItem!=='function')return null; + return storage; + }catch(e){ + return null; + } +} + +function readUiPrefs(storage){ + try{ + var store=resolveUiPrefsStorage(storage); + if(!store)return defaultUiPrefs(); + var raw=store.getItem(UI_PREFS_STORAGE_KEY); + if(!raw)return defaultUiPrefs(); + return normalizeUiPrefs(JSON.parse(raw)); + }catch(e){ + return defaultUiPrefs(); + } +} + +function writeUiPrefs(storage,prefs){ + var next=normalizeUiPrefs(Object.assign({},readUiPrefs(storage),prefs||{})); + try{ + var store=resolveUiPrefsStorage(storage); + if(store)store.setItem(UI_PREFS_STORAGE_KEY,JSON.stringify(next)); + }catch(e){} + return next; +} + +function persistUiPrefs(prefs){ + return writeUiPrefs(undefined,prefs); +} + +function graphLinkBaseWidth(count){ + return Math.max(1,Math.min(2,Math.sqrt(count||1)*0.3)); +} + +function graphLinkStrokeWidth(count,thickness){ + return graphLinkBaseWidth(count)*clampLineThickness(thickness); +} + +function scaleStrokeWidth(base,thickness){ + var n=Number(base); + if(!isFinite(n)||n<=0)n=1; + return Math.max(0.4,n*clampLineThickness(thickness)); +} + +function graph3dLinkWidth(link,selectedPath,thickness){ + link=link||{}; + var baseWidth=Math.max(0.8,Math.min(3,Math.sqrt(link.count||1)*0.4)); + if(selectedPath){ + var s=link.source&&(link.source.id||link.source); + var t=link.target&&(link.target.id||link.target); + if(s===selectedPath||t===selectedPath)return scaleStrokeWidth(baseWidth*2,thickness); + return scaleStrokeWidth(baseWidth*0.3,thickness); + } + return scaleStrokeWidth(baseWidth,thickness); +} function escapeRegExp(value){ return String(value||'').replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); @@ -5896,6 +5985,14 @@ return vizType!=='code'; } +function vizUsesLineThickness(vizType){ + return vizType==='graph'||vizType==='code'||vizType==='graph3d'||vizType==='dendro'||vizType==='sankey'||vizType==='disjoint'||vizType==='bundle'; +} + +function vizHasGraphToolbar(vizType){ + return vizType==='graph'||vizType==='code'||vizType==='graph3d'; +} + 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; @@ -7608,6 +7705,9 @@ var _pillScroll=useState(0),codePillScroll=_pillScroll[0],setCodePillScroll=_pillScroll[1]; var _codeExpand=useState(false),codeViewExpand=_codeExpand[0],setCodeViewExpand=_codeExpand[1]; var _codeWrap=useState(true),codeViewWrap=_codeWrap[0],setCodeViewWrap=_codeWrap[1]; + var _lineThick=useState(readUiPrefs().lineThickness),lineThickness=_lineThick[0],setLineThickness=_lineThick[1]; + var lineThicknessRef=useRef(lineThickness); + lineThicknessRef.current=lineThickness; var pendingRecentDeleteTimerRef=useRef(null); var zipInputRef=useRef(null); var zipArchiveRef=useRef(null); @@ -9193,6 +9293,20 @@ if(nodesRef.current)nodesRef.current.attr('transform',function(d){return'translate('+d.x+','+d.y+')';}); if(linksRef.current)linksRef.current.attr('d',graphLinkPath); } + function persistLineThickness(value){ + var next=persistUiPrefs({lineThickness:value}).lineThickness; + setLineThickness(next); + } + function applyLinkThickness(){ + var thickness=lineThicknessRef.current; + if(linksRef.current){ + linksRef.current.attr('stroke-width',function(d){return graphLinkStrokeWidth(d.count,thickness);}); + } + var g3=graph3dInstanceRef.current; + if(g3&&typeof g3.linkWidth==='function'){ + g3.linkWidth(function(link){return graph3dLinkWidth(link,selectedPathRef.current,thickness);}); + } + } function flyToOpenedCodeCard(place){ if(!svgRef.current||!zoomRef.current||!place||!isFinite(place.x)||!isFinite(place.y))return; var w=svgRef.current.clientWidth||800; @@ -9465,7 +9579,7 @@ var velDecay=isLargeGraph?0.7:0.6; sim.velocityDecay(velDecay).alphaDecay(alphaDecay); simRef.current=sim; - var link=linkLayer.selectAll('path').data(links).join('path').attr('fill','none').attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-width',function(d){return Math.max(1,Math.min(2,Math.sqrt(d.count)*0.3));}).attr('stroke-opacity',0.4).attr('marker-end','url(#arr)'); + var link=linkLayer.selectAll('path').data(links).join('path').attr('fill','none').attr('stroke',theme==='light'?'#ccc':'#333').attr('stroke-width',function(d){return graphLinkStrokeWidth(d.count,lineThicknessRef.current);}).attr('stroke-opacity',0.4).attr('marker-end','url(#arr)'); linksRef.current=link; var node=nodeLayer.selectAll('g').data(nodes).join('g').style('cursor','pointer'); nodesRef.current=node; @@ -9603,6 +9717,10 @@ if(updateHullsRef.current)updateHullsRef.current(); },[codeViewFiles,graphConfig.vizType,graphConfig.linkDist,codeViewExpand,codeViewWrap]); + useEffect(function(){ + applyLinkThickness(); + },[lineThickness,selected&&selected.path]); + // 3D Force Graph Hook useEffect(function(){ if(!data||!graph3dRef.current||graphConfig.vizType!=='graph3d')return; @@ -9743,14 +9861,7 @@ return theme==='light'?'rgba(200,200,200,0.4)':'rgba(60,60,70,0.4)'; }) .linkWidth(function(link){ - var s=link.source.id||link.source; - var t=link.target.id||link.target; - var baseWidth=Math.max(0.8,Math.min(3,Math.sqrt(link.count)*0.4)); - if(selected){ - if(s===selected.path||t===selected.path)return baseWidth*2.0; - return baseWidth*0.3; - } - return baseWidth; + return graph3dLinkWidth(link,selected&&selected.path,lineThicknessRef.current); }) .linkDirectionalArrowLength(function(link){ if(selected){ @@ -10126,7 +10237,7 @@ var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); g.selectAll('path.dendro-link').data(root.links()).join('path').attr('class','dendro-link') .attr('d',function(d){return'M'+d.source.y+','+d.source.x+'C'+(d.source.y+d.target.y)/2+','+d.source.x+' '+(d.source.y+d.target.y)/2+','+d.target.x+' '+d.target.y+','+d.target.x;}) - .attr('fill','none').attr('stroke','var(--border)').attr('stroke-width',1.5).attr('stroke-opacity',0.6); + .attr('fill','none').attr('stroke','var(--border)').attr('stroke-width',scaleStrokeWidth(1.5,lineThickness)).attr('stroke-opacity',0.6); var node=g.selectAll('g.dendro-node').data(root.descendants()).join('g').attr('class','dendro-node') .attr('transform',function(d){return'translate('+d.y+','+d.x+')';}).style('cursor','pointer'); node.append('circle').attr('r',function(d){return d.children?6:8;}) @@ -10154,7 +10265,7 @@ if(d.data.path&&selectFileRef.current)selectFileRef.current(d.data.path); else if(d.data.fullPath)filterByFolder(d.data.fullPath); }); - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Sankey Diagram - Flow visualization showing dependencies between folders useEffect(function(){ @@ -10217,7 +10328,7 @@ g.selectAll('path.sankey-link').data(graph.links).join('path').attr('class','sankey-link') .attr('d',d3.sankeyLinkHorizontal()).attr('fill','none') .attr('stroke',function(d){return colorMap[d.source.fullPath]||COLORS[d.source.id%COLORS.length];}) - .attr('stroke-width',function(d){return Math.max(2,d.width);}).attr('stroke-opacity',0.4) + .attr('stroke-width',function(d){return scaleStrokeWidth(Math.max(2,d.width),lineThickness);}).attr('stroke-opacity',0.4) .on('mouseenter',function(e,d){ d3.select(this).attr('stroke-opacity',0.8); tooltip.html(renderTooltipHtml(d.source.name+' → '+d.target.name,[ @@ -10240,7 +10351,7 @@ g.selectAll('path.sankey-link').attr('stroke-opacity',function(l){return l.source.id===d.id||l.target.id===d.id?0.8:0.1;}); }).on('mouseleave',function(){tooltip.style('display','none');g.selectAll('path.sankey-link').attr('stroke-opacity',0.4);}) .on('click',function(e,d){e.stopPropagation();filterByFolder(d.fullPath);}); - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Disjoint Force-Directed - Separate clusters per folder useEffect(function(){ @@ -10282,7 +10393,7 @@ .attr('x',function(d,i){return(i%cols)*cellW+20;}).attr('y',function(d,i){return Math.floor(i/cols)*cellH+28;}) .attr('fill','var(--t2)').attr('font-size','11px').attr('font-weight','600').text(function(d){return d.split('/').pop()||'root';}); var link=g.selectAll('line.disjoint-link').data(links).join('line').attr('class','disjoint-link') - .attr('stroke','var(--border)').attr('stroke-width',1).attr('stroke-opacity',0.3); + .attr('stroke','var(--border)').attr('stroke-width',scaleStrokeWidth(1,lineThickness)).attr('stroke-opacity',0.3); var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); var node=g.selectAll('g.disjoint-node').data(nodes).join('g').attr('class','disjoint-node').style('cursor','pointer') .call(d3.drag().on('start',function(e,d){if(!e.active)sim.alphaTarget(0.3).restart();d.fx=d.x;d.fy=d.y;}) @@ -10310,7 +10421,7 @@ }); svg.on('click',function(){setSelected(null);setBlastRadius(null);}); return function(){sim.stop();}; - },[data,graphConfig.vizType,colorMap,folderFilter]); + },[data,graphConfig.vizType,colorMap,folderFilter,lineThickness]); // Circular Bundle visualization - Interactive with zoom, selection, blast radius useEffect(function(){ @@ -10371,7 +10482,7 @@ return'M'+x1+','+y1+'Q'+cx+','+cy+' '+x2+','+y2; }) .attr('fill','none').attr('stroke',getBundleLinkColor) - .attr('stroke-width',1.8).attr('stroke-opacity',0.35); + .attr('stroke-width',scaleStrokeWidth(1.8,lineThickness)).attr('stroke-opacity',0.35); var tooltip=container.append('div').attr('class','treemap-tooltip').style('display','none').style('position','absolute'); var node=mainG.selectAll('g.bundle-node').data(nodes).join('g').attr('class','bundle-node').style('cursor','pointer') .attr('transform',function(d){return'rotate('+(d.angle*180/Math.PI-90)+') translate('+radius+',0)'+(d.angle>Math.PI?' rotate(180)':'');}); @@ -10382,7 +10493,7 @@ function applyBundleDefaultState(){ link.transition().duration(200) .attr('stroke-opacity',0.35) - .attr('stroke-width',1.8) + .attr('stroke-width',scaleStrokeWidth(1.8,lineThickness)) .attr('stroke',getBundleLinkColor); node.selectAll('.bundle-circle').transition().duration(200) .attr('fill',function(d){return colorMap[d.folder]||COLORS[0];}) @@ -10395,7 +10506,7 @@ var directConnections=getBundleDirectConnections(nodeId); link.transition().duration(200) .attr('stroke-opacity',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?0.88:0.04;}) - .attr('stroke-width',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?3.1:1;}) + .attr('stroke-width',function(linkDatum){return scaleStrokeWidth(isBundleLinkMatch(nodeId,linkDatum)?3.1:1,lineThickness);}) .attr('stroke',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?'var(--acc)':getBundleLinkColor(linkDatum);}); node.selectAll('.bundle-circle').transition().duration(200) .attr('opacity',function(nodeDatum){return directConnections.has(nodeDatum.id)?1:0.22;}) @@ -10408,7 +10519,7 @@ var affectedSet=new Set(blast&&blast.affected?blast.affected:[]); link.transition().duration(300) .attr('stroke-opacity',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?0.96:0.08;}) - .attr('stroke-width',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?3.6:1.15;}) + .attr('stroke-width',function(linkDatum){return scaleStrokeWidth(isBundleLinkMatch(nodeId,linkDatum)?3.6:1.15,lineThickness);}) .attr('stroke',function(linkDatum){return isBundleLinkMatch(nodeId,linkDatum)?'#ff9f43':getBundleLinkColor(linkDatum);}); node.selectAll('.bundle-circle').transition().duration(300) .attr('fill',function(nodeDatum){return nodeDatum.id===nodeId?'#ff5f5f':affectedSet.has(nodeDatum.id)?'#ff9f43':colorMap[nodeDatum.folder]||COLORS[0];}) @@ -10466,7 +10577,7 @@ }else{ applyBundleDefaultState(); } - },[data,graphConfig.vizType,colorMap,folderFilter,selected,blastRadius]); + },[data,graphConfig.vizType,colorMap,folderFilter,selected,blastRadius,lineThickness]); function zoomIn(){ if(graphConfig.vizType==='graph3d'&&graph3dInstanceRef.current){ @@ -11644,16 +11755,16 @@ graphConfig.vizType==='disjoint'&&React.createElement('div',{ref:disjointRef,className:'disjoint-container',style:{width:'100%',height:'100%',position:'relative'}}), graphConfig.vizType==='bundle'&&React.createElement('div',{ref:bundleRef,className:'bundle-container'}), graphConfig.vizType==='architecture'&&renderArchitectureView(), - (graphConfig.vizType==='graph'||graphConfig.vizType==='graph3d'||graphConfig.vizType==='code')&&React.createElement('div',{className:'canvas-toolbar'}, - React.createElement('button',{className:'tool-btn',onClick:zoomIn,'aria-label':'Zoom in'},'+'), - React.createElement('button',{className:'tool-btn',onClick:zoomOut,'aria-label':'Zoom out'},'−'), - React.createElement('button',{className:'tool-btn',onClick:resetZoom,'aria-label':'Reset zoom'},'⟲'), - React.createElement('button',{className:'tool-btn',onClick:fitView,'aria-label':'Fit view'},'⊡'), + vizUsesLineThickness(graphConfig.vizType)&&React.createElement('div',{className:'canvas-toolbar'}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:zoomIn,'aria-label':'Zoom in'},'+'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:zoomOut,'aria-label':'Zoom out'},'−'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:resetZoom,'aria-label':'Reset zoom'},'⟲'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('button',{className:'tool-btn',onClick:fitView,'aria-label':'Fit view'},'⊡'), React.createElement('button',{className:'tool-btn'+(showGraphConfig?' active':''),onClick:function(){setShowGraphConfig(!showGraphConfig);},'aria-label':'Graph settings',style:showGraphConfig?{background:'var(--accbg)',borderColor:'var(--acc)'}:{}}, React.createElement(Icon,{name:'settings',size:'m'}) ) ), - (graphConfig.vizType==='graph'||graphConfig.vizType==='graph3d'||graphConfig.vizType==='code')&&showGraphConfig&&React.createElement('div',{className:'graph-config'}, + vizUsesLineThickness(graphConfig.vizType)&&showGraphConfig&&React.createElement('div',{className:'graph-config'+(vizHasGraphToolbar(graphConfig.vizType)?'':' thickness-only')}, (graphConfig.vizType==='graph'||graphConfig.vizType==='code')&&React.createElement('div',{className:'graph-config-title'},'Layout'), (graphConfig.vizType==='graph'||graphConfig.vizType==='code')&&React.createElement('div',{className:'view-toggle',style:{flexWrap:'wrap'}}, React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='force'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'force'}));}},'Force'), @@ -11662,23 +11773,28 @@ React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='grid'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'grid'}));}},'Grid'), React.createElement('button',{className:'view-btn'+(graphConfig.viewMode==='metro'?' active':''),onClick:function(){setGraphConfig(Object.assign({},graphConfig,{viewMode:'metro'}));}},'Metro') ), - React.createElement('div',{className:'graph-config-title',style:{marginTop:(graphConfig.vizType==='graph'||graphConfig.vizType==='code')?8:0}},'Spacing'), - React.createElement('div',{className:'config-row'}, + React.createElement('div',{className:'graph-config-title',style:{marginTop:(graphConfig.vizType==='graph'||graphConfig.vizType==='code')?8:0}},vizHasGraphToolbar(graphConfig.vizType)?'Spacing':'Lines'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'config-row'}, React.createElement('span',{className:'config-label'},'Spread'), React.createElement('input',{type:'range',className:'config-slider',min:'50',max:'500',value:graphConfig.spacing,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{spacing:parseInt(e.target.value)}));}}), React.createElement('span',{className:'config-value'},graphConfig.spacing) ), - React.createElement('div',{className:'config-row'}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'config-row'}, React.createElement('span',{className:'config-label'},'Links'), React.createElement('input',{type:'range',className:'config-slider',min:'30',max:'200',value:graphConfig.linkDist,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{linkDist:parseInt(e.target.value)}));}}), React.createElement('span',{className:'config-value'},graphConfig.linkDist) ), - React.createElement('div',{className:'graph-config-title',style:{marginTop:8}},'Display'), - React.createElement('label',{className:'config-check'}, + React.createElement('div',{className:'config-row'}, + React.createElement('span',{className:'config-label'},'Thickness'), + React.createElement('input',{type:'range',className:'config-slider',min:String(LINE_THICKNESS_MIN),max:String(LINE_THICKNESS_MAX),step:'1',value:lineThickness,'aria-label':'Line thickness',onChange:function(e){persistLineThickness(e.target.value);}}), + React.createElement('span',{className:'config-value'},lineThickness) + ), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('div',{className:'graph-config-title',style:{marginTop:8}},'Display'), + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('label',{className:'config-check'}, React.createElement('input',{type:'checkbox',checked:graphConfig.showLabels,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{showLabels:e.target.checked}));}}), 'Show labels' ), - React.createElement('label',{className:'config-check',style:{marginTop:6}}, + vizHasGraphToolbar(graphConfig.vizType)&&React.createElement('label',{className:'config-check',style:{marginTop:6}}, React.createElement('input',{type:'checkbox',checked:graphConfig.curvedLinks,onChange:function(e){setGraphConfig(Object.assign({},graphConfig,{curvedLinks:e.target.checked}));}}), 'Curved links' ), diff --git a/tests/code-canvas.test.mjs b/tests/code-canvas.test.mjs index 8cd647c..885c50e 100644 --- a/tests/code-canvas.test.mjs +++ b/tests/code-canvas.test.mjs @@ -1273,4 +1273,118 @@ test('index.html ships a working Code view, not a stub', () => { assert.match(htmlSource, /\.code-card\.expand:not\(\.wrap\) \.code-card-body\{overflow-x:auto/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Color By'/); assert.doesNotMatch(htmlSource, /sidebar-title'\},'Explorer'/); + assert.match(htmlSource, /function persistLineThickness\(/); + assert.match(htmlSource, /function persistUiPrefs\(/); + assert.match(htmlSource, /function resolveUiPrefsStorage\(/); + assert.match(htmlSource, /function applyLinkThickness\(/); + assert.match(htmlSource, /'aria-label':'Line thickness'/); + assert.match(htmlSource, /config-label'\},'Thickness'/); + assert.match(htmlSource, /persistLineThickness\(e\.target\.value\)/); + assert.match(htmlSource, /useState\(readUiPrefs\(\)\.lineThickness\)/); + assert.match(htmlSource, /persistUiPrefs\(\{lineThickness:value\}\)/); + assert.doesNotMatch(htmlSource, /readUiPrefs\(window\.localStorage\)/); + assert.doesNotMatch(htmlSource, /writeUiPrefs\(window\.localStorage/); + assert.match(htmlSource, /graphLinkStrokeWidth\(d\.count,lineThicknessRef\.current\)/); + assert.match(htmlSource, /graph3dLinkWidth\(link,selected&&selected\.path,lineThicknessRef\.current\)/); + assert.match(htmlSource, /scaleStrokeWidth\(1\.5,lineThickness\)/); + assert.match(htmlSource, /scaleStrokeWidth\(Math\.max\(2,d\.width\),lineThickness\)/); + assert.match(htmlSource, /UI_PREFS_STORAGE_KEY/); + assert.match(htmlSource, /function vizUsesLineThickness\(/); + assert.match(htmlSource, /vizUsesLineThickness\(graphConfig\.vizType\)&&React\.createElement\('div',\{className:'canvas-toolbar'/); + assert.match(htmlSource, /vizUsesLineThickness\(graphConfig\.vizType\)&&showGraphConfig/); + assert.doesNotMatch(htmlSource, /\(graphConfig\.vizType==='graph'\|\|graphConfig\.vizType==='graph3d'\|\|graphConfig\.vizType==='code'\)&&React\.createElement\('div',\{className:'canvas-toolbar'/); +}); + +test('thickness control is offered on every view that draws links', () => { + ['graph', 'code', 'graph3d', 'dendro', 'sankey', 'disjoint', 'bundle'].forEach((viz) => { + assert.equal(context.vizUsesLineThickness(viz), true, viz); + }); + ['treemap', 'matrix', 'architecture', 'none', ''].forEach((viz) => { + assert.equal(context.vizUsesLineThickness(viz), false, viz); + }); + assert.equal(context.vizHasGraphToolbar('graph'), true); + assert.equal(context.vizHasGraphToolbar('dendro'), false); + assert.equal(context.vizHasGraphToolbar('bundle'), false); +}); + +function memoryStorage(seed) { + const data = Object.assign({}, seed || {}); + return { + getItem(key) { + return Object.prototype.hasOwnProperty.call(data, key) ? data[key] : null; + }, + setItem(key, value) { + data[key] = String(value); + }, + _data: data + }; +} + +test('line thickness defaults match current graph edges and stay in range', () => { + assert.equal(context.LINE_THICKNESS_DEFAULT, 1); + assert.equal(context.LINE_THICKNESS_MIN, 1); + assert.equal(context.LINE_THICKNESS_MAX, 6); + assert.equal(context.clampLineThickness(undefined), 1); + assert.equal(context.clampLineThickness('nope'), 1); + assert.equal(context.clampLineThickness(0), 1); + assert.equal(context.clampLineThickness(9), 6); + assert.equal(context.clampLineThickness(3.6), 4); + const thin = context.graphLinkStrokeWidth(1, 1); + const thick = context.graphLinkStrokeWidth(1, 4); + assert.equal(thin, Math.max(1, Math.min(2, Math.sqrt(1) * 0.3))); + assert.equal(thick, thin * 4); + assert.equal(context.scaleStrokeWidth(1.5, 1), 1.5); + assert.equal(context.scaleStrokeWidth(1.5, 2), 3); + const idle = context.graph3dLinkWidth({ count: 1, source: 'a.js', target: 'b.js' }, null, 1); + const selected = context.graph3dLinkWidth({ count: 1, source: 'a.js', target: 'b.js' }, 'a.js', 1); + assert.ok(selected > idle); +}); + +test('UI prefs persist line thickness in localStorage', () => { + const storage = memoryStorage(); + assert.equal(context.readUiPrefs(storage).lineThickness, 1); + assert.equal(context.readUiPrefs(null).lineThickness, 1); + const written = context.writeUiPrefs(storage, { lineThickness: 5 }); + assert.equal(written.lineThickness, 5); + assert.equal(context.readUiPrefs(storage).lineThickness, 5); + assert.equal(context.writeUiPrefs(storage, { lineThickness: 99 }).lineThickness, 6); + storage.setItem(context.UI_PREFS_STORAGE_KEY, '{not-json'); + assert.equal(context.readUiPrefs(storage).lineThickness, 1); + const other = memoryStorage({ [context.UI_PREFS_STORAGE_KEY]: JSON.stringify({ lineThickness: 2, extra: true }) }); + const merged = context.writeUiPrefs(other, { lineThickness: 3 }); + assert.equal(merged.lineThickness, 3); + context.window = { localStorage: storage }; + try { + assert.equal(context.persistUiPrefs({ lineThickness: 4 }).lineThickness, 4); + assert.equal(context.readUiPrefs().lineThickness, 4); + } finally { + delete context.window; + } +}); + +function throwingLocalStorageWindow() { + return { + get localStorage() { + const err = new Error('Access is denied for this document.'); + err.name = 'SecurityError'; + throw err; + } + }; +} + +test('UI prefs keep the default when localStorage access throws', () => { + context.window = throwingLocalStorageWindow(); + try { + assert.equal(context.resolveUiPrefsStorage(undefined), null); + assert.doesNotThrow(() => context.readUiPrefs()); + assert.equal(context.readUiPrefs().lineThickness, context.LINE_THICKNESS_DEFAULT); + const rendered = context.readUiPrefs().lineThickness; + assert.equal(rendered, 1); + assert.doesNotThrow(() => context.persistUiPrefs({ lineThickness: 5 })); + const inSession = context.persistUiPrefs({ lineThickness: 5 }); + assert.equal(inSession.lineThickness, 5); + assert.equal(context.readUiPrefs().lineThickness, 1); + } finally { + delete context.window; + } });