Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion lib/report-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,99 @@ function buildGraphologyGraph(dot) {
// Popup helpers (referenced by onclick attributes in the report template).
// ---------------------------------------------------------------------------

// Hovered-node label box backgrounds per palette. Sigma's default hover
// renderer hardcodes "#FFF", which washes out the light label text the dark
// palette switches to; the dark value is --card-bg in app/globals.css (the
// popup surface), so the box blends into it while keeping >= 4.5:1 against
// CHART_LEGEND_TEXT.dark.
const SIGMA_HOVER_BG = Object.freeze({
light: '#ffffff',
dark: '#1a2330'
});

/**
* Dark-palette counterpart of sigma.js' default hover renderer (upstream
* drawHover, MIT): identical geometry — a rounded label box trailing a drop
* shadow, or a shadowed circle when the node has no label — but the box
* fills with the dark popup surface instead of the hardcoded white, so the
* light label color stays readable. Canvas-drawn, so CSS cannot reach it.
*
* @param {CanvasRenderingContext2D} context - Hover layer canvas context.
* @param {object} data - Display data (x, y, size, label) of the hovered node.
* @param {object} settings - Resolved Sigma settings (labelColor, labelFont…).
* @returns {void}
*/
export function drawDarkHover(context, data, settings) {
const size = settings.labelSize;
const font = settings.labelFont;
const weight = settings.labelWeight;

context.font = `${weight} ${size}px ${font}`;

// Label box background in the dark surface color.
context.fillStyle = SIGMA_HOVER_BG.dark;
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 8;
context.shadowColor = '#000';

const PADDING = 2;

if (typeof data.label === 'string') {
const textWidth = context.measureText(data.label).width;
const boxWidth = Math.round(textWidth + 5);
const boxHeight = Math.round(size + 2 * PADDING);
const radius = Math.max(data.size, size / 2) + PADDING;

const angleRadian = Math.asin(boxHeight / 2 / radius);
const xDeltaCoord = Math.sqrt(Math.abs(Math.pow(radius, 2) - Math.pow(boxHeight / 2, 2)));

context.beginPath();
context.moveTo(data.x + xDeltaCoord, data.y + boxHeight / 2);
context.lineTo(data.x + radius + boxWidth, data.y + boxHeight / 2);
context.lineTo(data.x + radius + boxWidth, data.y - boxHeight / 2);
context.lineTo(data.x + xDeltaCoord, data.y - boxHeight / 2);
context.arc(data.x, data.y, radius, angleRadian, -angleRadian);
context.closePath();
context.fill();
} else {
context.beginPath();
context.arc(data.x, data.y, data.size + PADDING, 0, Math.PI * 2);
context.closePath();
context.fill();
}

context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 0;

// Label text in the same themed color sigma's default label renderer
// uses (settings.labelColor.color), mirroring upstream drawLabel.
if (data.label) {
context.fillStyle = settings.labelColor.color;
context.fillText(data.label, data.x + data.size + 3, data.y + size / 3);
}
}

/**
* Sigma settings for a 2D popup graph under the active palette: undefined in
* light mode (sigma defaults — #000 labels on the #FFF hover box — stay
* untouched), and in dark mode the chart legend text color for node labels
* plus the dark hover renderer above. Popups are rebuilt on every open, so
* resolving at open time suffices; the theme radios sit under the modal
* overlay while a popup is open.
*
* @returns {object|undefined} Partial Sigma settings, or undefined for sigma
* to keep its defaults.
*/
export function sigmaPopupSettings() {
if (activeChartTheme() !== 'dark') return undefined;
return {
labelColor: {color: CHART_LEGEND_TEXT.dark},
hoverRenderer: drawDarkHover
};
}

export function showPopup(popupId, containerName, dot) {
const overlay = document.getElementById('overlay');
const popup = document.getElementById(popupId);
Expand All @@ -123,7 +216,7 @@ export function showPopup(popupId, containerName, dot) {
popup.style.display = 'block';
if (window.graphlibDot && window.graphology && window.Sigma) {
const graph = buildGraphologyGraph(dot);
new window.Sigma(graph, document.getElementById(containerName));
new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings());
}
}

Expand Down
131 changes: 131 additions & 0 deletions tests/unit/report-view.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,137 @@ describe('popup helpers', () => {
});
});

