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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 1.17.1 — 2026-08-12

### Fixes

* **Switching workspaces always lands on a visible graph.** Clicking or dragging any node pinned the renderer's coordinate normalization to the workspace on screen at that moment, and nothing released the pin except ⊡ Fit — so switching to a workspace whose layout lives in a different coordinate range (a grid template versus a circular one, say) could render it entirely off-screen or collapsed into a corner, and only a Fit or a lucky second switch brought it back. The pin now lives exactly as long as the drag gesture, and every full re-render re-derives the coordinate frame from the nodes actually present.
* **Bubble groups no longer glitch through a workspace switch.** Switching between workspaces with different groups flashed the incoming groups' colours on the outgoing hull shape, let the hull trail behind the moving nodes, and briefly showed the stale shape again before it snapped into place. The hulls now hide the instant a switch starts, stay hidden while the nodes animate over, and fade back in only after they have been refitted around the settled positions — so the target workspace's groups appear fully formed, in their own colours.
* **Switching workspaces in quick succession no longer strands the first switch.** A switch started while the previous one was still animating cancelled that animation in a way its caller never noticed, leaving the older switch waiting forever — its cleanup, status message and undo-history reset never ran. A cancelled switch now finishes immediately and hands everything over to the newer one.
* **A failed switch can no longer freeze the loading overlay.** A narrow window at the start of every workspace switch, creation and re-layout sat outside the error handling that releases the overlay hold; an error there (for instance the selector naming a workspace that no longer exists) left the overlay up for good, with every later action unable to dismiss it short of a reload.
* **The workspace-name prompt validates inline.** Creating a workspace with an empty name popped a native browser alert — the only one left in the app, and one that blocks the whole window. The dialog now marks the name field with a standard validation bubble and clears it as you type.

### Performance

* **Style and filter updates stay cheap after an Arrange or Re-layout.** The first arrange of a session raised an internal "layout changed" flag that was never lowered, silently upgrading every later style- or filter-only update to a full re-indexing render for the rest of the session.

## 1.17.0 — 2026-08-07

Saved graph files, workspaces, filters, styles and bubble groups all load unchanged, including files written before this release — but **the interface is rearranged**, so it is worth reading the first section below before looking for a control where it used to be. The short version: the filter sidebar, styling sidebar, selection HUD and workspace bar are now a **rail** across the top, one **inspector** on the right with Filters / Overlays / Selection contexts, and a **workbench** of tabs (Data, Query, Metrics, Assistant) at the foot of the stage. `⌘K` / `Ctrl+K` finds any control by name and tells you where it lives, which is the fastest way to relearn the layout.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.17.0",
"version": "1.17.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.17.0";
const VERSION = "1.17.1";

