Skip to content

Commit 39ecf86

Browse files
committed
Merge branch 'main' into chore/manuscript-update
2 parents cea4bdf + 28ee065 commit 39ecf86

16 files changed

Lines changed: 868 additions & 420 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 1.15.1 — 2026-06-17
4+
5+
Saved graph files load unchanged — this release is bug fixes only.
6+
7+
### Fixes
8+
9+
* **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.
10+
* **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.
11+
312
## 1.15.0 — 2026-06-16
413

514
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.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "graph-lens-lite",
3-
"version": "1.15.0",
3+
"version": "1.15.1",
44
"main": "src/package/electron_app.js",
55
"description": "Visualise and explore property graphs in a lightweight desktop app.",
66
"homepage": "https://github.com/Delta4AI/GraphLensLite",

src/config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Defaults for the graph, layouts and UI
33
*/
4-
const VERSION = "1.15.0";
4+
const VERSION = "1.15.1";
55

66
const DEFAULTS = {
77
NODE: {

src/graph/bubble_layer.js

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* This replaces the G6 plugin's recompute-per-draw churn (the patched
2424
* updateBubbleSetsPath path coalescing, issue #7195) with an owned cache.
2525
*/
26-
import { DEFAULTS } from "../config.js";
26+
import { DEFAULTS } from '../config.js';
2727
import {
2828
nodeViewportRect,
2929
computeOutlinePoints,
@@ -32,10 +32,10 @@ import {
3232
idsKey,
3333
positionsChecksum,
3434
styleKey,
35-
} from "./bubble_geometry.js";
35+
} from './bubble_geometry.js';
3636

37-
const LAYER_NAME = "bubbleSets";
38-
const LABEL_LAYER_NAME = "bubbleSetsLabels";
37+
const LAYER_NAME = 'bubbleSets';
38+
const LABEL_LAYER_NAME = 'bubbleSetsLabels';
3939
const OUTLINE_STROKE_WIDTH = 2;
4040
// Extra screen-px gap (beyond half the font box) between the outline and a
4141
// label drawn with labelCloseToPath: false.
@@ -65,23 +65,23 @@ class BubbleSetLayer {
6565
// createCanvasContext returns the Sigma instance (fluent API); the canvas
6666
// and 2d context land in sigma.elements / sigma.canvasContexts.
6767
sigma.createCanvasContext(LAYER_NAME, {
68-
afterLayer: "nodes",
69-
style: { pointerEvents: "none" },
68+
afterLayer: 'nodes',
69+
style: { pointerEvents: 'none' },
7070
});
7171
this.canvas = sigma.getCanvases()[LAYER_NAME];
72-
this.ctx = this.canvas.getContext("2d");
72+
this.ctx = this.canvas.getContext('2d');
7373

7474
// Group labels paint on their own canvas stacked above sigma's node-label
7575
// layer so they always win the z-order contest against member labels.
7676
sigma.createCanvasContext(LABEL_LAYER_NAME, {
77-
afterLayer: "labels",
78-
style: { pointerEvents: "none" },
77+
afterLayer: 'labels',
78+
style: { pointerEvents: 'none' },
7979
});
8080
this.labelCanvas = sigma.getCanvases()[LABEL_LAYER_NAME];
81-
this.labelCtx = this.labelCanvas.getContext("2d");
81+
this.labelCtx = this.labelCanvas.getContext('2d');
8282

8383
this.renderHandler = () => this.scheduleRedraw();
84-
sigma.on("afterRender", this.renderHandler);
84+
sigma.on('afterRender', this.renderHandler);
8585
}
8686

8787
/**
@@ -110,7 +110,7 @@ class BubbleSetLayer {
110110
if (this.killed) return;
111111
this.killed = true;
112112
if (this.rafHandle !== null) cancelAnimationFrame(this.rafHandle);
113-
this.adapter.sigma.off("afterRender", this.renderHandler);
113+
this.adapter.sigma.off('afterRender', this.renderHandler);
114114
// sigma.kill() may or may not remove custom layer canvases; remove() on
115115
// an already-detached node is a no-op, so drop ours defensively.
116116
this.canvas?.remove();
@@ -130,7 +130,7 @@ class BubbleSetLayer {
130130
#groupState(group) {
131131
let state = this.groups.get(group);
132132
if (!state) {
133-
state = { members: new Map(), avoidMembers: [], opts: {}, membersKey: "", avoidKey: "" };
133+
state = { members: new Map(), avoidMembers: [], opts: {}, membersKey: '', avoidKey: '' };
134134
this.groups.set(group, state);
135135
}
136136
return state;
@@ -182,7 +182,7 @@ class BubbleSetLayer {
182182
// re-renders on hover etc. without clearing custom layers).
183183
const signature =
184184
`${width}x${height}x${dpr}|${camera.x},${camera.y},${camera.ratio},${camera.angle}` +
185-
`|${active.map(([g]) => g).join(",")}`;
185+
`|${active.map(([g]) => g).join(',')}`;
186186
if (!outlinesChanged && signature === this.lastPaintSignature) return;
187187
this.lastPaintSignature = signature;
188188

@@ -195,9 +195,7 @@ class BubbleSetLayer {
195195
#drawOutlines(active, sigma) {
196196
for (const [group, state] of active) {
197197
// Reprojection assumes camera.angle === 0 (the app never rotates the camera).
198-
const points = this.outlines
199-
.get(group)
200-
.graphPoints.map((p) => sigma.graphToViewport(p));
198+
const points = this.outlines.get(group).graphPoints.map((p) => sigma.graphToViewport(p));
201199
const defaults = this.cache.DEFAULTS.BUBBLE_GROUP_STYLE[group] ?? {};
202200
const drawn = this.#drawGroup(this.ctx, points, state, defaults);
203201
// Labels paint on the top canvas (afterLayer: "labels") so they read
@@ -296,6 +294,16 @@ class BubbleSetLayer {
296294
canvas.width = width * dpr;
297295
canvas.height = height * dpr;
298296
}
297+
// Own the CSS display size too: sigma.createCanvasContext only sets
298+
// position:absolute, and sigma.resize() (the sole writer of element.style
299+
// width/height) runs once at construction — before this canvas exists — and
300+
// early-returns on unchanged dimensions. Left unset, the canvas displays at
301+
// its backing-store size (width*dpr CSS px), which is dpr* too large on a
302+
// >1 DPR display, so the group lands in the wrong place until a panel toggle
303+
// forces a real sigma resize. Setting it here keeps the overlay 1:1 with the
304+
// WebGL layers regardless of sigma's resize timing or the display's DPR.
305+
if (canvas.style.width !== `${width}px`) canvas.style.width = `${width}px`;
306+
if (canvas.style.height !== `${height}px`) canvas.style.height = `${height}px`;
299307
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
300308
ctx.clearRect(0, 0, width, height);
301309
}
@@ -402,7 +410,7 @@ class BubbleSetLayer {
402410
// Reference-viewport → graph space (undo the ratio-1 mapping, then the
403411
// camera): the cache holds zoom-independent graph coords.
404412
return refPoints.map((p) =>
405-
sigma.viewportToGraph({ x: cx + (p.x - cx) / r, y: cy + (p.y - cy) / r }),
413+
sigma.viewportToGraph({ x: cx + (p.x - cx) / r, y: cy + (p.y - cy) / r })
406414
);
407415
}
408416

@@ -422,10 +430,10 @@ class BubbleSetLayer {
422430
ctx.save();
423431
try {
424432
ctx.globalAlpha = opts.fillOpacity ?? defaults.fillOpacity ?? 0.25;
425-
ctx.fillStyle = opts.fill ?? defaults.fill ?? "#403C53";
433+
ctx.fillStyle = opts.fill ?? defaults.fill ?? '#403C53';
426434
ctx.fill(path);
427435
ctx.globalAlpha = opts.strokeOpacity ?? defaults.strokeOpacity ?? 1;
428-
ctx.strokeStyle = opts.stroke ?? defaults.stroke ?? "#403C53";
436+
ctx.strokeStyle = opts.stroke ?? defaults.stroke ?? '#403C53';
429437
ctx.lineWidth = OUTLINE_STROKE_WIDTH;
430438
ctx.stroke(path);
431439
ctx.globalAlpha = 1;
@@ -442,9 +450,9 @@ class BubbleSetLayer {
442450
* outline tangent. labelOffsetX/Y stay additive in screen space.
443451
*/
444452
#drawLabel(ctx, points, opts, defaults) {
445-
const text = opts.labelText ?? defaults.labelText ?? "";
453+
const text = opts.labelText ?? defaults.labelText ?? '';
446454
if (!text) return;
447-
const placement = opts.labelPlacement ?? defaults.labelPlacement ?? "bottom";
455+
const placement = opts.labelPlacement ?? defaults.labelPlacement ?? 'bottom';
448456
const closeToPath = opts.labelCloseToPath ?? defaults.labelCloseToPath ?? true;
449457
const autoRotate = opts.labelAutoRotate ?? defaults.labelAutoRotate ?? true;
450458
const anchor = outlineLabelAnchor(points, placement);
@@ -459,12 +467,12 @@ class BubbleSetLayer {
459467
const y = anchor.y + anchor.ny * standoff + (opts.labelOffsetY ?? 0);
460468

461469
ctx.font = `${fontSize}px Arial, sans-serif`;
462-
ctx.textAlign = "center";
463-
ctx.textBaseline = "middle";
470+
ctx.textAlign = 'center';
471+
ctx.textBaseline = 'middle';
464472
const textWidth = ctx.measureText(text).width;
465473

466474
// Rotation only makes sense hugging the path; "center" has no tangent.
467-
const rotated = autoRotate && closeToPath && placement !== "center";
475+
const rotated = autoRotate && closeToPath && placement !== 'center';
468476
if (rotated) {
469477
ctx.save();
470478
ctx.translate(x, y);
@@ -475,18 +483,18 @@ class BubbleSetLayer {
475483

476484
if (opts.labelBackground ?? defaults.labelBackground) {
477485
const radius = opts.labelBackgroundRadius ?? defaults.labelBackgroundRadius ?? 5;
478-
ctx.fillStyle = opts.labelBackgroundFill ?? defaults.labelBackgroundFill ?? "#403C53";
486+
ctx.fillStyle = opts.labelBackgroundFill ?? defaults.labelBackgroundFill ?? '#403C53';
479487
ctx.beginPath();
480488
ctx.roundRect(
481489
lx - textWidth / 2 - padding,
482490
ly - fontSize / 2 - padding,
483491
textWidth + 2 * padding,
484492
fontSize + 2 * padding,
485-
radius,
493+
radius
486494
);
487495
ctx.fill();
488496
}
489-
ctx.fillStyle = opts.labelFill ?? defaults.labelFill ?? "#fff";
497+
ctx.fillStyle = opts.labelFill ?? defaults.labelFill ?? '#fff';
490498
ctx.fillText(text, lx, ly);
491499
if (rotated) ctx.restore();
492500
}

src/graph/dpr_watch.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Device-pixel-ratio change watcher.
3+
*
4+
* Dragging the window to a monitor with a different DPR changes
5+
* window.devicePixelRatio without changing the container's CSS box, so
6+
* sigma.resize() refreshes its pixelRatio but early-returns before re-sizing any
7+
* canvas — leaving the WebGL layers at the old backing resolution while the
8+
* bubble/heatmap overlays repaint at the new ratio, so groups land misaligned
9+
* until a sidebar toggle forces a real resize. The adapter uses this watcher to
10+
* force that resize automatically on every DPR change.
11+
*/
12+
13+
/**
14+
* Invoke `onChange` whenever window.devicePixelRatio changes. matchMedia with a
15+
* ratio-specific resolution query is the standard signal: the query stops
16+
* matching the instant the ratio leaves its value, so we re-arm against the new
17+
* ratio after each change. Returns a cleanup that detaches the currently-armed
18+
* one-shot listener.
19+
*
20+
* @param {() => void} onChange
21+
* @returns {() => void} cleanup
22+
*/
23+
function watchDevicePixelRatio(onChange) {
24+
let detach = null;
25+
const handler = () => {
26+
onChange();
27+
arm();
28+
};
29+
function arm() {
30+
const dpr = window.devicePixelRatio || 1;
31+
const media = window.matchMedia?.(`(resolution: ${dpr}dppx)`);
32+
if (!media?.addEventListener) {
33+
detach = null;
34+
return;
35+
}
36+
media.addEventListener('change', handler, { once: true });
37+
detach = () => media.removeEventListener('change', handler);
38+
}
39+
arm();
40+
return () => detach?.();
41+
}
42+
43+
export { watchDevicePixelRatio };

0 commit comments

Comments
 (0)