describe('showPopup sigma theme settings', () => {
beforeEach(() => {
document.body.innerHTML = `
<div class="overlay" id="overlay" style="display:none"></div>
<div class="popup" id="popup-classGraph" style="display:none">
<div id="graph-container-classGraph"></div>
</div>`;
});

afterEach(() => {
delete window.graphlibDot;
delete window.graphology;
delete window.Sigma;
delete window.matchMedia;
});

// Installs the theme radios (which replace document.body.innerHTML) and
// re-appends the popup DOM they wiped, so showPopup finds its overlay.
function installRadiosAndPopup(mode, osPrefersDark) {
installThemeRadios(mode, osPrefersDark);
document.body.insertAdjacentHTML('beforeend', `
<div class="overlay" id="overlay" style="display:none"></div>
<div class="popup" id="popup-classGraph" style="display:none">
<div id="graph-container-classGraph"></div>
</div>`);
}

// Minimal widget stubs so showPopup reaches the Sigma constructor: an
// empty graphlib-dot graph (the sugiyama layout then no-ops) and a Sigma
// "constructor" recording its arguments.
function stubSigmaStack() {
const constructed = [];
window.graphlibDot = { read: () => ({ nodes: () => [], edges: () => [] }) };
window.graphology = {
Graph: class {
addNode() {}
addEdge() {}
nodes() { return []; }
forEachNeighbor() {}
setNodeAttribute() {}
}
};
window.Sigma = function (graph, container, settings) {
constructed.push({ graph, container, settings });
};
return constructed;
}

it('keeps the sigma defaults in light mode', () => {
installRadiosAndPopup('light');
const constructed = stubSigmaStack();
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
expect(constructed.length).toBe(1);
expect(constructed[0].settings).toBeUndefined();
});

it('passes the dark legend text color and a hover renderer in dark mode', () => {
installRadiosAndPopup('dark');
const constructed = stubSigmaStack();
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
const settings = constructed[0].settings;
expect(settings.labelColor.color).toBe('#9fb0c0');
expect(typeof settings.hoverRenderer).toBe('function');
});

it('resolves the dark settings for the system radio when the OS prefers dark', () => {
installRadiosAndPopup('system', true);
const constructed = stubSigmaStack();
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
expect(constructed[0].settings.labelColor.color).toBe('#9fb0c0');
});

it('draws the hover box in the dark popup surface with light label text', () => {
installRadiosAndPopup('dark');
const constructed = stubSigmaStack();
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
// Representative resolved settings, as sigma's resolveSettings merges the
// popup partial over its DEFAULT_SETTINGS (labelSize 14, Arial, normal).
const resolved = {
labelSize: 14,
labelFont: 'Arial',
labelWeight: 'normal',
...constructed[0].settings
};

const fills = [];
const drawn = [];
const coords = [];
const context = {
font: '',
shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 0, shadowColor: '',
measureText: () => ({ width: 42 }),
beginPath() {}, closePath() {},
moveTo: (...args) => coords.push(...args),
lineTo: (...args) => coords.push(...args),
arc: (...args) => coords.push(...args),
fill() { drawn.push({ op: 'fill', color: fills[fills.length - 1] }); },
fillText(text, ...args) {
drawn.push({ op: 'text', text, color: fills[fills.length - 1] });
coords.push(...args);
},
set fillStyle(value) { fills.push(value); },
get fillStyle() { return fills[fills.length - 1]; }
};
resolved.hoverRenderer(context, { x: 10, y: 20, size: 5, label: 'Foo' }, resolved);
// The label-box geometry must be finite everywhere (NaN coordinates would
// silently skip painting the box and the label).
expect(coords.length).toBeGreaterThan(0);
expect(coords.every(Number.isFinite)).toBe(true);
// The label font comes from the resolved label settings.
expect(context.font).toBe('normal 14px Arial');
// The hovered node's label box fills with the dark --card-bg surface…
expect(drawn.some(d => d.op === 'fill' && d.color === '#1a2330')).toBe(true);
// …and its text uses the dark chart legend color (>= 4.5:1 on it).
expect(drawn.some(d => d.op === 'text' && d.text === 'Foo' && d.color === '#9fb0c0')).toBe(true);
});

it('keeps the dark sigma label readable on the dark hover box (>= 4.5:1)', () => {
// #1a2330 is the dark palette's --card-bg (the popup surface) in
// app/globals.css; #9fb0c0 is CHART_LEGEND_TEXT.dark.
expect(contrastAgainst('#9fb0c0', [0x1a, 0x23, 0x30])).toBeGreaterThanOrEqual(4.5);
});

it('falls back to sigma defaults for the system radio when the OS prefers light', () => {
installRadiosAndPopup('system', false);
const constructed = stubSigmaStack();
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
expect(constructed[0].settings).toBeUndefined();
});
});

describe('bindPopupHandlers (template uses data attributes, no inline handlers)', () => {
beforeEach(() => {
document.body.innerHTML = `
Expand Down
Loading