nyx/frontend/src/graph/styles.ts

258 lines
7.2 KiB
TypeScript

import type { GraphMetadata, GraphThemePalette, GraphViewKind } from './types';
export interface NodeStyle {
fill: string;
stroke: string;
textFill: string;
secondaryFill: string;
shape: 'rect' | 'terminal' | 'double';
strokeWidth: number;
accentFill: string;
neighborFill: string;
}
export interface EdgeStyle {
color: string;
width: number;
dash: number[];
}
const FALLBACK_PALETTE: GraphThemePalette = {
background: '#f9f8f4',
backgroundSecondary: '#f2f0ea',
text: '#0d0c0a',
textSecondary: '#3c3830',
textTertiary: '#6c6660',
border: '#e5e1d7',
borderLight: '#ede9df',
accent: '#0b3d2a',
accentSoft: '#ecf3ee',
success: '#1c5c38',
warning: '#8c6310',
danger: '#9d2f25',
neutral: '#6c6660',
neutralSoft: '#9c9690',
};
function readVar(name: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}
function hexToRgb(value: string): [number, number, number] | null {
const normalized = value.replace('#', '').trim();
if (normalized.length !== 3 && normalized.length !== 6) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((part) => part + part)
.join('')
: normalized;
const intValue = Number.parseInt(expanded, 16);
if (Number.isNaN(intValue)) return null;
return [(intValue >> 16) & 255, (intValue >> 8) & 255, intValue & 255];
}
export function withAlpha(color: string, alpha: number): string {
if (color.startsWith('rgba(')) {
return color.replace(/rgba\(([^)]+),[^)]+\)/, `rgba($1, ${alpha})`);
}
if (color.startsWith('rgb(')) {
const inner = color.slice(4, -1);
return `rgba(${inner}, ${alpha})`;
}
const rgb = hexToRgb(color);
if (!rgb) return color;
return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
}
export function readGraphPalette(): GraphThemePalette {
return {
background: readVar('--bg', FALLBACK_PALETTE.background),
backgroundSecondary: readVar(
'--bg-secondary',
FALLBACK_PALETTE.backgroundSecondary,
),
text: readVar('--text', FALLBACK_PALETTE.text),
textSecondary: readVar('--text-secondary', FALLBACK_PALETTE.textSecondary),
textTertiary: readVar('--text-tertiary', FALLBACK_PALETTE.textTertiary),
border: readVar('--border', FALLBACK_PALETTE.border),
borderLight: readVar('--border-light', FALLBACK_PALETTE.borderLight),
accent: readVar('--accent', FALLBACK_PALETTE.accent),
accentSoft: readVar('--accent-light', FALLBACK_PALETTE.accentSoft),
success: readVar('--success', FALLBACK_PALETTE.success),
warning: readVar('--sev-medium', FALLBACK_PALETTE.warning),
danger: readVar('--sev-high', FALLBACK_PALETTE.danger),
neutral: FALLBACK_PALETTE.neutral,
neutralSoft: FALLBACK_PALETTE.neutralSoft,
};
}
function cfgNodeStyle(
type: string,
palette: GraphThemePalette,
metadata?: GraphMetadata,
): NodeStyle {
if (metadata?.isCompound) {
return {
fill: withAlpha(palette.borderLight, 0.9),
stroke: palette.border,
textFill: palette.text,
secondaryFill: palette.textSecondary,
shape: 'rect',
strokeWidth: 1.25,
accentFill: palette.accent,
neighborFill: palette.accentSoft,
};
}
switch (type) {
case 'Entry':
return {
fill: palette.success,
stroke: withAlpha(palette.success, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'double',
strokeWidth: 1.8,
accentFill: palette.accent,
neighborFill: withAlpha(palette.success, 0.75),
};
case 'Exit':
return {
fill: palette.textSecondary,
stroke: withAlpha(palette.textSecondary, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'double',
strokeWidth: 1.6,
accentFill: palette.accent,
neighborFill: withAlpha(palette.textSecondary, 0.76),
};
case 'If':
return {
fill: palette.accent,
stroke: withAlpha(palette.accent, 0.82),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2,
accentFill: palette.accent,
neighborFill: palette.accentSoft,
};
case 'Loop':
return {
fill: '#6c6660',
stroke: '#3c3830',
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 2.1,
accentFill: palette.accent,
neighborFill: withAlpha('#6c6660', 0.74),
};
case 'Call':
return {
fill: palette.warning,
stroke: withAlpha(palette.warning, 0.85),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'rect',
strokeWidth: 1.5,
accentFill: palette.accent,
neighborFill: withAlpha(palette.warning, 0.76),
};
case 'Return':
return {
fill: palette.danger,
stroke: withAlpha(palette.danger, 0.86),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.8),
shape: 'terminal',
strokeWidth: 1.7,
accentFill: palette.accent,
neighborFill: withAlpha(palette.danger, 0.75),
};
default:
return {
fill: withAlpha(palette.neutral, 0.92),
stroke: withAlpha(palette.neutral, 0.8),
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.78),
shape: 'rect',
strokeWidth: 1.2,
accentFill: palette.accent,
neighborFill: withAlpha(palette.neutralSoft, 0.88),
};
}
}
function callGraphNodeStyle(
palette: GraphThemePalette,
metadata?: GraphMetadata,
): NodeStyle {
const isRecursive = metadata?.isRecursive === true;
const fill = isRecursive ? '#5a5042' : palette.neutral;
const stroke = isRecursive ? '#3c3830' : withAlpha(palette.neutral, 0.84);
return {
fill,
stroke,
textFill: '#ffffff',
secondaryFill: withAlpha('#ffffff', 0.74),
shape: 'rect',
strokeWidth: isRecursive ? 1.8 : 1.3,
accentFill: palette.accent,
neighborFill: isRecursive ? withAlpha(fill, 0.76) : palette.accentSoft,
};
}
export function getNodeStyle(
type: string,
graphKind: GraphViewKind = 'cfg',
metadata?: GraphMetadata,
palette = FALLBACK_PALETTE,
): NodeStyle {
return graphKind === 'callgraph'
? callGraphNodeStyle(palette, metadata)
: cfgNodeStyle(type, palette, metadata);
}
export function getEdgeStyle(
type: string,
graphKind: GraphViewKind = 'cfg',
palette = FALLBACK_PALETTE,
): EdgeStyle {
if (graphKind === 'callgraph') {
return {
color: withAlpha(palette.neutralSoft, 0.72),
width: 1.2,
dash: [],
};
}
switch (type) {
case 'True':
return { color: palette.success, width: 1.8, dash: [] };
case 'False':
return { color: palette.danger, width: 1.8, dash: [] };
case 'Back':
return { color: palette.textTertiary, width: 1.6, dash: [7, 4] };
case 'Exception':
return { color: palette.warning, width: 1.6, dash: [3, 3] };
default:
return {
color: withAlpha(palette.textTertiary, 0.78),
width: 1.3,
dash: [],
};
}
}