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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Defaults for the graph, layouts and UI
*/
const VERSION = "1.15.0";
const VERSION = "1.15.1";

const DEFAULTS = {
NODE: {
Expand Down
64 changes: 36 additions & 28 deletions src/graph/bubble_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 })
);
}

Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
}
Expand Down
43 changes: 43 additions & 0 deletions src/graph/dpr_watch.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading