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;
+ }
});