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: { 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/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/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'); + }); +}); 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(); });