const DEFAULTS = {
NODE: {
Expand Down
47 changes: 40 additions & 7 deletions src/graph/bubble_layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const CHEAP_FIT_MS = 8;
// re-fit once motion stops. The hull lags the node mid-drag and snaps true on
// release, which is the trade the alternative cannot buy: a locked UI.
const REFIT_SETTLE_MS = 90;
// Workspace-switch tween: the adapter fades the canvases out for the position
// tween (hulls can't track animated nodes) and back in over the settled refit.
const TRANSITION_FADE_MS = 200;

class BubbleSetLayer {
/**
Expand Down Expand Up @@ -151,6 +154,42 @@ class BubbleSetLayer {
this.labelCanvas?.remove();
}

/**
* Hide both canvases for a workspace switch and reveal them afterwards
* (GraphLayoutManager.changeLayout / SigmaAdapter.runLayoutTransition).
* Hiding is INSTANT — an eased fade-out would still show the incoming
* groups' colors repainted onto the outgoing shape. The reveal refits
* first (never show a hull the deferral left at stale positions), then
* eases the fresh one in. Pure CSS on top of the paint loop: the layer
* keeps painting underneath, and exports re-paint from the cached
* outlines regardless of canvas opacity.
*/
setFaded(faded) {
if (!faded) this.refitNow();
for (const canvas of [this.canvas, this.labelCanvas]) {
if (!canvas) continue;
canvas.style.transition = faded ? 'none' : `opacity ${TRANSITION_FADE_MS}ms ease`;
canvas.style.opacity = faded ? '0' : '1';
}
}

/**
* Fit + paint every deferred outline immediately (no settle wait). Shared
* by the settle timer and the reveal path above; cheap when nothing moved
* (unchanged identity/position keys skip the fit).
*/
refitNow() {
if (this.killed) return;
clearTimeout(this.settleHandle);
this.settleHandle = null;
this.forceRefit = true;
try {
this.#paint();
} finally {
this.forceRefit = false;
}
}

/** Show or hide every bubble, on screen and in both export paths. */
setVisible(visible) {
if (this.visible === visible) return;
Expand All @@ -177,13 +216,7 @@ class BubbleSetLayer {
clearTimeout(this.settleHandle);
this.settleHandle = setTimeout(() => {
this.settleHandle = null;
if (this.killed) return;
this.forceRefit = true;
try {
this.#paint();
} finally {
this.forceRefit = false;
}
this.refitNow();
}, REFIT_SETTLE_MS);
}

Expand Down
8 changes: 7 additions & 1 deletion src/graph/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,13 @@ class GraphCoreManager {
}
await this.cache.ui.showLoading('Loading', 'Rendering graph ..');
await new Promise((resolve) => requestAnimationFrame(resolve));
return await this.cache.graph.render();
const rendered = await this.cache.graph.render();
// Consume the flag only after the render succeeded (a failed render
// keeps it up so the next call re-renders). Left un-reset, the first
// Arrange/Re-layout of a session forced the full re-indexing render
// branch on every later style- or filter-only update.
this.cache.layoutChanged = false;
return rendered;
} else {
await this.cache.ui.showLoading('Loading', 'Redrawing graph ..');
await new Promise((resolve) => requestAnimationFrame(resolve));
Expand Down
12 changes: 11 additions & 1 deletion src/graph/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ class InteractionManager {
}
}
// Pin the normalization bbox: without it every x/y write re-normalizes
// the coordinate space and the graph swims under the cursor.
// the coordinate space and the graph swims under the cursor. Released on
// mouseup (the pin's lifetime is exactly the gesture); full renders
// (SigmaAdapter.render) and fitView clear it too — a pin left in place
// froze normalization across workspace switches and rendered workspaces
// with a different coordinate range off-screen.
const sigma = this.adapter.sigma;
if (!sigma.getCustomBBox()) sigma.setCustomBBox(sigma.getBBox());
}
Expand Down Expand Up @@ -176,6 +180,12 @@ class InteractionManager {
if (graph.hasNode(id)) graph.mergeNodeAttributes(id, { forceLabel: false });
}
this.pinnedLabels = null;
// Release the normalization pin taken in #onDownNode: its lifetime is
// exactly the gesture. Left in place, a full render fired mid-drag by
// something else (a filter event, an expand) would release it under the
// cursor instead — and until that render the frozen bbox distorts every
// extent-changing update.
this.adapter.sigma.setCustomBBox(null);
if (!moved) return;
// Set synchronously before any await: sigma emits clickNode right after
// mouseup with no microtask boundary, so the flag must already be up.
Expand Down
130 changes: 75 additions & 55 deletions src/graph/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,24 +35,33 @@ class GraphLayoutManager {
// 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));

const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout];

// Animate node positions from the outgoing workspace to this one when it
// carries persisted positions. The adapter leaves positions in place
// through the render (pendingLayoutTransition) and tweens them once the
// loading overlay clears (runLayoutTransition, last step below). A
// position-less view (fresh template) has nothing to tween from/to and
// takes the normal snap path.
const animatePositions = currentLayout.positions?.size > 0;
this.cache.graph.pendingLayoutTransition = animatePositions;

// finally: never leave pendingLayoutTransition stuck on. If any step below
// throws before runLayoutTransition consumes it, every later render would
// otherwise skip #applyPersistedPositions and freeze nodes at the outgoing
// workspace for the adapter's lifetime.
// The try starts HERE, immediately after the hold: a throw anywhere below
// (e.g. #selectView naming a workspace that no longer exists) must reach
// the finally, or the leaked hold blocks every hideLoading() forever and
// bricks the UI until reload. The finally also clears
// pendingLayoutTransition — left on, every later render would skip
// #applyPersistedPositions and freeze nodes at the outgoing workspace.
try {
await new Promise((resolve) => requestAnimationFrame(resolve));

// The incoming workspace's styles, visibility flips and group set all
// repaint the bubble hulls in place below — the new groups' colors on the
// OLD shape, visible through the overlay. Hide the hulls for the whole
// switch; revealed (refit + fade-in) by runLayoutTransition or finally.
this.cache.graph?.bubbleLayer?.setFaded(true);

const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout];

// Animate node positions from the outgoing workspace to this one when it
// carries persisted positions. The adapter leaves positions in place
// through the render (pendingLayoutTransition) and tweens them once the
// loading overlay clears (runLayoutTransition, last step below). A
// position-less view (fresh template) has nothing to tween from/to and
// takes the normal snap path.
const animatePositions = currentLayout.positions?.size > 0;
this.cache.graph.pendingLayoutTransition = animatePositions;

// Apply per-view node and edge styles (positions held at the outgoing
// view's when animating, so the tween starts from what's on screen).
await this.applyLayoutStyles(currentLayout, animatePositions);
Expand Down Expand Up @@ -108,6 +117,13 @@ class GraphLayoutManager {
this.cache.ui.releaseLoading();
await this.cache.ui.hideLoading();
if (this.cache.graph) this.cache.graph.pendingLayoutTransition = false;
// Reveal the hulls hidden at the top (refit + fade-in; no-op when
// runLayoutTransition already revealed them) — UNLESS a newer switch
// cancelled this one mid-tween and is still animating: it owns the
// fade now (layoutTransitionCancel is its live cancel handle).
if (!this.cache.graph?.layoutTransitionCancel) {
this.cache.graph?.bubbleLayer?.setFaded(false);
}
}
}

Expand Down Expand Up @@ -344,38 +360,39 @@ class GraphLayoutManager {
// still running. Released right before the position tween, and in finally.
this.cache.ui.holdLoading();

// Clear the filter lock since this is a fresh template with no query
this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false;

// Clear selection FIRST before doing anything else
await this.cache.sm.toggleSelectionForAllNodes(false);
await this.cache.sm.toggleSelectionForAllEdges(false);

// Update UI to show the new layout's filters and query
this.cache.ui.buildFilterUI();
this.cache.qm.updateQueryTextArea();
this.cache.ui.updateFilterLockState();
this.cache.ui.clearActivePropsCacheOnLayoutChange();

// Process filters to determine which nodes should be visible
await this.cache.gcm.preRenderEvent();
// The try starts immediately after the hold: any failure below —
// selection clears, the filter pass, the layout worker rejecting —
// must release the hold, drop the overlay and clear
// pendingLayoutTransition, or the leaked hold blocks every
// hideLoading() until reload.
try {
// Clear the filter lock since this is a fresh template with no query
this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false;

// Snapshot the on-screen (outgoing-workspace) positions so the new
// template layout animates IN from them instead of snapping — same
// effect as switching between existing workspaces. graphData is y-up
// graphology, which is exactly what runLayoutTransition tweens toward.
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 });
}
});
// Clear selection FIRST before doing anything else
await this.cache.sm.toggleSelectionForAllNodes(false);
await this.cache.sm.toggleSelectionForAllEdges(false);

