import { state, setCy, setCurrentLayout } from './state.js';
import { STABLE_CRATES, isExternalCrate, estimateVolatility } from './utils.js';
export function initCytoscape(data, onNodeTap, onEdgeTap, onBackgroundTap, options = {}) {
const elements = buildElements(data, options);
const cy = cytoscape({
container: document.getElementById('cy'),
elements: elements,
style: getCytoscapeStyle(),
layout: getLayoutConfig('cose'),
minZoom: 0.2,
maxZoom: 3,
wheelSensitivity: 0.3,
pixelRatio: 'auto'
});
setCy(cy);
setupGraphEventHandlers(cy, onNodeTap, onEdgeTap, onBackgroundTap);
return cy;
}
export function buildElements(data, options = {}) {
const { showItems = false } = options;
const nodes = [];
const itemNodeIds = new Set();
data.nodes.forEach(node => {
const crate = node.id.split('::')[0];
const items = node.items || [];
const fnCount = node.metrics?.fn_count ?? items.filter(i => i.kind === 'fn').length;
const typeCount = node.metrics?.type_count ?? items.filter(i => i.kind === 'type' || i.kind === 'trait').length;
const implCount = node.metrics?.impl_count ?? ((node.metrics?.trait_impl_count || 0) + (node.metrics?.inherent_impl_count || 0));
const statsStr = `${fnCount}fn ${typeCount}ty ${implCount}impl`;
nodes.push({
data: {
id: node.id,
label: node.label,
crate: crate,
nodeType: 'module',
...node.metrics,
file_path: node.file_path,
in_cycle: node.in_cycle,
items: node.items,
fn_count: fnCount,
type_count: typeCount,
impl_count: implCount,
stats_label: statsStr
}
});
if (showItems && items.length > 0) {
items.forEach(item => {
const itemId = `${node.id}::${item.name}`;
itemNodeIds.add(itemId);
nodes.push({
data: {
id: itemId,
label: item.name,
nodeType: 'item',
itemKind: item.kind,
visibility: item.visibility,
parentModule: node.id,
depCount: (item.dependencies || []).length
}
});
});
}
});
const edgeMap = new Map();
data.edges.forEach(edge => {
const key = `${edge.source}->${edge.target}`;
const dims = edge.dimensions || {};
if (!edgeMap.has(key)) {
edgeMap.set(key, {
source: edge.source,
target: edge.target,
strength: dims.strength?.value ?? 0.5,
strengthLabel: dims.strength?.label ?? 'Model',
distance: dims.distance?.label ?? 'DifferentModule',
volatility: dims.volatility?.label ?? 'Low',
balance: dims.balance?.value ?? 0.5,
balanceLabel: dims.balance?.label ?? 'Acceptable',
classification: dims.balance?.classification ?? '',
classificationJa: dims.balance?.classification_ja ?? '',
issue: edge.issue,
inCycle: edge.in_cycle,
location: edge.location,
count: 1
});
} else {
const existing = edgeMap.get(key);
existing.strength = Math.max(existing.strength, dims.strength?.value ?? 0.5);
existing.balance = Math.min(existing.balance, dims.balance?.value ?? 0.5);
existing.inCycle = existing.inCycle || edge.in_cycle;
existing.issue = existing.issue || edge.issue;
existing.count++;
if ((dims.strength?.value ?? 0) > existing.strength) {
existing.strengthLabel = dims.strength?.label ?? existing.strengthLabel;
}
}
});
const edges = Array.from(edgeMap.entries()).map(([key, data], idx) => ({
data: {
id: `e${idx}`,
source: data.source,
target: data.target,
edgeType: 'module',
strength: data.strength,
strengthLabel: data.strengthLabel,
distance: data.distance,
volatility: data.volatility,
balance: data.balance,
balanceLabel: data.balanceLabel,
classification: data.classification,
classificationJa: data.classificationJa,
issue: data.issue,
inCycle: data.inCycle,
location: data.location,
count: data.count
}
}));
if (showItems) {
let itemEdgeIdx = 0;
data.nodes.forEach(node => {
(node.items || []).forEach(item => {
const itemId = `${node.id}::${item.name}`;
edges.push({
data: {
id: `ie-parent-${itemEdgeIdx++}`,
source: itemId,
target: node.id,
edgeType: 'parent',
strengthLabel: 'Parent'
}
});
(item.dependencies || []).forEach(dep => {
const targetName = dep.target.split('::').pop();
let targetId = null;
for (const n of data.nodes) {
const matchingItem = (n.items || []).find(i => i.name === targetName);
if (matchingItem) {
targetId = `${n.id}::${matchingItem.name}`;
break;
}
}
if (!targetId) {
const moduleMatch = data.nodes.find(n => n.id === targetName || n.label === targetName);
if (moduleMatch) {
targetId = moduleMatch.id;
}
}
if (targetId && targetId !== itemId) {
edges.push({
data: {
id: `ie-dep-${itemEdgeIdx++}`,
source: itemId,
target: targetId,
edgeType: 'item-dep',
depType: dep.dep_type,
strengthLabel: dep.strength || 'Model',
distance: dep.distance || 'SameModule'
}
});
}
});
});
});
}
return [...nodes, ...edges];
}
export function getCytoscapeStyle() {
return [
{
selector: 'node[nodeType="module"]',
style: {
'label': node => {
const label = node.data('label') || '';
const fn = node.data('fn_count') || 0;
const ty = node.data('type_count') || 0;
const impl = node.data('impl_count') || 0;
if (fn === 0 && ty === 0 && impl === 0) return label;
return `${label}\n${fn}fn ${ty}ty ${impl}impl`;
},
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': '120px',
'background-color': node => getHealthColor(node.data('health')),
'border-width': 2,
'border-color': '#475569',
'color': '#f8fafc',
'font-size': '9px',
'text-outline-color': '#0f172a',
'text-outline-width': 2,
'width': node => 40 + (node.data('couplings_out') || 0) * 2,
'height': node => 40 + (node.data('couplings_out') || 0) * 2,
'shape': 'roundrectangle'
}
},
{
selector: 'node[nodeType="item"]',
style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'font-size': '7px',
'color': '#f8fafc',
'text-outline-color': '#0f172a',
'text-outline-width': 1,
'width': 20,
'height': 20,
'shape': 'ellipse',
'background-color': node => {
const kind = node.data('itemKind');
if (kind === 'fn') return '#3b82f6';
if (kind === 'trait') return '#22c55e';
return '#8b5cf6';
},
'border-width': node => node.data('visibility') === 'pub' ? 2 : 1,
'border-color': node => node.data('visibility') === 'pub' ? '#fbbf24' : '#475569'
}
},
{
selector: 'node:not([nodeType])',
style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'background-color': '#64748b',
'border-width': 2,
'border-color': '#475569',
'color': '#f8fafc',
'font-size': '9px',
'text-outline-color': '#0f172a',
'text-outline-width': 2,
'width': 40,
'height': 40
}
},
{
selector: 'edge',
style: {
'width': edge => 1 + edge.data('strength') * 4,
'line-color': edge => getEdgeColorByAnalysis(edge.data()),
'target-arrow-color': edge => getEdgeColorByAnalysis(edge.data()),
'target-arrow-shape': 'triangle',
'arrow-scale': 1.5,
'curve-style': 'bezier',
'opacity': 0.7,
'line-style': edge => getDistanceStyle(edge.data('distance'))
}
},
{
selector: 'edge[strengthLabel="Intrusive"][distance="DifferentCrate"], edge[strengthLabel="Intrusive"][distance="DifferentModule"], edge[strengthLabel="Functional"][distance="DifferentCrate"]',
style: {
'line-color': '#ef4444',
'target-arrow-color': '#ef4444',
'width': edge => 2 + edge.data('strength') * 5,
'opacity': 0.9
}
},
{
selector: 'edge[strengthLabel="Intrusive"][distance="SameModule"], edge[strengthLabel="Functional"][distance="SameModule"], edge[strengthLabel="Contract"][distance="DifferentModule"], edge[strengthLabel="Contract"][distance="DifferentCrate"]',
style: {
'line-color': '#22c55e',
'target-arrow-color': '#22c55e',
'opacity': 0.6
}
},
{
selector: 'edge[issue]',
style: {
'width': edge => 3 + edge.data('strength') * 4,
'opacity': 0.85
}
},
{
selector: 'edge[?inCycle]',
style: {
'line-color': '#dc2626',
'target-arrow-color': '#dc2626',
'width': 3,
'line-style': 'solid'
}
},
{
selector: 'edge[edgeType="parent"]',
style: {
'line-color': '#64748b',
'target-arrow-color': '#64748b',
'width': 1,
'opacity': 0.3,
'line-style': 'dotted',
'target-arrow-shape': 'none'
}
},
{
selector: 'edge[edgeType="item-dep"]',
style: {
'width': 1.5,
'opacity': 0.6,
'line-color': edge => {
const strength = edge.data('strengthLabel');
if (strength === 'Intrusive') return '#ef4444';
if (strength === 'Functional') return '#f97316';
return '#6b7280';
},
'target-arrow-color': edge => {
const strength = edge.data('strengthLabel');
if (strength === 'Intrusive') return '#ef4444';
if (strength === 'Functional') return '#f97316';
return '#6b7280';
},
'target-arrow-shape': 'triangle',
'arrow-scale': 0.8,
'curve-style': 'bezier'
}
},
{
selector: '.highlighted',
style: {
'opacity': 1,
'border-width': 3,
'border-color': '#3b82f6'
}
},
{
selector: '.dimmed',
style: { 'opacity': 0.15 }
},
{
selector: '.hidden',
style: { 'display': 'none' }
},
{
selector: '.dependency-source',
style: {
'border-color': '#22c55e',
'border-width': 4
}
},
{
selector: '.dependency-target',
style: {
'border-color': '#ef4444',
'border-width': 4
}
},
{
selector: '.hover',
style: {
'border-color': '#3b82f6',
'border-width': 3
}
},
{
selector: '.search-match',
style: {
'border-color': '#eab308',
'border-width': 4,
'background-color': '#eab308'
}
}
];
}
export function getLayoutConfig(name) {
const configs = {
cose: {
name: 'cose',
animate: true,
animationDuration: 500,
nodeRepulsion: 8000,
idealEdgeLength: 100,
edgeElasticity: 100,
gravity: 0.25,
numIter: 1000
},
dagre: {
name: 'dagre',
rankDir: 'TB',
nodeSep: 50,
rankSep: 80,
edgeSep: 10,
animate: true,
animationDuration: 500,
fit: true,
padding: 50
},
concentric: {
name: 'concentric',
animate: true,
animationDuration: 500,
concentric: node => node.data('couplings_in') || 0,
levelWidth: () => 2
},
grid: { name: 'grid', animate: true, animationDuration: 500, rows: 5 }
};
return configs[name] || configs.cose;
}
export function applyLayout(name) {
if (!state.cy) return;
setCurrentLayout(name);
state.cy.layout(getLayoutConfig(name)).run();
}
export function centerOnNode(node, useRelayout = false) {
if (!state.cy) return;
if (useRelayout) {
const layout = state.cy.layout({
name: 'concentric',
concentric: function(n) {
if (n.id() === node.id()) return 10;
if (node.neighborhood().contains(n)) return 5;
return 1;
},
levelWidth: function() { return 2; },
animate: true,
animationDuration: 500
});
layout.run();
} else {
state.cy.animate({
center: { eles: node },
zoom: Math.max(state.cy.zoom(), 1),
duration: 400,
easing: 'ease-out-cubic'
});
}
}
export function focusOnNode(node) {
if (!state.cy) return;
state.cy.animate({
center: { eles: node },
zoom: 1.5,
duration: 400,
easing: 'ease-out-cubic'
});
}
export function highlightNeighbors(node) {
clearHighlights();
state.cy.elements().addClass('dimmed');
node.removeClass('dimmed').addClass('highlighted');
node.neighborhood().removeClass('dimmed').addClass('highlighted');
}
export function highlightDependencyPath(edge) {
clearHighlights();
state.cy.elements().addClass('dimmed');
const source = state.cy.getElementById(edge.data('source'));
const target = state.cy.getElementById(edge.data('target'));
source.removeClass('dimmed').addClass('dependency-source');
target.removeClass('dimmed').addClass('dependency-target');
edge.removeClass('dimmed').addClass('highlighted');
state.cy.fit(source.union(target).union(edge), 100);
}
export function clearHighlights() {
if (state.cy) {
state.cy.elements().removeClass('highlighted dimmed dependency-source dependency-target search-match');
}
}
function setupGraphEventHandlers(cy, onNodeTap, onEdgeTap, onBackgroundTap) {
cy.on('tap', 'node', function(evt) {
if (onNodeTap) onNodeTap(evt.target);
});
cy.on('tap', 'edge', function(evt) {
if (onEdgeTap) onEdgeTap(evt.target);
});
cy.on('tap', function(evt) {
if (evt.target === cy && onBackgroundTap) {
onBackgroundTap();
}
});
cy.on('mouseover', 'node', function(evt) {
evt.target.addClass('hover');
});
cy.on('mouseout', 'node', function(evt) {
evt.target.removeClass('hover');
});
}
export function getHealthColor(health) {
const colors = { good: '#22c55e', needs_review: '#eab308', critical: '#ef4444' };
return colors[health] || '#64748b';
}
export function getBalanceColor(balance) {
if (balance >= 0.8) return '#22c55e';
if (balance >= 0.4) return '#eab308';
return '#ef4444';
}
function getDistanceStyle(distance) {
if (distance === 'SameModule' || distance === 'SameFunction') return 'solid';
if (distance === 'DifferentModule') return 'dashed';
return 'dotted';
}
function getStrengthName(value) {
if (value >= 0.75) return 'Intrusive';
if (value >= 0.5) return 'Functional';
if (value >= 0.25) return 'Model';
return 'Contract';
}
export function analyzeCoupling(strength, distance, volatility, targetName = '') {
const isStrongCoupling = ['Intrusive', 'Functional'].includes(strength);
const isWeakCoupling = ['Model', 'Contract'].includes(strength);
const isClose = ['SameFunction', 'SameModule'].includes(distance);
const isFar = ['DifferentModule', 'DifferentCrate'].includes(distance);
const effectiveVolatility = estimateVolatility(targetName, volatility);
const isHighVolatility = effectiveVolatility === 'High';
const isLowVolatility = effectiveVolatility === 'Low';
const isMediumVolatility = effectiveVolatility === 'Medium';
const isStableExternal = isExternalCrate(targetName) && isLowVolatility;
const hasModularity = (isStrongCoupling && isClose) || (isWeakCoupling && isFar);
if (isStrongCoupling && isFar) {
if (isLowVolatility) {
return {
status: 'good',
icon: '🔒',
statusText: 'Stable External Dependency',
action: null
};
}
if (isMediumVolatility) {
return {
status: 'acceptable',
icon: '⚠️',
statusText: 'Global Complexity (Medium)',
action: 'Consider introducing trait for abstraction or reducing distance'
};
}
return {
status: 'critical',
icon: '❌',
statusText: 'Global Complexity + Cascading Changes',
action: 'Introduce trait to invert dependency (DIP) or move closer'
};
}
if (isStrongCoupling && isClose) {
return {
status: 'good',
icon: '✅',
statusText: 'High Cohesion',
action: null
};
}
if (isWeakCoupling && isFar) {
return {
status: 'good',
icon: '✅',
statusText: 'Loose Coupling',
action: null
};
}
if (isWeakCoupling && isClose) {
return {
status: 'acceptable',
icon: '🤔',
statusText: 'Local Complexity',
action: 'Direct access may be simpler within same module'
};
}
return {
status: 'good',
icon: '✅',
statusText: 'Balanced',
action: null
};
}
function getEdgeColorByAnalysis(data) {
const strength = data.strengthLabel || getStrengthName(data.strength);
const distance = data.distance || 'Unknown';
const volatility = data.volatility || 'Medium';
const target = data.target || '';
const analysis = analyzeCoupling(strength, distance, volatility, target);
switch (analysis.status) {
case 'good': return '#22c55e';
case 'acceptable': return '#eab308';
case 'critical': return '#ef4444';
default: return '#64748b';
}
}