From 9acb09433e5e2f7f0a7f22343ac54bd125f92a10 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 17 Jun 2026 15:45:48 +0200 Subject: [PATCH 1/3] fix(graph): keep bubble/heatmap overlays aligned across DPR changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the window to a monitor with a different device-pixel-ratio left bubble groups (and the heatmap field) misaligned until a sidebar toggle forced a resize. Root cause: a monitor move changes window.devicePixelRatio without changing the container's CSS box, so sigma.resize() refreshes its pixelRatio but early-returns before re-sizing any canvas — the WebGL layers stay at the old backing resolution while the overlays repaint at the new ratio. The overlay canvases also never had an explicit CSS display size (createCanvasContext sets only position:absolute), so at >1 DPR they rendered at their backing- store size (width*dpr px) instead of the logical viewport. - add watchDevicePixelRatio (dpr_watch.js): on every DPR change force sigma.resize(true) + re-render, re-sizing all canvases to the new ratio (the same recovery a sidebar toggle triggered manually) - bubble_layer/heatmap_layer #prepareCanvas now own the CSS display size, keeping each overlay 1:1 with the WebGL layers regardless of resize timing - tests for the watcher, the A->B monitor-move sequence, and DPR-1/2 sizing --- src/graph/bubble_layer.js | 64 ++++---- src/graph/dpr_watch.js | 43 +++++ src/graph/heatmap_layer.js | 56 ++++--- src/graph/sigma_adapter.js | 145 +++++++++-------- tests/bubble-layer-canvas-css-size.test.js | 168 ++++++++++++++++++++ tests/bubble-layer-export.test.js | 4 +- tests/bubble-layer-malformed.test.js | 2 +- tests/bubble-layer-repro.test.js | 4 +- tests/dpr-watch.test.js | 107 +++++++++++++ tests/heatmap-layer-canvas-css-size.test.js | 83 ++++++++++ 10 files changed, 555 insertions(+), 121 deletions(-) create mode 100644 src/graph/dpr_watch.js create mode 100644 tests/bubble-layer-canvas-css-size.test.js create mode 100644 tests/dpr-watch.test.js create mode 100644 tests/heatmap-layer-canvas-css-size.test.js diff --git a/src/graph/bubble_layer.js b/src/graph/bubble_layer.js index 4f1a5cf..dcb2fb5 100644 --- a/src/graph/bubble_layer.js +++ b/src/graph/bubble_layer.js @@ -23,7 +23,7 @@ * This replaces the G6 plugin's recompute-per-draw churn (the patched * updateBubbleSetsPath path coalescing, issue #7195) with an owned cache. */ -import { DEFAULTS } from "../config.js"; +import { DEFAULTS } from '../config.js'; import { nodeViewportRect, computeOutlinePoints, @@ -32,10 +32,10 @@ import { idsKey, positionsChecksum, styleKey, -} from "./bubble_geometry.js"; +} from './bubble_geometry.js'; -const LAYER_NAME = "bubbleSets"; -const LABEL_LAYER_NAME = "bubbleSetsLabels"; +const LAYER_NAME = 'bubbleSets'; +const LABEL_LAYER_NAME = 'bubbleSetsLabels'; const OUTLINE_STROKE_WIDTH = 2; // Extra screen-px gap (beyond half the font box) between the outline and a // label drawn with labelCloseToPath: false. @@ -65,23 +65,23 @@ class BubbleSetLayer { // createCanvasContext returns the Sigma instance (fluent API); the canvas // and 2d context land in sigma.elements / sigma.canvasContexts. sigma.createCanvasContext(LAYER_NAME, { - afterLayer: "nodes", - style: { pointerEvents: "none" }, + afterLayer: 'nodes', + style: { pointerEvents: 'none' }, }); this.canvas = sigma.getCanvases()[LAYER_NAME]; - this.ctx = this.canvas.getContext("2d"); + this.ctx = this.canvas.getContext('2d'); // Group labels paint on their own canvas stacked above sigma's node-label // layer so they always win the z-order contest against member labels. sigma.createCanvasContext(LABEL_LAYER_NAME, { - afterLayer: "labels", - style: { pointerEvents: "none" }, + afterLayer: 'labels', + style: { pointerEvents: 'none' }, }); this.labelCanvas = sigma.getCanvases()[LABEL_LAYER_NAME]; - this.labelCtx = this.labelCanvas.getContext("2d"); + this.labelCtx = this.labelCanvas.getContext('2d'); this.renderHandler = () => this.scheduleRedraw(); - sigma.on("afterRender", this.renderHandler); + sigma.on('afterRender', this.renderHandler); } /** @@ -110,7 +110,7 @@ class BubbleSetLayer { if (this.killed) return; this.killed = true; if (this.rafHandle !== null) cancelAnimationFrame(this.rafHandle); - this.adapter.sigma.off("afterRender", this.renderHandler); + this.adapter.sigma.off('afterRender', this.renderHandler); // sigma.kill() may or may not remove custom layer canvases; remove() on // an already-detached node is a no-op, so drop ours defensively. this.canvas?.remove(); @@ -130,7 +130,7 @@ class BubbleSetLayer { #groupState(group) { let state = this.groups.get(group); if (!state) { - state = { members: new Map(), avoidMembers: [], opts: {}, membersKey: "", avoidKey: "" }; + state = { members: new Map(), avoidMembers: [], opts: {}, membersKey: '', avoidKey: '' }; this.groups.set(group, state); } return state; @@ -182,7 +182,7 @@ class BubbleSetLayer { // re-renders on hover etc. without clearing custom layers). const signature = `${width}x${height}x${dpr}|${camera.x},${camera.y},${camera.ratio},${camera.angle}` + - `|${active.map(([g]) => g).join(",")}`; + `|${active.map(([g]) => g).join(',')}`; if (!outlinesChanged && signature === this.lastPaintSignature) return; this.lastPaintSignature = signature; @@ -195,9 +195,7 @@ class BubbleSetLayer { #drawOutlines(active, sigma) { for (const [group, state] of active) { // Reprojection assumes camera.angle === 0 (the app never rotates the camera). - const points = this.outlines - .get(group) - .graphPoints.map((p) => sigma.graphToViewport(p)); + const points = this.outlines.get(group).graphPoints.map((p) => sigma.graphToViewport(p)); const defaults = this.cache.DEFAULTS.BUBBLE_GROUP_STYLE[group] ?? {}; const drawn = this.#drawGroup(this.ctx, points, state, defaults); // Labels paint on the top canvas (afterLayer: "labels") so they read @@ -296,6 +294,16 @@ class BubbleSetLayer { canvas.width = width * dpr; canvas.height = height * dpr; } + // Own the CSS display size too: sigma.createCanvasContext only sets + // position:absolute, and sigma.resize() (the sole writer of element.style + // width/height) runs once at construction — before this canvas exists — and + // early-returns on unchanged dimensions. Left unset, the canvas displays at + // its backing-store size (width*dpr CSS px), which is dpr* too large on a + // >1 DPR display, so the group lands in the wrong place until a panel toggle + // forces a real sigma resize. Setting it here keeps the overlay 1:1 with the + // WebGL layers regardless of sigma's resize timing or the display's DPR. + if (canvas.style.width !== `${width}px`) canvas.style.width = `${width}px`; + if (canvas.style.height !== `${height}px`) canvas.style.height = `${height}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); } @@ -402,7 +410,7 @@ class BubbleSetLayer { // Reference-viewport → graph space (undo the ratio-1 mapping, then the // camera): the cache holds zoom-independent graph coords. return refPoints.map((p) => - sigma.viewportToGraph({ x: cx + (p.x - cx) / r, y: cy + (p.y - cy) / r }), + sigma.viewportToGraph({ x: cx + (p.x - cx) / r, y: cy + (p.y - cy) / r }) ); } @@ -422,10 +430,10 @@ class BubbleSetLayer { ctx.save(); try { ctx.globalAlpha = opts.fillOpacity ?? defaults.fillOpacity ?? 0.25; - ctx.fillStyle = opts.fill ?? defaults.fill ?? "#403C53"; + ctx.fillStyle = opts.fill ?? defaults.fill ?? '#403C53'; ctx.fill(path); ctx.globalAlpha = opts.strokeOpacity ?? defaults.strokeOpacity ?? 1; - ctx.strokeStyle = opts.stroke ?? defaults.stroke ?? "#403C53"; + ctx.strokeStyle = opts.stroke ?? defaults.stroke ?? '#403C53'; ctx.lineWidth = OUTLINE_STROKE_WIDTH; ctx.stroke(path); ctx.globalAlpha = 1; @@ -442,9 +450,9 @@ class BubbleSetLayer { * outline tangent. labelOffsetX/Y stay additive in screen space. */ #drawLabel(ctx, points, opts, defaults) { - const text = opts.labelText ?? defaults.labelText ?? ""; + const text = opts.labelText ?? defaults.labelText ?? ''; if (!text) return; - const placement = opts.labelPlacement ?? defaults.labelPlacement ?? "bottom"; + const placement = opts.labelPlacement ?? defaults.labelPlacement ?? 'bottom'; const closeToPath = opts.labelCloseToPath ?? defaults.labelCloseToPath ?? true; const autoRotate = opts.labelAutoRotate ?? defaults.labelAutoRotate ?? true; const anchor = outlineLabelAnchor(points, placement); @@ -459,12 +467,12 @@ class BubbleSetLayer { const y = anchor.y + anchor.ny * standoff + (opts.labelOffsetY ?? 0); ctx.font = `${fontSize}px Arial, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; const textWidth = ctx.measureText(text).width; // Rotation only makes sense hugging the path; "center" has no tangent. - const rotated = autoRotate && closeToPath && placement !== "center"; + const rotated = autoRotate && closeToPath && placement !== 'center'; if (rotated) { ctx.save(); ctx.translate(x, y); @@ -475,18 +483,18 @@ class BubbleSetLayer { if (opts.labelBackground ?? defaults.labelBackground) { const radius = opts.labelBackgroundRadius ?? defaults.labelBackgroundRadius ?? 5; - ctx.fillStyle = opts.labelBackgroundFill ?? defaults.labelBackgroundFill ?? "#403C53"; + ctx.fillStyle = opts.labelBackgroundFill ?? defaults.labelBackgroundFill ?? '#403C53'; ctx.beginPath(); ctx.roundRect( lx - textWidth / 2 - padding, ly - fontSize / 2 - padding, textWidth + 2 * padding, fontSize + 2 * padding, - radius, + radius ); ctx.fill(); } - ctx.fillStyle = opts.labelFill ?? defaults.labelFill ?? "#fff"; + ctx.fillStyle = opts.labelFill ?? defaults.labelFill ?? '#fff'; ctx.fillText(text, lx, ly); if (rotated) ctx.restore(); } diff --git a/src/graph/dpr_watch.js b/src/graph/dpr_watch.js new file mode 100644 index 0000000..30783d5 --- /dev/null +++ b/src/graph/dpr_watch.js @@ -0,0 +1,43 @@ +/** + * Device-pixel-ratio change watcher. + * + * Dragging the window to a monitor with a different DPR changes + * window.devicePixelRatio without changing the container's CSS box, so + * sigma.resize() refreshes its pixelRatio but early-returns before re-sizing any + * canvas — leaving the WebGL layers at the old backing resolution while the + * bubble/heatmap overlays repaint at the new ratio, so groups land misaligned + * until a sidebar toggle forces a real resize. The adapter uses this watcher to + * force that resize automatically on every DPR change. + */ + +/** + * Invoke `onChange` whenever window.devicePixelRatio changes. matchMedia with a + * ratio-specific resolution query is the standard signal: the query stops + * matching the instant the ratio leaves its value, so we re-arm against the new + * ratio after each change. Returns a cleanup that detaches the currently-armed + * one-shot listener. + * + * @param {() => void} onChange + * @returns {() => void} cleanup + */ +function watchDevicePixelRatio(onChange) { + let detach = null; + const handler = () => { + onChange(); + arm(); + }; + function arm() { + const dpr = window.devicePixelRatio || 1; + const media = window.matchMedia?.(`(resolution: ${dpr}dppx)`); + if (!media?.addEventListener) { + detach = null; + return; + } + media.addEventListener('change', handler, { once: true }); + detach = () => media.removeEventListener('change', handler); + } + arm(); + return () => detach?.(); +} + +export { watchDevicePixelRatio }; diff --git a/src/graph/heatmap_layer.js b/src/graph/heatmap_layer.js index 96146df..1a3c2ae 100644 --- a/src/graph/heatmap_layer.js +++ b/src/graph/heatmap_layer.js @@ -34,18 +34,18 @@ * ≤ MAX_RESOLUTION² offscreen; fine at the app's typical graph sizes, the * knob to lower on huge graphs is DEFAULTS.HEATMAP.MAX_RESOLUTION. */ -import { DEFAULTS } from "../config.js"; -import { currentTheme } from "../utilities/theme.js"; -import { positionsChecksum } from "./bubble_geometry.js"; +import { DEFAULTS } from '../config.js'; +import { currentTheme } from '../utilities/theme.js'; +import { positionsChecksum } from './bubble_geometry.js'; import { graphBBox, splatTransform, heatBandwidth, buildRampLut, applyRampToAlpha, -} from "./heatmap_geometry.js"; +} from './heatmap_geometry.js'; -const LAYER_NAME = "heatmap"; +const LAYER_NAME = 'heatmap'; /** Initial runtime settings, copied from config (see header comment). */ function defaultSettings() { @@ -63,7 +63,7 @@ function defaultSettings() { /** Theme-resolved stops for a preset name; unknown names fall back to default. */ function rampStopsFor(ramp, theme) { const preset = DEFAULTS.HEATMAP.RAMPS[ramp] ?? DEFAULTS.HEATMAP.RAMPS.default; - return theme === "dark" ? preset.dark : preset.light; + return theme === 'dark' ? preset.dark : preset.light; } class HeatmapLayer { @@ -88,14 +88,14 @@ class HeatmapLayer { const sigma = adapter.sigma; sigma.createCanvasContext(LAYER_NAME, { - beforeLayer: "edges", - style: { pointerEvents: "none" }, + beforeLayer: 'edges', + style: { pointerEvents: 'none' }, }); this.canvas = sigma.getCanvases()[LAYER_NAME]; - this.ctx = this.canvas.getContext("2d"); + this.ctx = this.canvas.getContext('2d'); this.renderHandler = () => this.scheduleRedraw(); - sigma.on("afterRender", this.renderHandler); + sigma.on('afterRender', this.renderHandler); } /** @param {boolean} enabled */ @@ -122,9 +122,9 @@ class HeatmapLayer { const next = { ...this.settings }; for (const [key, value] of Object.entries(partial)) { if (!(key in next)) continue; - if (key === "dimGraph") next.dimGraph = !!value; - else if (key === "ramp") { - if (typeof value === "string" && value in DEFAULTS.HEATMAP.RAMPS) next.ramp = value; + if (key === 'dimGraph') next.dimGraph = !!value; + else if (key === 'ramp') { + if (typeof value === 'string' && value in DEFAULTS.HEATMAP.RAMPS) next.ramp = value; } else if (Number.isFinite(value)) next[key] = value; } this.settings = next; @@ -144,7 +144,7 @@ class HeatmapLayer { if (this.killed) return; this.killed = true; if (this.rafHandle !== null) cancelAnimationFrame(this.rafHandle); - this.adapter.sigma.off("afterRender", this.renderHandler); + this.adapter.sigma.off('afterRender', this.renderHandler); // sigma.kill() may or may not remove custom layer canvases; remove() on // an already-detached node is a no-op, so drop ours defensively. this.canvas?.remove(); @@ -229,6 +229,13 @@ class HeatmapLayer { this.canvas.width = width * dpr; this.canvas.height = height * dpr; } + // Own the CSS display size too (see bubble_layer.js #prepareCanvas): sigma + // never sets element.style width/height on a custom canvas created after its + // construction-time resize, so without this the field displays at its + // backing-store size (dpr* too large on a >1 DPR monitor) until a panel + // toggle forces a real sigma resize. + if (this.canvas.style.width !== `${width}px`) this.canvas.style.width = `${width}px`; + if (this.canvas.style.height !== `${height}px`) this.canvas.style.height = `${height}px`; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.ctx.clearRect(0, 0, width, height); } @@ -275,10 +282,10 @@ class HeatmapLayer { return; } const transform = splatTransform(bbox, bandwidth, DEFAULTS.HEATMAP.MAX_RESOLUTION); - const off = document.createElement("canvas"); + const off = document.createElement('canvas'); off.width = transform.width; off.height = transform.height; - const offCtx = off.getContext("2d", { willReadFrequently: true }); + const offCtx = off.getContext('2d', { willReadFrequently: true }); // One alpha-only sprite stamped per node beats N radial-gradient fills. const radiusPx = Math.max(1, bandwidth * transform.scale); @@ -288,7 +295,7 @@ class HeatmapLayer { offCtx.drawImage( sprite, (p.x - transform.offsetX) * transform.scale - half, - (p.y - transform.offsetY) * transform.scale - half, + (p.y - transform.offsetY) * transform.scale - half ); } @@ -298,9 +305,14 @@ class HeatmapLayer { // the contract explicit at this call site. const image = offCtx.getImageData(0, 0, transform.width, transform.height); offCtx.putImageData( - applyRampToAlpha(image, this.#rampLut(rampStops), this.settings.gamma, this.settings.threshold), - 0, + applyRampToAlpha( + image, + this.#rampLut(rampStops), + this.settings.gamma, + this.settings.threshold + ), 0, + 0 ); this.heatCache = { key, canvas: off, transform }; } @@ -308,14 +320,14 @@ class HeatmapLayer { /** Radial alpha falloff sprite: settings.intensity at center → 0 at radius. */ #buildSplatSprite(radiusPx) { const size = Math.max(2, Math.ceil(radiusPx * 2)); - const sprite = document.createElement("canvas"); + const sprite = document.createElement('canvas'); sprite.width = size; sprite.height = size; - const ctx = sprite.getContext("2d"); + const ctx = sprite.getContext('2d'); const c = size / 2; const gradient = ctx.createRadialGradient(c, c, 0, c, c, radiusPx); gradient.addColorStop(0, `rgba(0, 0, 0, ${this.settings.intensity})`); - gradient.addColorStop(1, "rgba(0, 0, 0, 0)"); + gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); return sprite; diff --git a/src/graph/sigma_adapter.js b/src/graph/sigma_adapter.js index 958b2d8..555e5b8 100644 --- a/src/graph/sigma_adapter.js +++ b/src/graph/sigma_adapter.js @@ -19,34 +19,35 @@ import { edgeCurve, animateNodes, createNodePiechartProgram, -} from "../lib/sigma.bundle.mjs"; -import { DEFAULTS } from "../config.js"; +} from '../lib/sigma.bundle.mjs'; +import { DEFAULTS } from '../config.js'; import { EdgeHaloProgram, createCurveHaloProgram, createEdgeMarkerHeadProgram, -} from "./edge_programs.js"; +} from './edge_programs.js'; import { clampExportScale, MAX_CANVAS_SIDE, WEBGL_SAFE_SIDE_FRACTION, -} from "../utilities/export_scale.js"; -import { webglMaxCanvasSide } from "./webgl_support.js"; -import { buildGraphSvg } from "./export_svg.js"; -import { EdgeFlowProgram, createCurveFlowProgram } from "./edge_flow_programs.js"; -import { FlowAnimator } from "./flow_animator.js"; +} from '../utilities/export_scale.js'; +import { webglMaxCanvasSide } from './webgl_support.js'; +import { buildGraphSvg } from './export_svg.js'; +import { EdgeFlowProgram, createCurveFlowProgram } from './edge_flow_programs.js'; +import { FlowAnimator } from './flow_animator.js'; import { nodeAttributesFromStyle, edgeAttributesFromStyle, flipY, buildLayoutTransitionTargets, -} from "./graph_model.js"; -import { executeLayout } from "./layout_algorithms.js"; -import { drawNodeLabel, drawEdgeLabel, BAKED_DEFAULT_LABEL_COLOR } from "./label_renderers.js"; -import { InteractionManager } from "./interactions.js"; -import { BubbleSetLayer } from "./bubble_layer.js"; -import { HeatmapLayer } from "./heatmap_layer.js"; -import { Minimap } from "./minimap.js"; +} from './graph_model.js'; +import { executeLayout } from './layout_algorithms.js'; +import { drawNodeLabel, drawEdgeLabel, BAKED_DEFAULT_LABEL_COLOR } from './label_renderers.js'; +import { InteractionManager } from './interactions.js'; +import { BubbleSetLayer } from './bubble_layer.js'; +import { HeatmapLayer } from './heatmap_layer.js'; +import { Minimap } from './minimap.js'; +import { watchDevicePixelRatio } from './dpr_watch.js'; // Rasterization resolution for the SVG shape textures. 512 px keeps shapes // crisp at the ~4x zoom the UI allows (risk #1 in MIGRATION.md). @@ -67,7 +68,7 @@ const LAYOUT_TRANSITION_MAX_NODES = 2000; /** @returns {boolean} whether the user asked the OS to minimize motion */ function prefersReducedMotion() { - return Boolean(window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches); + return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches); } /** @@ -87,7 +88,7 @@ function guardHoverDrawer(drawer, getDraggedNode) { // Defensive: if a future sigma bundle moves the wrapped drawer off the // instance/export we read it from, fail to "no hover" instead of throwing // inside sigma's render loop (which would swallow the error silently). - if (typeof drawer !== "function") return () => {}; + if (typeof drawer !== 'function') return () => {}; return (context, data, settings) => { if (getDraggedNode()) return; // Sigma's drawDiscNodeHover hardcodes a white pill behind the label, so @@ -97,7 +98,7 @@ function guardHoverDrawer(drawer, getDraggedNode) { // (sigma resolves data[attribute] || color). drawer(context, data, { ...settings, - labelColor: { attribute: "labelColor", color: "#000" }, + labelColor: { attribute: 'labelColor', color: '#000' }, }); }; } @@ -126,9 +127,9 @@ function guardHoverDrawer(drawer, getDraggedNode) { */ function buildProgramRegistry(getDraggedNode) { const shapeProgram = nodeImage.createNodeImageProgram({ - size: { mode: "force", value: SHAPE_TEXTURE_RESOLUTION }, - objectFit: "contain", - drawingMode: "background", + size: { mode: 'force', value: SHAPE_TEXTURE_RESOLUTION }, + objectFit: 'contain', + drawingMode: 'background', keepWithinCircle: false, padding: 0, debounceTimeout: ATLAS_REGEN_DEBOUNCE_MS, @@ -164,10 +165,10 @@ function buildProgramRegistry(getDraggedNode) { const borderCircleProgram = createNodeBorderProgram({ borders: [ { - size: { attribute: "borderRatio", defaultValue: 0, mode: "relative" }, - color: { attribute: "borderColor" }, + size: { attribute: 'borderRatio', defaultValue: 0, mode: 'relative' }, + color: { attribute: 'borderColor' }, }, - { size: { fill: true }, color: { attribute: "color" } }, + { size: { fill: true }, color: { attribute: 'color' } }, ], }); // Pie-chart nodes: one program with a fixed slice count, each slice reading @@ -181,7 +182,7 @@ function buildProgramRegistry(getDraggedNode) { defaultColor: pie.DEFAULT_COLOR, offset: { value: 0 }, slices: Array.from({ length: pie.MAX_SLICES }, (_, k) => ({ - color: { attribute: `pieColor${k}`, defaultValue: "#00000000" }, + color: { attribute: `pieColor${k}`, defaultValue: '#00000000' }, value: { attribute: `pieValue${k}` }, })), drawLabel: drawNodeLabel, @@ -203,7 +204,7 @@ function buildProgramRegistry(getDraggedNode) { try { curveFlowProgram = createCurveFlowProgram(curveProgram); } catch (error) { - console.warn("buildProgramRegistry: curve flow overlay disabled:", error); + console.warn('buildProgramRegistry: curve flow overlay disabled:', error); } return { nodeProgramClasses: { @@ -218,8 +219,8 @@ function buildProgramRegistry(getDraggedNode) { EdgeHaloProgram, EdgeRectangleProgram, EdgeFlowProgram, - createEdgeMarkerHeadProgram({ extremity: "source" }), - createEdgeMarkerHeadProgram({ extremity: "target" }), + createEdgeMarkerHeadProgram({ extremity: 'source' }), + createEdgeMarkerHeadProgram({ extremity: 'target' }), ]), curve: curveProgram, styledCurve: createEdgeCompoundProgram( @@ -228,10 +229,10 @@ function buildProgramRegistry(getDraggedNode) { curveProgram, // Flow overlay rides on the body, marker heads stay crisp on top. ...(curveFlowProgram ? [curveFlowProgram] : []), - createEdgeMarkerHeadProgram({ extremity: "source", curved: true }), - createEdgeMarkerHeadProgram({ extremity: "target", curved: true }), + createEdgeMarkerHeadProgram({ extremity: 'source', curved: true }), + createEdgeMarkerHeadProgram({ extremity: 'target', curved: true }), ], - drawCurvedEdgeLabelWithSize, + drawCurvedEdgeLabelWithSize ), }, }; @@ -240,7 +241,7 @@ function buildProgramRegistry(getDraggedNode) { // Curved-edge labels reuse @sigma/edge-curve's drawer, proxying the settings // so per-edge labelSize/labelColor (graph_model attrs) are honoured. const drawCurvedEdgeLabelBase = edgeCurve.createDrawCurvedEdgeLabel( - edgeCurve.DEFAULT_EDGE_CURVE_PROGRAM_OPTIONS, + edgeCurve.DEFAULT_EDGE_CURVE_PROGRAM_OPTIONS ); function drawCurvedEdgeLabelWithSize(context, edgeData, sourceData, targetData, settings) { // The baked #000000 default counts as "no explicit color" so the @@ -254,9 +255,7 @@ function drawCurvedEdgeLabelWithSize(context, edgeData, sourceData, targetData, ? { ...settings, edgeLabelSize: edgeData.labelSize ?? settings.edgeLabelSize, - edgeLabelColor: explicitColor - ? { color: explicitColor } - : settings.edgeLabelColor, + edgeLabelColor: explicitColor ? { color: explicitColor } : settings.edgeLabelColor, } : settings; drawCurvedEdgeLabelBase(context, edgeData, sourceData, targetData, effective); @@ -279,10 +278,10 @@ const MAX_FIT_ZOOM = 4; * @returns {() => void} restore */ function overrideDevicePixelRatio(value) { - const original = Object.getOwnPropertyDescriptor(window, "devicePixelRatio"); - Object.defineProperty(window, "devicePixelRatio", { configurable: true, value }); + const original = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio'); + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value }); return () => { - if (original) Object.defineProperty(window, "devicePixelRatio", original); + if (original) Object.defineProperty(window, 'devicePixelRatio', original); else delete window.devicePixelRatio; }; } @@ -302,10 +301,10 @@ const BLANK_PROBE_SIDE = 32; * @returns {boolean} true when every sampled pixel has alpha 0 */ function bitmapLooksBlank(bitmap) { - const probe = document.createElement("canvas"); + const probe = document.createElement('canvas'); probe.width = BLANK_PROBE_SIDE; probe.height = BLANK_PROBE_SIDE; - const ctx = probe.getContext("2d", { willReadFrequently: true }); + const ctx = probe.getContext('2d', { willReadFrequently: true }); ctx.drawImage(bitmap, 0, 0, BLANK_PROBE_SIDE, BLANK_PROBE_SIDE); const { data } = ctx.getImageData(0, 0, BLANK_PROBE_SIDE, BLANK_PROBE_SIDE); for (let i = 3; i < data.length; i += 4) { @@ -323,7 +322,7 @@ class SigmaAdapter { constructor( cache, container, - { nodeReducer, edgeReducer, elementStates, hoverIds = new Set(), settings = {} }, + { nodeReducer, edgeReducer, elementStates, hoverIds = new Set(), settings = {} } ) { this.cache = cache; this.graph = cache.graphData; @@ -338,7 +337,7 @@ class SigmaAdapter { this.killed = false; const containerEl = - typeof container === "string" ? document.getElementById(container) : container; + typeof container === 'string' ? document.getElementById(container) : container; // Lazy read: this.interactions is assigned AFTER new Sigma(...); the // closure only runs per hover render, by which time it exists. @@ -375,6 +374,19 @@ class SigmaAdapter { this.resizeDebounce = setTimeout(() => this.resize(), RESIZE_DEBOUNCE_MS); }); this.resizeObserver.observe(containerEl); + // Recover from a device-pixel-ratio change (window dragged to a monitor with + // a different DPR). Such a move changes window.devicePixelRatio WITHOUT + // changing the container's CSS box, so sigma.resize() refreshes its + // pixelRatio but early-returns before re-sizing any canvas — leaving the + // WebGL layers at the old backing resolution while the bubble/heatmap + // overlays repaint at the new ratio, so groups land misaligned until a + // sidebar toggle forces a real resize. Force that resize on every DPR change + // (the same recovery the user otherwise triggers manually), then re-render. + this.dprWatchCleanup = watchDevicePixelRatio(() => { + if (this.killed) return; + this.sigma.resize(true); + this.sigma.scheduleRender(); + }); this.interactions = new InteractionManager(this, cache, hoverIds, containerEl); // Created BEFORE BubbleSetLayer on purpose: both register with // beforeLayer: "edges", and the earliest-created canvas sits deepest @@ -406,8 +418,8 @@ class SigmaAdapter { nodeLabels = this.graph.someNode((_, attrs) => attrs.label != null); edgeLabels = this.graph.someEdge((_, attrs) => attrs.label != null); } - this.sigma.setSetting("renderLabels", nodeLabels); - this.sigma.setSetting("renderEdgeLabels", edgeLabels); + this.sigma.setSetting('renderLabels', nodeLabels); + this.sigma.setSetting('renderEdgeLabels', edgeLabels); } // ---------------------------------------------------------------- lifecycle @@ -455,6 +467,7 @@ class SigmaAdapter { this.layoutTransitionCancel = null; clearTimeout(this.resizeDebounce); this.resizeObserver.disconnect(); + this.dprWatchCleanup?.(); this.flowAnimator.destroy(); this.heatmapLayer.destroy(); this.bubbleLayer.destroy(); @@ -520,7 +533,7 @@ class SigmaAdapter { } else if (layout.layoutType) { await this.cache.lm.persistNodePositions(); delete layout.layoutType; - this.cache.ui.debug("Initial layout positions persisted"); + this.cache.ui.debug('Initial layout positions persisted'); } } @@ -586,7 +599,7 @@ class SigmaAdapter { // Graphology is y-up; the app model (and everything persisted from it) // stays y-down — flip on the way out, mirror of the mapper's flip in. ref.style.y = flipY(attrs.y); - ref.style.visibility = attrs.hidden ? "hidden" : "visible"; + ref.style.visibility = attrs.hidden ? 'hidden' : 'visible'; ref.states = [...(this.elementStates.get(ref.id) ?? [])]; views.push(ref); } @@ -602,7 +615,7 @@ class SigmaAdapter { for (const ref of refs) { if (!this.graph.hasEdge(ref.id)) continue; const attrs = this.graph.getEdgeAttributes(ref.id); - ref.style.visibility = attrs.hidden ? "hidden" : "visible"; + ref.style.visibility = attrs.hidden ? 'hidden' : 'visible'; ref.states = [...(this.elementStates.get(ref.id) ?? [])]; views.push(ref); } @@ -630,7 +643,7 @@ class SigmaAdapter { * Triggers the draw choreography (selection UI sync happens there). */ async setElementState(mapOrId, states) { - if (typeof mapOrId === "string") { + if (typeof mapOrId === 'string') { this.#setStates(mapOrId, states ?? []); } else { for (const [id, elementStates] of Object.entries(mapOrId ?? {})) { @@ -662,14 +675,14 @@ class SigmaAdapter { #setHidden(ids, hidden) { if (this.killed) return; - const visibility = hidden ? "hidden" : "visible"; + const visibility = hidden ? 'hidden' : 'visible'; for (const id of Array.isArray(ids) ? ids : [ids]) { if (this.graph.hasNode(id)) { - this.graph.setNodeAttribute(id, "hidden", hidden); + this.graph.setNodeAttribute(id, 'hidden', hidden); const ref = this.cache.nodeRef.get(id); if (ref) ref.style.visibility = visibility; } else if (this.graph.hasEdge(id)) { - this.graph.setEdgeAttribute(id, "hidden", hidden); + this.graph.setEdgeAttribute(id, 'hidden', hidden); const ref = this.cache.edgeRef.get(id); if (ref) ref.style.visibility = visibility; } @@ -700,11 +713,11 @@ class SigmaAdapter { const spanY = Math.abs(p2.y - p1.y) || 1; const scale = Math.max( spanX / Math.max(width - 2 * FIT_PADDING_PX, 1), - spanY / Math.max(height - 2 * FIT_PADDING_PX, 1), + spanY / Math.max(height - 2 * FIT_PADDING_PX, 1) ); const ratio = Math.max(camera.getState().ratio * scale, 1 / MAX_FIT_ZOOM); const center = this.sigma.viewportToFramedGraph( - this.sigma.graphToViewport({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }), + this.sigma.graphToViewport({ x: (minX + maxX) / 2, y: (minY + maxY) / 2 }) ); camera.setState({ x: center.x, y: center.y, ratio }); } @@ -812,7 +825,7 @@ class SigmaAdapter { // Build graph-space targets (y-flipped) only for nodes that still exist. const { targets, count } = buildLayoutTransitionTargets(positionsMap, (id) => - this.graph.hasNode(id), + this.graph.hasNode(id) ); if (count === 0) return; @@ -833,8 +846,8 @@ class SigmaAdapter { this.layoutTransitionCancel = animateNodes( this.graph, targets, - { duration: LAYOUT_TRANSITION_MS, easing: "cubicInOut" }, - resolve, + { duration: LAYOUT_TRANSITION_MS, easing: 'cubicInOut' }, + resolve ); }); this.layoutTransitionCancel = null; @@ -876,7 +889,7 @@ class SigmaAdapter { * plain group key */ getPluginInstance(key) { - const group = key.startsWith("bubbleSetPlugin-") ? key.slice("bubbleSetPlugin-".length) : key; + const group = key.startsWith('bubbleSetPlugin-') ? key.slice('bubbleSetPlugin-'.length) : key; return this.bubbleLayer.getGroupHandle(group); } @@ -914,7 +927,7 @@ class SigmaAdapter { const probedSide = webglMaxCanvasSide(); const maxSide = Math.min( probedSide != null ? probedSide * WEBGL_SAFE_SIDE_FRACTION : Infinity, - MAX_CANVAS_SIDE, + MAX_CANVAS_SIDE ); let appliedScale = clampExportScale(scale, dims, dpr, { maxSide }); const background = this.#stageBackgroundColor(); @@ -931,10 +944,10 @@ class SigmaAdapter { } try { - const out = document.createElement("canvas"); + const out = document.createElement('canvas'); out.width = sigmaImage.width; out.height = sigmaImage.height; - const ctx = out.getContext("2d"); + const ctx = out.getContext('2d'); // Opaque stage background first: export-image renders sigma transparent, // so without this the PNG shows through to whatever the viewer paints // behind alpha (which reads as "dark mode" regardless of the theme). @@ -950,7 +963,7 @@ class SigmaAdapter { // Group labels sit above sigma's node labels on screen (their own canvas // at afterLayer "labels"); composite them last to keep that z-order. this.bubbleLayer.drawExportLabels(ctx, bubbleGroups, dpr * appliedScale); - return { url: out.toDataURL("image/png"), requestedScale: scale, appliedScale }; + return { url: out.toDataURL('image/png'), requestedScale: scale, appliedScale }; } catch (error) { throw new Error(`Graph image export failed: ${error?.message ?? error}`); } finally { @@ -980,7 +993,7 @@ class SigmaAdapter { const restoreDpr = scale !== 1 ? overrideDevicePixelRatio(dpr * scale) : null; let blob; try { - blob = await exportImage.toBlob(this.sigma, { format: "png" }); + blob = await exportImage.toBlob(this.sigma, { format: 'png' }); } finally { restoreDpr?.(); } @@ -996,7 +1009,7 @@ class SigmaAdapter { */ toSVG() { const dims = this.sigma.getDimensions(); - const measureCtx = document.createElement("canvas").getContext("2d"); + const measureCtx = document.createElement('canvas').getContext('2d'); const measureText = (text, font) => { measureCtx.font = font; return measureCtx.measureText(text).width; @@ -1019,13 +1032,13 @@ class SigmaAdapter { * @returns {string} a CSS color usable as a 2d fillStyle */ #stageBackgroundColor() { - const fallback = "#ffffff"; + const fallback = '#ffffff'; try { const el = this.sigma.getContainer?.(); - if (!el || typeof getComputedStyle !== "function") return fallback; + if (!el || typeof getComputedStyle !== 'function') return fallback; const bg = getComputedStyle(el).backgroundColor; // Transparent container → fall back (otherwise we'd re-introduce alpha). - if (!bg || bg === "transparent" || bg === "rgba(0, 0, 0, 0)") return fallback; + if (!bg || bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)') return fallback; return bg; } catch { return fallback; diff --git a/tests/bubble-layer-canvas-css-size.test.js b/tests/bubble-layer-canvas-css-size.test.js new file mode 100644 index 0000000..ed0c973 --- /dev/null +++ b/tests/bubble-layer-canvas-css-size.test.js @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { BubbleSetLayer } from '../src/graph/bubble_layer.js'; + +// ========================================================================== +// Regression: bubble groups land in the WRONG PLACE on a non-primary, +// higher-DPR display until the style panel is toggled. +// +// Root cause: sigma.createCanvasContext() creates the layer canvas with only +// `position:absolute` — no CSS width/height. The layer's own #prepareCanvas +// sizes the BACKING STORE (canvas.width = width*dpr) but never the CSS DISPLAY +// size (canvas.style.width). The only code that sets the CSS size is sigma's +// resize(), which runs once at construction (before this canvas exists) and +// early-returns when dimensions are unchanged. So the canvas displays at its +// backing-store size in CSS px = width*dpr: +// - dpr 1 (primary monitor): width*1 == width -> coincidentally correct +// - dpr 2 (laptop monitor): width*2 -> 2x too large/offset +// Toggling the style panel changes container width -> sigma.resize() runs past +// its early-return -> sets the CSS size -> bubbles snap back into place. +// +// The fix makes the layer own its full canvas sizing (backing store + CSS), +// so it is correct regardless of whether/when sigma.resize() has run. +// ========================================================================== + +function makeCanvas() { + const ctx = { + setTransform: () => {}, + clearRect: () => {}, + save: () => {}, + restore: () => {}, + fill: () => {}, + stroke: () => {}, + beginPath: () => {}, + roundRect: () => {}, + measureText: () => ({ width: 10 }), + fillText: () => {}, + translate: () => {}, + rotate: () => {}, + set fillStyle(_v) {}, + set strokeStyle(_v) {}, + set lineWidth(_v) {}, + set globalAlpha(_v) {}, + set font(_v) {}, + set textAlign(_v) {}, + set textBaseline(_v) {}, + }; + return { + width: 0, + height: 0, + style: {}, + getContext: () => ctx, + remove: () => {}, + }; +} + +function makeSigma(pixelRatio, dims) { + const handlers = new Map(); + const canvas = makeCanvas(); + const labelCanvas = makeCanvas(); + const sigma = { + pixelRatio, + createCanvasContext: () => sigma, + getCanvases: () => ({ bubbleSets: canvas, bubbleSetsLabels: labelCanvas }), + getDimensions: () => ({ width: dims.width, height: dims.height }), + getCamera: () => ({ getState: () => ({ x: 0, y: 0, ratio: 1, angle: 0 }) }), + graphToViewport: (g) => ({ x: dims.width / 2 + g.x, y: dims.height / 2 - g.y }), + viewportToGraph: (v) => ({ x: v.x - dims.width / 2, y: dims.height / 2 - v.y }), + scaleSize: (s) => s, + on: (ev, fn) => handlers.set(ev, fn), + off: () => {}, + emit: (ev) => handlers.get(ev)?.(), + }; + return { sigma, canvas, labelCanvas }; +} + +function makeGraph(nodes) { + const map = new Map(nodes.map((n) => [n.id, { size: 20, hidden: false, ...n }])); + return { hasNode: (id) => map.has(id), getNodeAttributes: (id) => map.get(id) }; +} + +function makeCache() { + return { DEFAULTS: { BUBBLE_GROUP_STYLE: { groupOne: {} } } }; +} + +class FakePath2D { + moveTo() {} + lineTo() {} + closePath() {} +} + +let rafQueue = []; +beforeEach(() => { + rafQueue = []; + globalThis.Path2D = FakePath2D; + globalThis.requestAnimationFrame = (cb) => { + rafQueue.push(cb); + return rafQueue.length; + }; + globalThis.cancelAnimationFrame = () => {}; + if (!globalThis.window) globalThis.window = {}; + globalThis.window.devicePixelRatio = 1; +}); + +function flushRaf() { + const q = rafQueue; + rafQueue = []; + q.forEach((cb) => cb()); +} + +const TRI = [ + { id: 'a', x: -50, y: -50 }, + { id: 'b', x: 50, y: -50 }, + { id: 'c', x: 0, y: 50 }, +]; + +describe('BubbleSetLayer — canvas CSS display size (non-primary DPR bug)', () => { + it('sets the CSS display size to the viewport CSS px, not the backing store, at dpr 2', () => { + const { sigma, canvas, labelCanvas } = makeSigma(2, { width: 800, height: 600 }); + const layer = new BubbleSetLayer({ sigma, graph: makeGraph(TRI) }, makeCache()); + + layer.getGroupHandle('groupOne').update({ members: ['a', 'b', 'c'], label: false }); + flushRaf(); + + // Backing store is scaled by dpr (unchanged behavior). + expect(canvas.width).toBe(800 * 2); + expect(canvas.height).toBe(600 * 2); + + // CSS display size MUST be the logical viewport size so the canvas overlays + // the WebGL layers 1:1. Without this the bubble is drawn dpr* too large and + // lands in the wrong place on a >1 DPR display. + expect(canvas.style.width).toBe('800px'); + expect(canvas.style.height).toBe('600px'); + expect(labelCanvas.style.width).toBe('800px'); + expect(labelCanvas.style.height).toBe('600px'); + }); + + it('keeps the CSS size correct at dpr 1 (primary monitor)', () => { + const { sigma, canvas } = makeSigma(1, { width: 1024, height: 768 }); + const layer = new BubbleSetLayer({ sigma, graph: makeGraph(TRI) }, makeCache()); + + layer.getGroupHandle('groupOne').update({ members: ['a', 'b', 'c'], label: false }); + flushRaf(); + + expect(canvas.width).toBe(1024); + expect(canvas.style.width).toBe('1024px'); + expect(canvas.style.height).toBe('768px'); + }); + + it('keeps the overlay 1:1 when the DPR changes mid-session (monitor A -> B)', () => { + // Monitor A (dpr 1): apply a group, paint correctly. + const { sigma, canvas } = makeSigma(1, { width: 800, height: 600 }); + const layer = new BubbleSetLayer({ sigma, graph: makeGraph(TRI) }, makeCache()); + layer.getGroupHandle('groupOne').update({ members: ['a', 'b', 'c'], label: false }); + flushRaf(); + expect(canvas.style.width).toBe('800px'); + + // Drag to monitor B (dpr 2): sigma.pixelRatio flips but the CSS box is + // unchanged. A repaint must keep the CSS display size at the logical + // viewport px — not the width*2 backing store — or the hull renders 2x too + // large and lands in the wrong place. + sigma.pixelRatio = 2; + sigma.emit('afterRender'); + flushRaf(); + + expect(canvas.width).toBe(800 * 2); + expect(canvas.style.width).toBe('800px'); + expect(canvas.style.height).toBe('600px'); + }); +}); diff --git a/tests/bubble-layer-export.test.js b/tests/bubble-layer-export.test.js index 4b8dd9b..ec0c106 100644 --- a/tests/bubble-layer-export.test.js +++ b/tests/bubble-layer-export.test.js @@ -48,9 +48,9 @@ function makeRecordingCtx() { function makeSigma(camera, dims) { const ctx = makeRecordingCtx(); - const canvas = { width: 0, height: 0, getContext: () => ctx, remove: () => {} }; + const canvas = { width: 0, height: 0, style: {}, getContext: () => ctx, remove: () => {} }; const labelCtx = makeRecordingCtx(); - const labelCanvas = { width: 0, height: 0, getContext: () => labelCtx, remove: () => {} }; + const labelCanvas = { width: 0, height: 0, style: {}, getContext: () => labelCtx, remove: () => {} }; const sigma = { pixelRatio: 1, diff --git a/tests/bubble-layer-malformed.test.js b/tests/bubble-layer-malformed.test.js index 6ac5f7a..e93e7b1 100644 --- a/tests/bubble-layer-malformed.test.js +++ b/tests/bubble-layer-malformed.test.js @@ -23,7 +23,7 @@ const BOWTIE = [{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 10, y: 0 }, { x: 0, y: 10 function makeSigma(camera) { const handlers = new Map(); const ctx = new Proxy({}, { get: () => () => {} }); - const canvas = { width: 0, height: 0, getContext: () => ctx, remove: () => {} }; + const canvas = { width: 0, height: 0, style: {}, getContext: () => ctx, remove: () => {} }; const sigma = { pixelRatio: 1, createCanvasContext: () => sigma, diff --git a/tests/bubble-layer-repro.test.js b/tests/bubble-layer-repro.test.js index 55fc79e..583a5cf 100644 --- a/tests/bubble-layer-repro.test.js +++ b/tests/bubble-layer-repro.test.js @@ -45,7 +45,7 @@ function makeSigma(camera, dims) { set textBaseline(_v) {}, get textBaseline() { return "middle"; }, }; const canvas = { - width: 0, height: 0, + width: 0, height: 0, style: {}, getContext: () => ctx, remove: () => {}, }; @@ -54,7 +54,7 @@ function makeSigma(camera, dims) { const labelTexts = []; const labelCtx = { ...ctx, fillText: (t, x, y) => labelTexts.push({ t, x, y }) }; const labelCanvas = { - width: 0, height: 0, + width: 0, height: 0, style: {}, getContext: () => labelCtx, remove: () => {}, }; diff --git a/tests/dpr-watch.test.js b/tests/dpr-watch.test.js new file mode 100644 index 0000000..9980a77 --- /dev/null +++ b/tests/dpr-watch.test.js @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { watchDevicePixelRatio } from "../src/graph/dpr_watch.js"; + +// ========================================================================== +// Device-pixel-ratio watcher (bubble/heatmap misalignment on monitor move). +// +// Dragging the window to a monitor with a different DPR changes +// window.devicePixelRatio but NOT the container's CSS box, so sigma.resize() +// early-returns without re-sizing the canvases and the overlays drift out of +// alignment until a sidebar toggle forces a real resize. watchDevicePixelRatio +// turns the DPR change into a signal so the adapter can force that resize. +// ========================================================================== + +// Minimal MediaQueryList stub that records its change listeners so a test can +// fire them. matchMedia is not implemented by jsdom, so we install our own. +function installMatchMedia() { + const created = []; + window.matchMedia = vi.fn((query) => { + const listeners = new Set(); + const mql = { + query, + addEventListener: (_ev, fn) => listeners.add(fn), + removeEventListener: (_ev, fn) => listeners.delete(fn), + dispatch: () => [...listeners].forEach((fn) => fn()), + listenerCount: () => listeners.size, + }; + created.push(mql); + return mql; + }); + return created; +} + +let originalDpr; +beforeEach(() => { + originalDpr = Object.getOwnPropertyDescriptor(window, "devicePixelRatio"); +}); +afterEach(() => { + vi.restoreAllMocks(); + if (originalDpr) Object.defineProperty(window, "devicePixelRatio", originalDpr); + else delete window.devicePixelRatio; + delete window.matchMedia; +}); + +function setDpr(value) { + Object.defineProperty(window, "devicePixelRatio", { configurable: true, value }); +} + +describe("watchDevicePixelRatio", () => { + it("arms a resolution-specific media query for the current DPR", () => { + const created = installMatchMedia(); + setDpr(1); + + watchDevicePixelRatio(() => {}); + + expect(window.matchMedia).toHaveBeenCalledWith("(resolution: 1dppx)"); + expect(created.at(-1).listenerCount()).toBe(1); + }); + + it("fires onChange and re-arms against the new DPR when the ratio changes", () => { + const created = installMatchMedia(); + setDpr(1); + const onChange = vi.fn(); + + watchDevicePixelRatio(onChange); + const first = created.at(-1); + + // Simulate dragging to a 2x monitor: the 1dppx query stops matching. + setDpr(2); + first.dispatch(); + + expect(onChange).toHaveBeenCalledTimes(1); + // Re-armed against the new ratio so a subsequent move is also caught. + expect(window.matchMedia).toHaveBeenLastCalledWith("(resolution: 2dppx)"); + expect(created.at(-1)).not.toBe(first); + expect(created.at(-1).listenerCount()).toBe(1); + + // A second move (2x -> 1x) still fires. + setDpr(1); + created.at(-1).dispatch(); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it("cleanup detaches the currently-armed listener", () => { + const created = installMatchMedia(); + setDpr(1); + const onChange = vi.fn(); + + const cleanup = watchDevicePixelRatio(onChange); + const mql = created.at(-1); + expect(mql.listenerCount()).toBe(1); + + cleanup(); + expect(mql.listenerCount()).toBe(0); + + // A change after cleanup must not fire onChange. + mql.dispatch(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("no-ops gracefully when matchMedia is unavailable", () => { + delete window.matchMedia; + setDpr(1); + const cleanup = watchDevicePixelRatio(() => {}); + expect(() => cleanup()).not.toThrow(); + }); +}); diff --git a/tests/heatmap-layer-canvas-css-size.test.js b/tests/heatmap-layer-canvas-css-size.test.js new file mode 100644 index 0000000..cb557a5 --- /dev/null +++ b/tests/heatmap-layer-canvas-css-size.test.js @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { HeatmapLayer } from '../src/graph/heatmap_layer.js'; + +// ========================================================================== +// Regression (mirrors bubble-layer-canvas-css-size.test.js): the heatmap +// custom canvas is registered via sigma.createCanvasContext(), which never sets +// its CSS display size. #prepareCanvas must own the CSS size (not just the +// backing store) so the field overlays the WebGL layers 1:1 on a >1 DPR display +// — otherwise it renders dpr* too large until a panel toggle forces a sigma +// resize. Exercised through the disabled-paint branch, which calls +// #prepareCanvas and returns (no node/offscreen machinery needed). +// ========================================================================== + +function makeCanvas() { + const ctx = { + setTransform: () => {}, + clearRect: () => {}, + }; + return { width: 0, height: 0, style: {}, getContext: () => ctx, remove: () => {} }; +} + +function makeSigma(pixelRatio, dims) { + const handlers = new Map(); + const canvas = makeCanvas(); + const sigma = { + pixelRatio, + createCanvasContext: () => sigma, + getCanvases: () => ({ heatmap: canvas }), + getDimensions: () => ({ width: dims.width, height: dims.height }), + on: (ev, fn) => handlers.set(ev, fn), + off: () => {}, + emit: (ev) => handlers.get(ev)?.(), + }; + return { sigma, canvas }; +} + +let rafQueue = []; +beforeEach(() => { + rafQueue = []; + globalThis.requestAnimationFrame = (cb) => { + rafQueue.push(cb); + return rafQueue.length; + }; + globalThis.cancelAnimationFrame = () => {}; + if (!globalThis.window) globalThis.window = {}; + globalThis.window.devicePixelRatio = 1; +}); + +function flushRaf() { + const q = rafQueue; + rafQueue = []; + q.forEach((cb) => cb()); +} + +describe('HeatmapLayer — canvas CSS display size (non-primary DPR bug)', () => { + it('sets the CSS display size to the viewport CSS px at dpr 2', () => { + const { sigma, canvas } = makeSigma(2, { width: 800, height: 600 }); + const layer = new HeatmapLayer({ sigma }); + + // Heatmap defaults to disabled; force a paint through the disabled branch, + // which clears via #prepareCanvas. + layer.cleared = false; + sigma.emit('afterRender'); + flushRaf(); + + expect(canvas.width).toBe(800 * 2); + expect(canvas.style.width).toBe('800px'); + expect(canvas.style.height).toBe('600px'); + }); + + it('keeps the CSS size correct at dpr 1 (primary monitor)', () => { + const { sigma, canvas } = makeSigma(1, { width: 1024, height: 768 }); + const layer = new HeatmapLayer({ sigma }); + + layer.cleared = false; + sigma.emit('afterRender'); + flushRaf(); + + expect(canvas.width).toBe(1024); + expect(canvas.style.width).toBe('1024px'); + expect(canvas.style.height).toBe('768px'); + }); +}); From 551f919fd8ed76e00743e9100f8bdf3587925ae1 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 17 Jun 2026 16:03:00 +0200 Subject: [PATCH 2/3] fix(graph): make Arrange Selection work and use graphology layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six Arrange Selection tools (shrink/expand/circle/force/grid/random) were silent no-ops: layoutSelectedNodes persisted positions before pushing them to the graph, and persistNodePositions reads via getNodeData(), which re-syncs nodeRef.style from graphology — clobbering the freshly computed coordinates back to their pre-layout values before they ever reached the renderer. Push to the graph first, then persist. Replace the hand-rolled circle/force/random geometry (incl. a ~90-line force sim that clamped toward world-origin instead of the selection centroid) with layoutSelectionSubgraph(): run graphology circular/ forceAtlas2/random on a throwaway subgraph of the selection and recenter on the original centroid. Grid stays manual (no graphology grid) and shrink/expand stay as centroid scaling. Rewrite the regression test with a faithful adapter mock (backing store synced into refs by getNodeData) so it reproduces the clobbering bug; verified it fails on the old persist-before-push order. --- src/graph/layout.js | 340 +++++++++------------------- src/graph/layout_algorithms.js | 96 +++++++- tests/layout-selected-nodes.test.js | 163 ++++++++----- 3 files changed, 302 insertions(+), 297 deletions(-) diff --git a/src/graph/layout.js b/src/graph/layout.js index ed7f56f..ec0d53d 100644 --- a/src/graph/layout.js +++ b/src/graph/layout.js @@ -1,5 +1,5 @@ -import {Popup} from "../utilities/popup.js"; -import {applyNoverlap} from "./layout_algorithms.js"; +import { Popup } from '../utilities/popup.js'; +import { applyNoverlap, layoutSelectionSubgraph } from './layout_algorithms.js'; class GraphLayoutManager { constructor(cache) { @@ -15,13 +15,13 @@ class GraphLayoutManager { async changeLayout() { this.cache.data.selectedLayout = document.getElementById('selectView').value; - await this.cache.ui.showLoading("Switching Workspace", this.cache.data.selectedLayout); + await this.cache.ui.showLoading('Switching Workspace', this.cache.data.selectedLayout); // Pin the overlay up across the whole switch so the inner render's // #postRefresh hideLoading() can't drop it before bubble-sync and // hide-disconnected finish. Released right before the position tween (which // is meant to animate with the overlay clear) and again in finally. this.cache.ui.holdLoading(); - await new Promise(resolve => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout]; @@ -48,7 +48,7 @@ class GraphLayoutManager { // Update filter lock state based on whether this layout has a custom query this.cache.qm.updateQueryTextArea(); - if (currentLayout["query"]) { + if (currentLayout['query']) { this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = true; } else { this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false; @@ -135,7 +135,7 @@ class GraphLayoutManager { } } - nodeUpdates.push({id: nodeID, type: newType, style: newStyle}); + nodeUpdates.push({ id: nodeID, type: newType, style: newStyle }); // Update the nodeRef cache to keep it in sync node.type = newType; @@ -170,7 +170,7 @@ class GraphLayoutManager { newType = edge.originalType || edge.type; } - edgeUpdates.push({id: edgeID, type: newType, style: newStyle}); + edgeUpdates.push({ id: edgeID, type: newType, style: newStyle }); // Update the edgeRef cache to keep it in sync edge.type = newType; @@ -188,7 +188,7 @@ class GraphLayoutManager { // Show dialog with clone vs template options const result = await Popup.layoutCreationDialog(this.cache.DEFAULTS.LAYOUT_INTERNALS); if (!result) { - this.cache.ui.info("Creating workspace canceled"); + this.cache.ui.info('Creating workspace canceled'); return; } @@ -208,7 +208,7 @@ class GraphLayoutManager { for (const [nodeID, node] of this.cache.nodeRef.entries()) { nodeStyles.set(nodeID, { type: node.type, - style: structuredClone(node.style) + style: structuredClone(node.style), }); } @@ -216,13 +216,13 @@ class GraphLayoutManager { for (const [edgeID, edge] of this.cache.edgeRef.entries()) { edgeStyles.set(edgeID, { type: edge.type, - style: structuredClone(edge.style) + style: structuredClone(edge.style), }); } this.cache.data.layouts[result.name] = { internals: null, - layoutType: currentLayout.layoutType, // inherit origin type for re-layout default + layoutType: currentLayout.layoutType, // inherit origin type for re-layout default positions: structuredClone(currentLayout.positions), filters: structuredClone(currentLayout.filters), isCustom: true, @@ -234,14 +234,18 @@ class GraphLayoutManager { }; // Copy query if it exists - if (currentLayout["query"]) { - this.cache.data.layouts[result.name]["query"] = currentLayout["query"]; + if (currentLayout['query']) { + this.cache.data.layouts[result.name]['query'] = currentLayout['query']; } // Copy bubble group props and manual members for (let group of this.cache.bs.traverseBubbleSets()) { - this.cache.data.layouts[result.name][`${group}Props`] = structuredClone(currentLayout[`${group}Props`]); - this.cache.data.layouts[result.name][`${group}ManualMembers`] = structuredClone(currentLayout[`${group}ManualMembers`] || new Set()); + this.cache.data.layouts[result.name][`${group}Props`] = structuredClone( + currentLayout[`${group}Props`] + ); + this.cache.data.layouts[result.name][`${group}ManualMembers`] = structuredClone( + currentLayout[`${group}ManualMembers`] || new Set() + ); } this.cache.ui.info(`Cloned view: ${result.name}`); @@ -268,10 +272,10 @@ class GraphLayoutManager { const proceed = await Popup.confirm( `The "${result.templateType}" layout is computationally intensive and may ` + `take several minutes on ${nodeCount.toLocaleString()} nodes. The UI stays ` + - `blocked until it finishes. Continue?`, + `blocked until it finishes. Continue?` ); if (!proceed) { - this.cache.ui.info("Creating workspace canceled"); + this.cache.ui.info('Creating workspace canceled'); return; } } @@ -279,11 +283,11 @@ class GraphLayoutManager { // Create the layout structure first this.cache.data.layouts[result.name] = { internals: null, - layoutType: result.templateType, // remember origin type for re-layout default - positions: new Map(), // Will be filled after layout - filters: structuredClone(this.cache.data.filterDefaults), // Reset to defaults - isCustom: true, // All layouts are custom (position-based) - query: undefined, // No query + layoutType: result.templateType, // remember origin type for re-layout default + positions: new Map(), // Will be filled after layout + filters: structuredClone(this.cache.data.filterDefaults), // Reset to defaults + isCustom: true, // All layouts are custom (position-based) + query: undefined, // No query hideDisconnectedNodes: false, // Start with default styles nodeStyles: new Map(), @@ -302,7 +306,10 @@ class GraphLayoutManager { document.getElementById('selectView').value = result.name; this.cache.data.selectedLayout = result.name; - await this.cache.ui.showLoading("Creating Workspace", `Applying ${result.templateType} layout`); + await this.cache.ui.showLoading( + 'Creating Workspace', + `Applying ${result.templateType} layout` + ); // Pin the overlay up across the whole creation so the inner render's // #postRefresh hideLoading() can't drop it while the layout (possibly an // expensive off-thread worker), bubble-sync and hide-disconnected are @@ -349,7 +356,10 @@ class GraphLayoutManager { // drops the overlay and clears pendingLayoutTransition. try { // Apply the layout algorithm once - await this.cache.graph.setLayout({type: result.templateType, ...this.cache.DEFAULTS.LAYOUT_INTERNALS[result.templateType]}); + await this.cache.graph.setLayout({ + type: result.templateType, + ...this.cache.DEFAULTS.LAYOUT_INTERNALS[result.templateType], + }); await this.cache.graph.layout(); // Persist the positions so they're stored permanently @@ -392,7 +402,9 @@ class GraphLayoutManager { // Tween the new layout in from the outgoing positions, once the overlay // has cleared (no-op when there was nothing on screen to animate from). if (animateNewWorkspace) { - await this.cache.graph.runLayoutTransition(this.cache.data.layouts[result.name].positions); + await this.cache.graph.runLayoutTransition( + this.cache.data.layouts[result.name].positions + ); } this.cache.ui.info(`Created Workspace: ${result.name} (${result.templateType})`); @@ -408,19 +420,21 @@ class GraphLayoutManager { async removeSelectedLayout() { // Protect the "Default" layout from deletion - if (this.cache.data.selectedLayout === "Default") { - this.cache.ui.error("Cannot delete the Default workspace."); + if (this.cache.data.selectedLayout === 'Default') { + this.cache.ui.error('Cannot delete the Default workspace.'); return; } - const confirmed = await Popup.confirm(`Are you sure you want to delete view "${this.cache.data.selectedLayout}"?`); + const confirmed = await Popup.confirm( + `Are you sure you want to delete view "${this.cache.data.selectedLayout}"?` + ); if (!confirmed) return false; delete this.cache.data.layouts[this.cache.data.selectedLayout]; this.cache.uiComponents.buildDropdownOptions(); // Switch back to Default layout after deletion - document.getElementById('selectView').value = "Default"; + document.getElementById('selectView').value = 'Default'; await this.changeLayout(); } @@ -438,110 +452,31 @@ class GraphLayoutManager { } } - async function arrangeNodesInCircle(radius) { - const numNodes = cache.selectedNodes.length; - let angleStep = (2 * Math.PI) / numNodes; - - let i = 0; - for (const node of await cache.sm.getSelectedNodes()) { - const angle = i * angleStep; - node.style.x = origAvgX + radius * Math.cos(angle); - node.style.y = origAvgY + radius * Math.sin(angle); - i++; - } - } - - async function applyForceLayout(iterations) { - // ----------------------------- - // Updated parameters - // ----------------------------- - const INITIAL_TEMPERATURE = 2.0; // Starting "temperature" for the cooling factor - const COOLING_FACTOR = 0.98; // Slower cooling to allow more spreading - const GRAVITY_STRENGTH = 0.00001; // Reduced gravity so nodes aren't pulled too close - const MAX_DISPLACEMENT = 50; // Higher limit on movement per iteration or remove if needed - - const REPULSION = 20000; // Strong repulsion to push nodes apart - const SPRING_LENGTH = 300; // Ideal distance between connected nodes - const SPRING_STRENGTH = 0.005; // Reduced tension to allow more space - - // ----------------------------- - // Larger initial placement range - // ----------------------------- + // circle/force/random delegate to graphology layouts run on a throwaway + // subgraph of the selection, then recentered on the selection's original + // centroid. Replaces the former hand-rolled circle/physics/scatter geometry. + async function applySubgraphLayout(type) { const nodes = await cache.sm.getSelectedNodes(); - for (const node of nodes) { - node.style.x = Math.random() * 1000 - 500; // Range: [-500, 500] - node.style.y = Math.random() * 1000 - 500; // Range: [-500, 500] - } - - // ----------------------------- - // Main iteration - // ----------------------------- - let temperature = INITIAL_TEMPERATURE; - for (let i = 0; i < iterations; i++) { - // 1) Repulsion between every pair of nodes - for (let a = 0; a < nodes.length; a++) { - for (let b = a + 1; b < nodes.length; b++) { - const dx = nodes[b].style.x - nodes[a].style.x; - const dy = nodes[b].style.y - nodes[a].style.y; - const dist = Math.sqrt(dx * dx + dy * dy) + 0.01; // Avoid dividing by zero - - const force = REPULSION / (dist * dist); // 1 / distance^2 - const fx = force * (dx / dist); - const fy = force * (dy / dist); - - // Apply forces (scaled by temperature) - nodes[a].style.x -= fx * temperature; - nodes[a].style.y -= fy * temperature; - nodes[b].style.x += fx * temperature; - nodes[b].style.y += fy * temperature; - } - } + if (nodes.length === 0) return; - // 2) Spring forces (edges) - for (const edge of await cache.graph.getEdgeData()) { - const {source, target} = edge; - if (cache.selectedNodes.includes(source) && cache.selectedNodes.includes(target)) { - const nodeA = nodes.find((n) => n.id === source); - const nodeB = nodes.find((n) => n.id === target); - if (nodeA && nodeB) { - const dx = nodeB.style.x - nodeA.style.x; - const dy = nodeB.style.y - nodeA.style.y; - const dist = Math.sqrt(dx * dx + dy * dy) + 0.01; - - // (currentDistance - idealDistance) - const force = (dist - SPRING_LENGTH) * SPRING_STRENGTH; - const fx = force * (dx / dist); - const fy = force * (dy / dist); - - // Apply (scaled by temperature) - nodeA.style.x += fx * temperature; - nodeA.style.y += fy * temperature; - nodeB.style.x -= fx * temperature; - nodeB.style.y -= fy * temperature; - } - } - } + const selectedIds = new Set(nodes.map((n) => n.id)); + const edges = (await cache.graph.getEdgeData()) + .filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target)) + .map((e) => ({ source: e.source, target: e.target })); - // 3) Gravity / Centering - // With reduced gravity, nodes won't cluster too tightly - for (const node of nodes) { - node.style.x += -node.style.x * GRAVITY_STRENGTH * temperature; - node.style.y += -node.style.y * GRAVITY_STRENGTH * temperature; - } + const positions = layoutSelectionSubgraph( + nodes.map((n) => ({ id: n.id, x: n.style.x, y: n.style.y, size: n.style.size })), + edges, + type, + { x: origAvgX, y: origAvgY } + ); - // 4) Limit maximum displacement (optional) - // Increase or remove if you don't want clamping - for (const node of nodes) { - const dist = Math.sqrt(node.style.x * node.style.x + node.style.y * node.style.y); - if (dist > MAX_DISPLACEMENT) { - const ratio = MAX_DISPLACEMENT / dist; - node.style.x *= ratio; - node.style.y *= ratio; - } + for (const node of nodes) { + const pos = positions.get(node.id); + if (pos) { + node.style.x = pos.x; + node.style.y = pos.y; } - - // 5) Cool down temperature for next iteration - temperature *= COOLING_FACTOR; } } @@ -569,110 +504,46 @@ class GraphLayoutManager { } } - async function applyRandomLayout() { - const nodes = await cache.sm.getSelectedNodes(); - if (nodes.length < 2) return; - - // ALWAYS use the fixed original bounding‐box: - const centerX = origCenterX; - const centerY = origCenterY; - const width = origWidth; - const height = origHeight; - - // If you really want rotation (see note below), be aware a rotated - // rectangle has a larger AABB—but we’re not recomputing the AABB, - // so the outer box stays fixed at origWidth × origHeight. - const angle = Math.random() * 2 * Math.PI; - - // Pick two anchors to go to two opposite corners of the ROTATED rectangle - // (but the *axis‐aligned* bounding-box of that rotated rectangle is still - // held “virtually” at origWidth×origHeight; we do not “re‐read” min/max from it). - const [anchor1, anchor2] = getRandomElements(nodes, 2); - - // Rotate those two anchors to the “corners” of a width×height box at random angle: - // corner1 ( +width/2, +height/2 ) after rotation; corner2 ( -width/2, -height/2 ). - anchor1.style.x = centerX + (width / 2) * Math.cos(angle) - (height / 2) * Math.sin(angle); - anchor1.style.y = centerY + (width / 2) * Math.sin(angle) + (height / 2) * Math.cos(angle); - - anchor2.style.x = centerX - (width / 2) * Math.cos(angle) + (height / 2) * Math.sin(angle); - anchor2.style.y = centerY - (width / 2) * Math.sin(angle) - (height / 2) * Math.cos(angle); - - // Now scatter the rest uniformly inside that same rotated box: - for (const node of nodes) { - if (node === anchor1 || node === anchor2) continue; - - // pick a random (u,v) in [–0.5..+0.5] × [–0.5..+0.5] - const u = Math.random() - 0.5; - const v = Math.random() - 0.5; - - // scale to [–width/2..+width/2], [–height/2..+height/2] - const dx = u * width; - const dy = v * height; - - // rotate (dx,dy) around origin by “angle” - node.style.x = centerX + dx * Math.cos(angle) - dy * Math.sin(angle); - node.style.y = centerY + dx * Math.sin(angle) + dy * Math.cos(angle); - } - } - - function getRandomElements(array, n) { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled.slice(0, n); - } - const sel = await cache.sm.getSelectedNodes(); if (sel.length == 0) return; - const coords = sel.map(n => ({x: n.style.x, y: n.style.y})); + const coords = sel.map((n) => ({ x: n.style.x, y: n.style.y })); const origAvgX = coords.reduce((sum, pos) => sum + pos.x, 0) / coords.length; const origAvgY = coords.reduce((sum, pos) => sum + pos.y, 0) / coords.length; - const origMinX = Math.min(...coords.map((pos) => pos.x)); - const origMaxX = Math.max(...coords.map((pos) => pos.x)); - const origMinY = Math.min(...coords.map((pos) => pos.y)); - const origMaxY = Math.max(...coords.map((pos) => pos.y)); - - const origCenterX = (origMinX + origMaxX) / 2; - const origCenterY = (origMinY + origMaxY) / 2; - const origWidth = origMaxX - origMinX; - const origHeight = origMaxY - origMinY; - const eventLabels = { - "shrink": "Shrink selected nodes toward their center", - "expand": "Expand selected nodes outward from their center", - "circle": "Arrange selected nodes evenly in a circular layout", - "force": "Apply a force-directed layout to selected nodes", - "grid": "Align selected nodes in a uniform grid layout", - "random": "Distribute selected nodes randomly while preserving the original layout bounds" + shrink: 'Shrink selected nodes toward their center', + expand: 'Expand selected nodes outward from their center', + circle: 'Arrange selected nodes evenly in a circular layout', + force: 'Apply a force-directed layout to selected nodes', + grid: 'Align selected nodes in a uniform grid layout', + random: 'Distribute selected nodes randomly around their center', }; const layoutActions = { - "shrink": () => groupOrSpreadSelectedNodes(0.5), - "expand": () => groupOrSpreadSelectedNodes(2), - "circle": () => arrangeNodesInCircle(100), - "force": () => applyForceLayout(150), - "grid": () => applyGridLayout(), - "random": () => applyRandomLayout(), - } + shrink: () => groupOrSpreadSelectedNodes(0.5), + expand: () => groupOrSpreadSelectedNodes(2), + circle: () => applySubgraphLayout('circular'), + force: () => applySubgraphLayout('force'), + grid: () => applyGridLayout(), + random: () => applySubgraphLayout('random'), + }; await layoutActions[action](); - await this.persistNodePositions(); - // Push new positions to G6 — without this, mutated node.style.x/y on cached - // refs don't reach the graph's internal data store, so the layout only - // becomes visible once the selection state changes and forces a re-read. - const movedNodes = await cache.sm.getSelectedNodes(); - if (movedNodes.length > 0) { + // Push the freshly mutated positions to the graph BEFORE anything reads it + // back. getNodeData() (used by persistNodePositions) re-syncs nodeRef.style + // from graphology, so persisting first would clobber these positions with + // the pre-layout ones — that bug made every Arrange button a silent no-op. + // `sel` holds the same node refs the layout fns just mutated. + if (sel.length > 0) { await this.cache.graph.updateNodeData( - movedNodes.map(n => ({id: n.id, style: {x: n.style.x, y: n.style.y}})) + sel.map((n) => ({ id: n.id, style: { x: n.style.x, y: n.style.y } })) ); } + await this.persistNodePositions(); await this.handleLayoutChangeLoadingEvent(action, eventLabels[action]); } @@ -692,8 +563,10 @@ class GraphLayoutManager { if (!graph || graph.order < 2) return; applyNoverlap(graph); await this.persistNodePositions(); - await this.handleLayoutChangeLoadingEvent("Remove overlaps", - "Spread overlapping nodes apart minimally"); + await this.handleLayoutChangeLoadingEvent( + 'Remove overlaps', + 'Spread overlapping nodes apart minimally' + ); } /** @@ -722,13 +595,13 @@ class GraphLayoutManager { warningThreshold: this.cache.DEFAULTS.LAYOUT_NODE_WARNING_THRESHOLD, }); if (!result) { - this.cache.ui.info("Re-layout canceled"); + this.cache.ui.info('Re-layout canceled'); return; } const layoutType = result.templateType; - await this.cache.ui.showLoading("Re-layouting Workspace", `Applying ${layoutType} layout`); + await this.cache.ui.showLoading('Re-layouting Workspace', `Applying ${layoutType} layout`); // Pin the overlay up across the whole re-layout so the inner render's // hideLoading() can't drop it while the layout (possibly an expensive // off-thread worker) and bubble-sync are still running. Released right @@ -740,12 +613,15 @@ class GraphLayoutManager { const fromPositions = new Map(); this.cache.graphData?.forEachNode((id, attrs) => { if (Number.isFinite(attrs.x) && Number.isFinite(attrs.y)) { - fromPositions.set(id, {x: attrs.x, y: attrs.y}); + fromPositions.set(id, { x: attrs.x, y: attrs.y }); } }); try { - await this.cache.graph.setLayout({type: layoutType, ...this.cache.DEFAULTS.LAYOUT_INTERNALS[layoutType]}); + await this.cache.graph.setLayout({ + type: layoutType, + ...this.cache.DEFAULTS.LAYOUT_INTERNALS[layoutType], + }); await this.cache.graph.layout(); // Remember the chosen type so the next re-layout (and reload) defaults to it. @@ -761,7 +637,7 @@ class GraphLayoutManager { if (animate) { for (const [id, p] of fromPositions) { if (this.cache.graphData.hasNode(id)) { - this.cache.graphData.mergeNodeAttributes(id, {x: p.x, y: p.y}); + this.cache.graphData.mergeNodeAttributes(id, { x: p.x, y: p.y }); } } this.cache.graph.pendingLayoutTransition = true; @@ -800,8 +676,8 @@ class GraphLayoutManager { id: node.id, style: { x: node.style.x, - y: node.style.y - } + y: node.style.y, + }, }); } return posCopy; @@ -814,19 +690,21 @@ class GraphLayoutManager { } async persistNodePositions() { - this.cache.ui.debug("PERSISTING NODE POSITIONS .."); + this.cache.ui.debug('PERSISTING NODE POSITIONS ..'); for (const node of await this.cache.graph.getNodeData()) { - this.cache.data.layouts[this.cache.data.selectedLayout].positions.set(node.id, {style: {x: node.style.x, y: node.style.y}}); + this.cache.data.layouts[this.cache.data.selectedLayout].positions.set(node.id, { + style: { x: node.style.x, y: node.style.y }, + }); } } createDefaultLayout(key, overridePositionsFromExcel = false) { const defLayout = { - layoutType: key, // Store layout algorithm type for initial render only + layoutType: key, // Store layout algorithm type for initial render only internals: null, positions: new Map(), filters: structuredClone(this.cache.data.filterDefaults), - isCustom: true, // All layouts are position-based + isCustom: true, // All layouts are position-based query: undefined, hideDisconnectedNodes: false, // Per-view styles @@ -838,7 +716,7 @@ class GraphLayoutManager { if (overridePositionsFromExcel) { // applies given coordinates from Excel template; remaining positions will be force layouted for (const [nodeID, positions] of this.cache.nodePositionsFromExcelImport) { - defLayout.positions.set(nodeID, {style: {x: positions.x, y: positions.y}}); + defLayout.positions.set(nodeID, { style: { x: positions.x, y: positions.y } }); } defLayout.layoutType = this.cache.DEFAULTS.LAYOUT; } @@ -852,7 +730,9 @@ class GraphLayoutManager { async nodePositionsAreInSync() { for (const node of await this.cache.graph.getNodeData()) { - const existing = this.cache.data.layouts[this.cache.data.selectedLayout].positions?.get(node.id); + const existing = this.cache.data.layouts[this.cache.data.selectedLayout].positions?.get( + node.id + ); if (!existing) continue; if (node.style.x !== existing.style.x || node.style.y !== existing.style.y) { return false; @@ -862,4 +742,4 @@ class GraphLayoutManager { } } -export {GraphLayoutManager} \ No newline at end of file +export { GraphLayoutManager }; diff --git a/src/graph/layout_algorithms.js b/src/graph/layout_algorithms.js index c15de90..681a7f1 100644 --- a/src/graph/layout_algorithms.js +++ b/src/graph/layout_algorithms.js @@ -8,6 +8,7 @@ * vitest. */ import { + Graph, circular, circlepack, random, @@ -18,8 +19,8 @@ import { ConcentricLayout, MDSLayout, DagreLayout, -} from "../lib/graphology.bundle.mjs"; -import { LAYOUT_WORKER_SOURCE } from "../lib/layout_worker_source.js"; +} from '../lib/graphology.bundle.mjs'; +import { LAYOUT_WORKER_SOURCE } from '../lib/layout_worker_source.js'; const FORCE_ITERATIONS = 200; const GRID_SPACING = 100; @@ -57,7 +58,7 @@ async function executeForceAnimated(graph, Supervisor) { animatingGraphs.add(graph); const budgetMs = Math.min( FORCE_ANIMATE_MAX_MS, - FORCE_ANIMATE_BASE_MS + graph.order * FORCE_ANIMATE_PER_NODE_MS, + FORCE_ANIMATE_BASE_MS + graph.order * FORCE_ANIMATE_PER_NODE_MS ); let layout = null; try { @@ -125,7 +126,7 @@ let layoutWorkerUrl = null; function defaultLayoutWorkerFactory() { if (layoutWorkerUrl === null) { const blob = new Blob([LAYOUT_WORKER_SOURCE], { - type: "application/javascript", + type: 'application/javascript', }); layoutWorkerUrl = URL.createObjectURL(blob); } @@ -157,7 +158,7 @@ async function executeAntvLayoutWorker(graph, type, options, negateY, workerFact } }; worker.onerror = (event) => { - reject(new Error(event.message || "Layout worker failed")); + reject(new Error(event.message || 'Layout worker failed')); }; worker.postMessage({ type, options, nodes, edges }); }); @@ -189,6 +190,78 @@ export function applyNoverlap(graph) { }); } +/** + * Lay out a *subset* of nodes in isolation and recenter the result on a target + * point. Used by the "Arrange Selection" tools: build a throwaway graphology + * graph from the selection (plus the edges internal to it), run a synchronous + * graphology layout, then translate every node so the subset's new centroid + * lands on `center`. This reuses the same battle-tested layouts as the + * whole-graph path instead of hand-rolled geometry. + * + * Synchronous on purpose: a selection subgraph is tiny, so the worker-supervised + * animated FA2 path (executeBaseLayout) would be pure overhead. y-orientation is + * irrelevant — these layouts synthesize fresh positions, and the caller applies + * the recentered output directly in app-model (y-down) space. + * + * @param {Array<{id: string, x?: number, y?: number, size?: number}>} nodes + * selected nodes with their current positions (FA2 seed) and sizes. + * @param {Array<{source: string, target: string}>} edges edges whose endpoints + * are both in the selection (ignored by circular/random, used by force). + * @param {"force"|"circular"|"random"} type layout to apply. + * @param {{x: number, y: number}} center target centroid for the arrangement. + * @returns {Map} id → recentered position. + */ +export function layoutSelectionSubgraph(nodes, edges, type, center) { + const positions = new Map(); + if (!nodes?.length) return positions; + + const graph = new Graph(); + nodes.forEach((node, i) => { + if (graph.hasNode(node.id)) return; + // Seed from current positions; a tiny per-index offset guarantees distinct + // coordinates so FA2 never sees coincident nodes (zero-distance → NaN). + graph.addNode(node.id, { + x: (Number.isFinite(node.x) ? node.x : 0) + i * 1e-3, + y: (Number.isFinite(node.y) ? node.y : 0) + i * 1e-3, + size: Number.isFinite(node.size) ? node.size : 1, + }); + }); + for (const edge of edges ?? []) { + if ( + graph.hasNode(edge.source) && + graph.hasNode(edge.target) && + !graph.hasEdge(edge.source, edge.target) + ) { + graph.addEdge(edge.source, edge.target); + } + } + + if (type === 'circular') { + circular.assign(graph, { scale: Math.max(100, 12 * Math.sqrt(graph.order)) }); + } else if (type === 'random') { + random.assign(graph, { center: 0, scale: Math.max(200, 24 * Math.sqrt(graph.order)) }); + } else if (graph.order >= 2) { + // 'force' → forceAtlas2 (needs ≥2 nodes for inferSettings). + forceAtlas2.assign(graph, { + iterations: FORCE_ITERATIONS, + settings: forceAtlas2.inferSettings(graph), + }); + } + + let sumX = 0; + let sumY = 0; + graph.forEachNode((id, attrs) => { + sumX += attrs.x; + sumY += attrs.y; + }); + const avgX = sumX / graph.order; + const avgY = sumY / graph.order; + graph.forEachNode((id, attrs) => { + positions.set(id, { x: center.x + (attrs.x - avgX), y: center.y + (attrs.y - avgY) }); + }); + return positions; +} + /** * Execute a layout spec against a graphology graph, assigning x/y per node. * @param {import('graphology').default} graph @@ -211,14 +284,14 @@ export async function executeLayout(graph, spec, testOverrides = {}) { /** The pre-post-pass layout dispatch — see executeLayout. */ async function executeBaseLayout(graph, spec, testOverrides) { const { type, ...options } = spec; - if (type === "circular") { + if (type === 'circular') { // graphology's circular ignores the G6-era startRadius/endRadius options. circular.assign(graph, { scale: Math.max(100, 12 * Math.sqrt(graph.order)), }); return; } - if (type === "grid") { + if (type === 'grid') { const cols = Math.ceil(Math.sqrt(graph.order)) || 1; let i = 0; graph.forEachNode((id) => { @@ -230,14 +303,14 @@ async function executeBaseLayout(graph, spec, testOverrides) { }); return; } - if (type === "circlepack") { + if (type === 'circlepack') { // d3-hierarchy circle packing; each node's circle radius is its `size` // attribute (sigma radius, set by graph_model's node mapper). center:0 // packs the cluster around the origin. Assigns x/y in place. circlepack.assign(graph, { center: 0 }); return; } - if (type === "random") { + if (type === 'random') { // Uniform scatter around the origin. center:0 shifts random's [0,scale) // range to [-scale/2, scale/2); scale tracks node count so the cloud grows // with the graph instead of collapsing toward a point. @@ -256,7 +329,7 @@ async function executeBaseLayout(graph, spec, testOverrides) { // positions, just on-thread. const workerFactory = testOverrides.LayoutWorkerFactory ?? - (typeof Worker === "undefined" ? null : defaultLayoutWorkerFactory); + (typeof Worker === 'undefined' ? null : defaultLayoutWorkerFactory); if (workerFactory) { await executeAntvLayoutWorker(graph, type, options, antv.negateY, workerFactory); } else { @@ -268,8 +341,7 @@ async function executeBaseLayout(graph, spec, testOverrides) { // Electron renderer) the FA2 worker supervisor animates the layout live; // under node (vitest) Worker is undefined → deterministic synchronous path. const Supervisor = - testOverrides.ForceSupervisor ?? - (typeof Worker === "undefined" ? null : FA2Layout); + testOverrides.ForceSupervisor ?? (typeof Worker === 'undefined' ? null : FA2Layout); if (Supervisor) { await executeForceAnimated(graph, Supervisor); return; diff --git a/tests/layout-selected-nodes.test.js b/tests/layout-selected-nodes.test.js index 8f69f96..4ccbed3 100644 --- a/tests/layout-selected-nodes.test.js +++ b/tests/layout-selected-nodes.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { Graph } from "../src/lib/graphology.bundle.mjs"; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Graph } from '../src/lib/graphology.bundle.mjs'; // ========================================================================== // Regression: Arrange Selection actions must push the mutated node positions @@ -9,38 +9,66 @@ import { Graph } from "../src/lib/graphology.bundle.mjs"; // ========================================================================== // Stub Popup before importing layout.js (layout.js imports it eagerly). -vi.mock("../src/utilities/popup.js", () => ({ Popup: {} })); +vi.mock('../src/utilities/popup.js', () => ({ Popup: {} })); -const { GraphLayoutManager } = await import("../src/graph/layout.js").then(m => ({ +const { GraphLayoutManager } = await import('../src/graph/layout.js').then((m) => ({ GraphLayoutManager: m.default || m.GraphLayoutManager || Object.values(m)[0], })); function makeNode(id, x, y) { - return { id, style: { x, y } }; + return { id, style: { x, y }, states: ['selected'] }; } +// Faithful mock of the sigma adapter's destructive read: getNodeData() syncs +// each ref's style.x/y FROM the backing graphology store (mirroring +// sigma_adapter.getNodeData). updateNodeData() writes the payload back INTO the +// store. This is what makes the regression observable — the old layout.js +// persisted (via getNodeData) before pushing positions, so the read clobbered +// the freshly computed coordinates back to their pre-layout values. A mock that +// returns the mutated refs verbatim (no backing store) hides that bug entirely. function createMockCache(nodes, edges = []) { - const nodeRef = new Map(nodes.map(n => [n.id, n])); + const nodeRef = new Map(nodes.map((n) => [n.id, n])); const positions = new Map(); + const store = new Map(nodes.map((n) => [n.id, { x: n.style.x, y: n.style.y }])); + + const getNodeData = vi.fn(async () => { + for (const node of nodeRef.values()) { + const pos = store.get(node.id); + if (pos) { + node.style.x = pos.x; + node.style.y = pos.y; + } + } + return [...nodeRef.values()]; + }); const cache = { nodeRef, - selectedNodes: nodes.map(n => n.id), + store, + selectedNodes: nodes.map((n) => n.id), selectedEdges: [], layoutChanged: false, data: { - selectedLayout: "default", + selectedLayout: 'default', layouts: { default: { positions }, }, }, sm: { - getSelectedNodes: async () => nodes, + // Mirror selection.js: read live node data, filter to the selected set. + getSelectedNodes: async () => + (await getNodeData()).filter((n) => n.states?.includes('selected')), }, graph: { - updateNodeData: vi.fn(), + updateNodeData: vi.fn(async (payload) => { + for (const item of payload ?? []) { + const ref = nodeRef.get(item.id); + if (ref && item.style) Object.assign(ref.style, item.style); + if (item.style) store.set(item.id, { x: item.style.x, y: item.style.y }); + } + }), getEdgeData: async () => edges, - getNodeData: async () => nodes, + getNodeData, }, ui: { showLoading: vi.fn(async () => {}), @@ -53,70 +81,95 @@ function createMockCache(nodes, edges = []) { return cache; } -describe("layoutSelectedNodes — syncs positions to the graph", () => { +describe('layoutSelectedNodes — syncs positions to the graph', () => { let cache, lm; + const initial = { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, c: { x: 0, y: 100 } }; + beforeEach(() => { - cache = createMockCache([ - makeNode("a", 0, 0), - makeNode("b", 100, 0), - makeNode("c", 0, 100), - ]); + cache = createMockCache([makeNode('a', 0, 0), makeNode('b', 100, 0), makeNode('c', 0, 100)]); lm = new GraphLayoutManager(cache); }); - for (const action of ["shrink", "expand", "circle", "force", "grid", "random"]) { - it(`calls graph.updateNodeData with the post-${action} positions`, async () => { + for (const action of ['shrink', 'expand', 'circle', 'force', 'grid', 'random']) { + it(`pushes the post-${action} positions through to the graph store`, async () => { await lm.layoutSelectedNodes(action); - expect(cache.graph.updateNodeData).toHaveBeenCalledTimes(1); - - const payload = cache.graph.updateNodeData.mock.calls[0][0]; - expect(payload).toHaveLength(3); - - // Every payload entry must carry the live x/y from the cached node ref. - // This is what the bug guards: if updateNodeData is skipped, G6 never - // sees these coordinates until the selection changes. - for (const entry of payload) { - const node = cache.nodeRef.get(entry.id); - expect(entry.style.x).toBe(node.style.x); - expect(entry.style.y).toBe(node.style.y); - expect(Number.isFinite(entry.style.x)).toBe(true); - expect(Number.isFinite(entry.style.y)).toBe(true); + expect(cache.graph.updateNodeData).toHaveBeenCalled(); + + // The backing store is the renderer's source of truth. Every node must + // land at a finite coordinate, and at least one must have actually moved + // off its pre-layout spot — the old persist-before-push order left the + // store untouched (silent no-op), which this assertion catches. + let moved = false; + for (const id of ['a', 'b', 'c']) { + const pos = cache.store.get(id); + expect(Number.isFinite(pos.x)).toBe(true); + expect(Number.isFinite(pos.y)).toBe(true); + if (pos.x !== initial[id].x || pos.y !== initial[id].y) moved = true; } + expect(moved).toBe(true); }); } + it('grid arranges the selection on a centroid-aligned lattice', async () => { + await lm.layoutSelectedNodes('grid'); + + // Centroid of (0,0),(100,0),(0,100) is (33.33, 33.33); 3 nodes → 2 cols, + // 2 rows, 100px spacing, so the lattice spans 100×100 centered on it. + const centroid = { x: 100 / 3, y: 100 / 3 }; + const expected = [ + { x: centroid.x - 50, y: centroid.y - 50 }, + { x: centroid.x + 50, y: centroid.y - 50 }, + { x: centroid.x - 50, y: centroid.y + 50 }, + ]; + const got = ['a', 'b', 'c'].map((id) => cache.store.get(id)); + for (let i = 0; i < expected.length; i++) { + expect(got[i].x).toBeCloseTo(expected[i].x, 5); + expect(got[i].y).toBeCloseTo(expected[i].y, 5); + } + }); + + it('circle preserves the selection centroid', async () => { + await lm.layoutSelectedNodes('circle'); + + const avg = ['a', 'b', 'c'] + .map((id) => cache.store.get(id)) + .reduce((s, p) => ({ x: s.x + p.x / 3, y: s.y + p.y / 3 }), { x: 0, y: 0 }); + expect(avg.x).toBeCloseTo(100 / 3, 4); + expect(avg.y).toBeCloseTo(100 / 3, 4); + }); + it("persists the new positions to the active layout's position map", async () => { - await lm.layoutSelectedNodes("grid"); + await lm.layoutSelectedNodes('grid'); const stored = cache.data.layouts.default.positions; expect(stored.size).toBe(3); - for (const id of ["a", "b", "c"]) { - const node = cache.nodeRef.get(id); - expect(stored.get(id).style.x).toBe(node.style.x); - expect(stored.get(id).style.y).toBe(node.style.y); + for (const id of ['a', 'b', 'c']) { + const pos = cache.store.get(id); + expect(stored.get(id).style.x).toBeCloseTo(pos.x, 5); + expect(stored.get(id).style.y).toBeCloseTo(pos.y, 5); } }); - it("triggers a redraw via gcm.decideToRenderOrDraw", async () => { - await lm.layoutSelectedNodes("circle"); + it('triggers a redraw via gcm.decideToRenderOrDraw', async () => { + await lm.layoutSelectedNodes('circle'); expect(cache.gcm.decideToRenderOrDraw).toHaveBeenCalledTimes(1); expect(cache.layoutChanged).toBe(true); }); - it("is a no-op when nothing is selected", async () => { + it('is a no-op when nothing is selected', async () => { cache.selectedNodes = []; cache.sm.getSelectedNodes = async () => []; - await lm.layoutSelectedNodes("force"); + await lm.layoutSelectedNodes('force'); expect(cache.graph.updateNodeData).not.toHaveBeenCalled(); expect(cache.gcm.decideToRenderOrDraw).not.toHaveBeenCalled(); }); }); -describe("removeNodeOverlaps — noverlap on the live graphology model", () => { +describe('removeNodeOverlaps — noverlap on the live graphology model', () => { let cache, lm; /** Mirror the adapter: getNodeData syncs nodeRef styles from graphology. */ @@ -133,48 +186,48 @@ describe("removeNodeOverlaps — noverlap on the live graphology model", () => { } beforeEach(() => { - cache = createMockCache([makeNode("a", 0, 0), makeNode("b", 0, 0)]); + cache = createMockCache([makeNode('a', 0, 0), makeNode('b', 0, 0)]); lm = new GraphLayoutManager(cache); }); - it("separates overlapping nodes, persists positions and redraws", async () => { + it('separates overlapping nodes, persists positions and redraws', async () => { // Arrange: two same-spot nodes on the live graphology instance. const graphData = new Graph(); - graphData.addNode("a", { x: 0, y: 0, size: 10 }); - graphData.addNode("b", { x: 0, y: 0, size: 10 }); + graphData.addNode('a', { x: 0, y: 0, size: 10 }); + graphData.addNode('b', { x: 0, y: 0, size: 10 }); wireGraphData([...cache.nodeRef.values()], graphData); // Act await lm.removeNodeOverlaps(); // Assert: graphology positions separated by ≥ combined size + margin. - const a = graphData.getNodeAttributes("a"); - const b = graphData.getNodeAttributes("b"); + const a = graphData.getNodeAttributes('a'); + const b = graphData.getNodeAttributes('b'); expect(Math.hypot(a.x - b.x, a.y - b.y)).toBeGreaterThanOrEqual(10 + 10 + 5); // Moved positions are persisted to the active layout's position map. const stored = cache.data.layouts.default.positions; - expect(stored.get("a").style).toEqual({ x: a.x, y: a.y }); - expect(stored.get("b").style).toEqual({ x: b.x, y: b.y }); + expect(stored.get('a').style).toEqual({ x: a.x, y: a.y }); + expect(stored.get('b').style).toEqual({ x: b.x, y: b.y }); // And the standard layout-change pipeline ran. expect(cache.gcm.decideToRenderOrDraw).toHaveBeenCalledTimes(1); expect(cache.layoutChanged).toBe(true); }); - it("is a no-op without a graph model or with fewer than two nodes", async () => { + it('is a no-op without a graph model or with fewer than two nodes', async () => { // Arrange + Act: no graphData at all. cache.graphData = null; await lm.removeNodeOverlaps(); // Arrange + Act: single-node graph. const single = new Graph(); - single.addNode("a", { x: 3, y: 4, size: 10 }); - wireGraphData([cache.nodeRef.get("a")], single); + single.addNode('a', { x: 3, y: 4, size: 10 }); + wireGraphData([cache.nodeRef.get('a')], single); await lm.removeNodeOverlaps(); // Assert: untouched position, no persist, no redraw. - expect(single.getNodeAttributes("a")).toMatchObject({ x: 3, y: 4 }); + expect(single.getNodeAttributes('a')).toMatchObject({ x: 3, y: 4 }); expect(cache.data.layouts.default.positions.size).toBe(0); expect(cache.gcm.decideToRenderOrDraw).not.toHaveBeenCalled(); }); From 621983b01a7d5ae5ff32a9e8ce0b292a67ca96f9 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 17 Jun 2026 16:17:20 +0200 Subject: [PATCH 3/3] chore(release): bump version to 1.15.1 Patch release covering the two bug fixes since v1.15.0: - Arrange Selection tools no longer silent no-ops - bubble/heatmap overlays stay aligned across DPR changes --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- src/config.js | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6e8054..cba990a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.15.1 — 2026-06-17 + +Saved graph files load unchanged — this release is bug fixes only. + +### Fixes + +* **Arrange Selection tools now work.** All six tools (shrink/expand/circle/force/grid/random) were silent no-ops: positions were persisted before being pushed to the graph, and the persist path re-synced node state from graphology — clobbering the freshly computed coordinates before they reached the renderer. Positions are now pushed to the graph first, then persisted. The circle/force/random geometry is also rebuilt on graphology layouts (circular/forceAtlas2/random) run over a subgraph of the selection and recentered on the selection centroid, replacing the hand-rolled force sim that drifted toward world-origin. +* **Bubble and heatmap overlays stay aligned across DPR changes.** Moving the window to a monitor with a different device-pixel-ratio left bubble groups and the heatmap field misaligned until a sidebar toggle forced a resize. A DPR watcher now forces a full sigma resize and re-render on every ratio change, and the overlay canvases own their CSS display size so they stay 1:1 with the WebGL layers regardless of resize timing. + ## 1.15.0 — 2026-06-16 Existing graph files (including version-less JSON saved by 1.14.x) load unchanged — this release adds and replaces functionality without breaking the saved-file format. diff --git a/package.json b/package.json index 837552d..402215a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.15.0", + "version": "1.15.1", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index c9f9f13..d6ab403 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.15.0"; +const VERSION = "1.15.1"; const DEFAULTS = { NODE: {