// Update UI to show the new layout's filters and query
this.cache.ui.buildFilterUI();
this.cache.qm.updateQueryTextArea();
this.cache.ui.updateFilterLockState();
this.cache.ui.clearActivePropsCacheOnLayoutChange();

// Process filters to determine which nodes should be visible
await this.cache.gcm.preRenderEvent();

// Snapshot the on-screen (outgoing-workspace) positions so the new
// template layout animates IN from them instead of snapping — same
// effect as switching between existing workspaces. graphData is y-up
// graphology, which is exactly what runLayoutTransition tweens toward.
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 });
}
});

// setLayout/layout (possibly the off-thread worker), the full render
// pipeline and the position tween all run under one try so any failure —
// including the layout worker rejecting — releases the loading hold,
// drops the overlay and clears pendingLayoutTransition.
try {
// Apply the layout algorithm once
await this.cache.graph.setLayout({
type: result.templateType,
Expand Down Expand Up @@ -667,16 +684,19 @@ class GraphLayoutManager {
// before the position tween, and again in finally.
this.cache.ui.holdLoading();

// Snapshot the on-screen positions so the new layout animates IN from them
// instead of snapping (same approach as the addLayout template branch).
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 });
}
});

// Try starts immediately after the hold (a throw before the finally would
// leak it and block every hideLoading() until reload).
try {
// Snapshot the on-screen positions so the new layout animates IN from
// them instead of snapping (same approach as the addLayout template
// branch).
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 });
}
});

await this.cache.graph.setLayout({
type: layoutType,
...this.cache.DEFAULTS.LAYOUT_INTERNALS[layoutType],
Expand Down
Loading
Loading