Skip to content

Commit 9a3b17f

Browse files
Improving visibility of 2D popup graph text in dark mode (#20)
* Improving visibility of 2D popup graph text in dark mode * Apply Coderabbit suggestions
1 parent d3063a3 commit 9a3b17f

2 files changed

Lines changed: 225 additions & 1 deletion

File tree

‎lib/report-view.js‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,99 @@ function buildGraphologyGraph(dot) {
115115
// Popup helpers (referenced by onclick attributes in the report template).
116116
// ---------------------------------------------------------------------------
117117

118+
// Hovered-node label box backgrounds per palette. Sigma's default hover
119+
// renderer hardcodes "#FFF", which washes out the light label text the dark
120+
// palette switches to; the dark value is --card-bg in app/globals.css (the
121+
// popup surface), so the box blends into it while keeping >= 4.5:1 against
122+
// CHART_LEGEND_TEXT.dark.
123+
const SIGMA_HOVER_BG = Object.freeze({
124+
light: '#ffffff',
125+
dark: '#1a2330'
126+
});
127+
128+
/**
129+
* Dark-palette counterpart of sigma.js' default hover renderer (upstream
130+
* drawHover, MIT): identical geometry — a rounded label box trailing a drop
131+
* shadow, or a shadowed circle when the node has no label — but the box
132+
* fills with the dark popup surface instead of the hardcoded white, so the
133+
* light label color stays readable. Canvas-drawn, so CSS cannot reach it.
134+
*
135+
* @param {CanvasRenderingContext2D} context - Hover layer canvas context.
136+
* @param {object} data - Display data (x, y, size, label) of the hovered node.
137+
* @param {object} settings - Resolved Sigma settings (labelColor, labelFont…).
138+
* @returns {void}
139+
*/
140+
export function drawDarkHover(context, data, settings) {
141+
const size = settings.labelSize;
142+
const font = settings.labelFont;
143+
const weight = settings.labelWeight;
144+
145+
context.font = `${weight} ${size}px ${font}`;
146+
147+
// Label box background in the dark surface color.
148+
context.fillStyle = SIGMA_HOVER_BG.dark;
149+
context.shadowOffsetX = 0;
150+
context.shadowOffsetY = 0;
151+
context.shadowBlur = 8;
152+
context.shadowColor = '#000';
153+
154+
const PADDING = 2;
155+
156+
if (typeof data.label === 'string') {
157+
const textWidth = context.measureText(data.label).width;
158+
const boxWidth = Math.round(textWidth + 5);
159+
const boxHeight = Math.round(size + 2 * PADDING);
160+
const radius = Math.max(data.size, size / 2) + PADDING;
161+
162+
const angleRadian = Math.asin(boxHeight / 2 / radius);
163+
const xDeltaCoord = Math.sqrt(Math.abs(Math.pow(radius, 2) - Math.pow(boxHeight / 2, 2)));
164+
165+
context.beginPath();
166+
context.moveTo(data.x + xDeltaCoord, data.y + boxHeight / 2);
167+
context.lineTo(data.x + radius + boxWidth, data.y + boxHeight / 2);
168+
context.lineTo(data.x + radius + boxWidth, data.y - boxHeight / 2);
169+
context.lineTo(data.x + xDeltaCoord, data.y - boxHeight / 2);
170+
context.arc(data.x, data.y, radius, angleRadian, -angleRadian);
171+
context.closePath();
172+
context.fill();
173+
} else {
174+
context.beginPath();
175+
context.arc(data.x, data.y, data.size + PADDING, 0, Math.PI * 2);
176+
context.closePath();
177+
context.fill();
178+
}
179+
180+
context.shadowOffsetX = 0;
181+
context.shadowOffsetY = 0;
182+
context.shadowBlur = 0;
183+
184+
// Label text in the same themed color sigma's default label renderer
185+
// uses (settings.labelColor.color), mirroring upstream drawLabel.
186+
if (data.label) {
187+
context.fillStyle = settings.labelColor.color;
188+
context.fillText(data.label, data.x + data.size + 3, data.y + size / 3);
189+
}
190+
}
191+
192+
/**
193+
* Sigma settings for a 2D popup graph under the active palette: undefined in
194+
* light mode (sigma defaults — #000 labels on the #FFF hover box — stay
195+
* untouched), and in dark mode the chart legend text color for node labels
196+
* plus the dark hover renderer above. Popups are rebuilt on every open, so
197+
* resolving at open time suffices; the theme radios sit under the modal
198+
* overlay while a popup is open.
199+
*
200+
* @returns {object|undefined} Partial Sigma settings, or undefined for sigma
201+
* to keep its defaults.
202+
*/
203+
export function sigmaPopupSettings() {
204+
if (activeChartTheme() !== 'dark') return undefined;
205+
return {
206+
labelColor: {color: CHART_LEGEND_TEXT.dark},
207+
hoverRenderer: drawDarkHover
208+
};
209+
}
210+
118211
export function showPopup(popupId, containerName, dot) {
119212
const overlay = document.getElementById('overlay');
120213
const popup = document.getElementById(popupId);
@@ -123,7 +216,7 @@ export function showPopup(popupId, containerName, dot) {
123216
popup.style.display = 'block';
124217
if (window.graphlibDot && window.graphology && window.Sigma) {
125218
const graph = buildGraphologyGraph(dot);
126-
new window.Sigma(graph, document.getElementById(containerName));
219+
new window.Sigma(graph, document.getElementById(containerName), sigmaPopupSettings());
127220
}
128221
}
129222

‎tests/unit/report-view.test.js‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,137 @@ describe('popup helpers', () => {
10381038
});
10391039
});
10401040

1041+
describe('showPopup sigma theme settings', () => {
1042+
beforeEach(() => {
1043+
document.body.innerHTML = `
1044+
<div class="overlay" id="overlay" style="display:none"></div>
1045+
<div class="popup" id="popup-classGraph" style="display:none">
1046+
<div id="graph-container-classGraph"></div>
1047+
</div>`;
1048+
});
1049+
1050+
afterEach(() => {
1051+
delete window.graphlibDot;
1052+
delete window.graphology;
1053+
delete window.Sigma;
1054+
delete window.matchMedia;
1055+
});
1056+
1057+
// Installs the theme radios (which replace document.body.innerHTML) and
1058+
// re-appends the popup DOM they wiped, so showPopup finds its overlay.
1059+
function installRadiosAndPopup(mode, osPrefersDark) {
1060+
installThemeRadios(mode, osPrefersDark);
1061+
document.body.insertAdjacentHTML('beforeend', `
1062+
<div class="overlay" id="overlay" style="display:none"></div>
1063+
<div class="popup" id="popup-classGraph" style="display:none">
1064+
<div id="graph-container-classGraph"></div>
1065+
</div>`);
1066+
}
1067+
1068+
// Minimal widget stubs so showPopup reaches the Sigma constructor: an
1069+
// empty graphlib-dot graph (the sugiyama layout then no-ops) and a Sigma
1070+
// "constructor" recording its arguments.
1071+
function stubSigmaStack() {
1072+
const constructed = [];
1073+
window.graphlibDot = { read: () => ({ nodes: () => [], edges: () => [] }) };
1074+
window.graphology = {
1075+
Graph: class {
1076+
addNode() {}
1077+
addEdge() {}
1078+
nodes() { return []; }
1079+
forEachNeighbor() {}
1080+
setNodeAttribute() {}
1081+
}
1082+
};
1083+
window.Sigma = function (graph, container, settings) {
1084+
constructed.push({ graph, container, settings });
1085+
};
1086+
return constructed;
1087+
}
1088+
1089+
it('keeps the sigma defaults in light mode', () => {
1090+
installRadiosAndPopup('light');
1091+
const constructed = stubSigmaStack();
1092+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1093+
expect(constructed.length).toBe(1);
1094+
expect(constructed[0].settings).toBeUndefined();
1095+
});
1096+
1097+
it('passes the dark legend text color and a hover renderer in dark mode', () => {
1098+
installRadiosAndPopup('dark');
1099+
const constructed = stubSigmaStack();
1100+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1101+
const settings = constructed[0].settings;
1102+
expect(settings.labelColor.color).toBe('#9fb0c0');
1103+
expect(typeof settings.hoverRenderer).toBe('function');
1104+
});
1105+
1106+
it('resolves the dark settings for the system radio when the OS prefers dark', () => {
1107+
installRadiosAndPopup('system', true);
1108+
const constructed = stubSigmaStack();
1109+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1110+
expect(constructed[0].settings.labelColor.color).toBe('#9fb0c0');
1111+
});
1112+
1113+
it('draws the hover box in the dark popup surface with light label text', () => {
1114+
installRadiosAndPopup('dark');
1115+
const constructed = stubSigmaStack();
1116+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1117+
// Representative resolved settings, as sigma's resolveSettings merges the
1118+
// popup partial over its DEFAULT_SETTINGS (labelSize 14, Arial, normal).
1119+
const resolved = {
1120+
labelSize: 14,
1121+
labelFont: 'Arial',
1122+
labelWeight: 'normal',
1123+
...constructed[0].settings
1124+
};
1125+
1126+
const fills = [];
1127+
const drawn = [];
1128+
const coords = [];
1129+
const context = {
1130+
font: '',
1131+
shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 0, shadowColor: '',
1132+
measureText: () => ({ width: 42 }),
1133+
beginPath() {}, closePath() {},
1134+
moveTo: (...args) => coords.push(...args),
1135+
lineTo: (...args) => coords.push(...args),
1136+
arc: (...args) => coords.push(...args),
1137+
fill() { drawn.push({ op: 'fill', color: fills[fills.length - 1] }); },
1138+
fillText(text, ...args) {
1139+
drawn.push({ op: 'text', text, color: fills[fills.length - 1] });
1140+
coords.push(...args);
1141+
},
1142+
set fillStyle(value) { fills.push(value); },
1143+
get fillStyle() { return fills[fills.length - 1]; }
1144+
};
1145+
resolved.hoverRenderer(context, { x: 10, y: 20, size: 5, label: 'Foo' }, resolved);
1146+
// The label-box geometry must be finite everywhere (NaN coordinates would
1147+
// silently skip painting the box and the label).
1148+
expect(coords.length).toBeGreaterThan(0);
1149+
expect(coords.every(Number.isFinite)).toBe(true);
1150+
// The label font comes from the resolved label settings.
1151+
expect(context.font).toBe('normal 14px Arial');
1152+
// The hovered node's label box fills with the dark --card-bg surface…
1153+
expect(drawn.some(d => d.op === 'fill' && d.color === '#1a2330')).toBe(true);
1154+
// …and its text uses the dark chart legend color (>= 4.5:1 on it).
1155+
expect(drawn.some(d => d.op === 'text' && d.text === 'Foo' && d.color === '#9fb0c0')).toBe(true);
1156+
});
1157+
1158+
it('keeps the dark sigma label readable on the dark hover box (>= 4.5:1)', () => {
1159+
// #1a2330 is the dark palette's --card-bg (the popup surface) in
1160+
// app/globals.css; #9fb0c0 is CHART_LEGEND_TEXT.dark.
1161+
expect(contrastAgainst('#9fb0c0', [0x1a, 0x23, 0x30])).toBeGreaterThanOrEqual(4.5);
1162+
});
1163+
1164+
it('falls back to sigma defaults for the system radio when the OS prefers light', () => {
1165+
installRadiosAndPopup('system', false);
1166+
const constructed = stubSigmaStack();
1167+
showPopup('popup-classGraph', 'graph-container-classGraph', 'strict digraph G {}');
1168+
expect(constructed[0].settings).toBeUndefined();
1169+
});
1170+
});
1171+
10411172
describe('bindPopupHandlers (template uses data attributes, no inline handlers)', () => {
10421173
beforeEach(() => {
10431174
document.body.innerHTML = `

0 commit comments

Comments
 (0)