diff --git a/lib/report-view.js b/lib/report-view.js index b81f791..7c38887 100644 --- a/lib/report-view.js +++ b/lib/report-view.js @@ -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); @@ -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()); } } diff --git a/tests/unit/report-view.test.js b/tests/unit/report-view.test.js index b1bb87a..2cdf4af 100644 --- a/tests/unit/report-view.test.js +++ b/tests/unit/report-view.test.js @@ -1038,6 +1038,137 @@ describe('popup helpers', () => { }); }); +describe('showPopup sigma theme settings', () => { + beforeEach(() => { + document.body.innerHTML = ` + + `; + }); + + 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', ` + + `); + } + + // 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 = `