From e207e14c329740899825e75d6842b54690e1b955 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Tue, 21 Jul 2026 14:45:42 +0200 Subject: [PATCH 01/18] feat(styling): add bubble-set padding and corridor-width controls Per-group Padding and Corridor Width sliders multiply the bubblesets-js influence-field radii (nodeR0/R1 and edgeR0/R1) so tight two-node blobs can be shrunk and thin arms to outlying members thickened. Values persist with bubbleSetStyle in JSON; multipliers are clamped to [0.25, 4] in geometry. Also fold each member's on-screen radius into the outline identity checksum: the fit consumes node radii, so a size change (or a post-load size settle) now invalidates the cached hull instead of leaving a stale outline until membership changes. --- src/config.js | 8 ++++ src/graph/bubble_geometry.js | 34 +++++++++++++---- src/graph/bubble_layer.js | 16 +++++++- src/graph/bubble_sets.js | 8 ++++ src/managers/ui_style_div.js | 16 ++++++++ tests/bubble-geometry.test.js | 71 +++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 10 deletions(-) diff --git a/src/config.js b/src/config.js index 606d70a..296e2a4 100644 --- a/src/config.js +++ b/src/config.js @@ -202,6 +202,8 @@ const DEFAULTS = { stroke: '#C33D35', strokeOpacity: 1, virtualEdges: true, + padding: 1, + corridor: 1, label: true, labelText: 'group one', labelFill: '#fff', @@ -222,6 +224,8 @@ const DEFAULTS = { stroke: '#403c53', strokeOpacity: 1, virtualEdges: true, + padding: 1, + corridor: 1, label: true, labelText: 'group two', labelFill: '#fff', @@ -242,6 +246,8 @@ const DEFAULTS = { stroke: '#8CA6D9', strokeOpacity: 1, virtualEdges: true, + padding: 1, + corridor: 1, label: true, labelText: 'group three', labelFill: '#fff', @@ -262,6 +268,8 @@ const DEFAULTS = { stroke: '#EFB0AA', strokeOpacity: 1, virtualEdges: true, + padding: 1, + corridor: 1, label: true, labelText: 'group four', labelFill: '#fff', diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index 9d0c5f9..4c9c31e 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -34,6 +34,11 @@ const FIELD_EDGE_R0 = 10; const FIELD_EDGE_R1 = 20; const FIELD_MORPH_BUFFER = 10; const FIELD_PIXEL_GROUP = 4; +// User-tunable multipliers on the influence field (per-group style): +// padding scales the node field (how far the body extends past members), +// corridor scales the virtual-edge field (arm thickness to outliers). +const GEOMETRY_MULTIPLIER_MIN = 0.25; +const GEOMETRY_MULTIPLIER_MAX = 4; // Marching-squares grid cell can never go below 1 px (pixelGroup 0 hangs // the library; sub-pixel cells just waste time). const FIELD_PIXEL_GROUP_MIN = 1; @@ -55,7 +60,8 @@ function nodeViewportRect(x, y, radius) { * * @param {Array<{x,y,width,height}>} memberRects * @param {Array<{x,y,width,height}>} avoidRects - * @param {{virtualEdges?: boolean, scale?: number}} [opts] + * @param {{virtualEdges?: boolean, scale?: number, padding?: number, + * corridor?: number}} [opts] * virtualEdges routes connecting corridors around avoid rects (the * per-group style enables it); its cost is O(members × avoid) — see * MAX_NODES_BEFORE_DISABLING_AVOID_MEMBERS_IN_BUBBLE_GROUPS in config.js @@ -63,19 +69,26 @@ function nodeViewportRect(x, y, radius) { * scale multiplies the bubblesets influence-field pixel constants so the * field stays proportional to the rects at any zoom (1 = library * defaults; pass sigma's node-size zoom factor). + * padding multiplies the node influence radii (body extent past members); + * corridor multiplies the edge influence radii (arm thickness). Both are + * user style knobs, clamped to [0.25, 4], default 1 = library defaults. * @returns {Array<{x: number, y: number}>} closed polygon (empty when no * members or the outline collapsed) */ function computeOutlinePoints(memberRects, avoidRects = [], opts = {}) { if (!memberRects || memberRects.length === 0) return []; const s = opts.scale ?? 1; + const clampMult = (v) => + Math.min(GEOMETRY_MULTIPLIER_MAX, Math.max(GEOMETRY_MULTIPLIER_MIN, Number(v) || 1)); + const pad = clampMult(opts.padding ?? 1); + const cor = clampMult(opts.corridor ?? 1); const path = bubblesets.createOutline(memberRects, avoidRects, [], { virtualEdges: opts.virtualEdges !== false, - nodeR0: FIELD_NODE_R0 * s, - nodeR1: FIELD_NODE_R1 * s, - edgeR0: FIELD_EDGE_R0 * s, - edgeR1: FIELD_EDGE_R1 * s, - morphBuffer: FIELD_MORPH_BUFFER * s, + nodeR0: FIELD_NODE_R0 * s * pad, + nodeR1: FIELD_NODE_R1 * s * pad, + edgeR0: FIELD_EDGE_R0 * s * cor, + edgeR1: FIELD_EDGE_R1 * s * cor, + morphBuffer: FIELD_MORPH_BUFFER * s * Math.max(pad, cor), pixelGroup: Math.max(FIELD_PIXEL_GROUP_MIN, FIELD_PIXEL_GROUP * s), }); // Integer stride only (see OUTLINE_SAMPLE_STEP note): Math.round keeps the @@ -272,9 +285,11 @@ function idsKey(ids) { * quantized to 1/8 px plus the index: exact integer arithmetic is immune to * IEEE754 accumulation loss at large coordinate magnitudes and member * counts, and folding the index in keeps it order-sensitive (transpositions - * hash differently). + * hash differently). An optional per-point `s` (on-screen node radius) folds + * in too, so a node-size change invalidates the cached outline — the fit + * consumes radii, not just positions. * - * @param {Array<{x: number, y: number}>} points + * @param {Array<{x: number, y: number, s?: number}>} points * @returns {string} */ function positionsChecksum(points) { @@ -283,6 +298,7 @@ function positionsChecksum(points) { for (const p of points) { hash = Math.imul(hash ^ Math.round(p.x * 8), 0x01000193); hash = Math.imul(hash ^ Math.round(p.y * 8), 0x01000193); + hash = Math.imul(hash ^ Math.round((p.s ?? 0) * 8), 0x01000193); hash = Math.imul(hash ^ i, 0x01000193); i++; } @@ -297,6 +313,8 @@ const STYLE_KEY_FIELDS = [ "stroke", "strokeOpacity", "virtualEdges", + "padding", + "corridor", "label", "labelText", "labelFill", diff --git a/src/graph/bubble_layer.js b/src/graph/bubble_layer.js index dcb2fb5..82bafb9 100644 --- a/src/graph/bubble_layer.js +++ b/src/graph/bubble_layer.js @@ -317,6 +317,7 @@ class BubbleSetLayer { */ #syncGroupOutline(group, state) { const graph = this.adapter.graph; + const sigma = this.adapter.sigma; const memberPositions = []; const visibleMembers = []; for (const id of state.members.keys()) { @@ -324,7 +325,13 @@ class BubbleSetLayer { const attrs = graph.getNodeAttributes(id); if (attrs.hidden) continue; visibleMembers.push({ id, attrs }); - memberPositions.push({ x: attrs.x, y: attrs.y }); + // The ratio-1 radius feeds the fit (see #fitGraphOutline), so fold it + // into the checksum: a node-size change must invalidate the outline. + memberPositions.push({ + x: attrs.x, + y: attrs.y, + s: sigma.scaleSize(attrs.size ?? DEFAULTS.NODE.SIZE / 2, 1), + }); } // membersKey/avoidKey are precomputed in #updateGroup, so the per-frame @@ -400,7 +407,12 @@ class BubbleSetLayer { avoidRects.push(toRefRect(attrs)); } - const outlineOpts = { virtualEdges: state.opts.virtualEdges, scale: 1 }; + const outlineOpts = { + virtualEdges: state.opts.virtualEdges, + scale: 1, + padding: state.opts.padding, + corridor: state.opts.corridor, + }; let refPoints = computeOutlinePoints(memberRects, avoidRects, outlineOpts); // Safety net: if the avoid nodes' negative field collapses the outline, a // hull that ignores avoid nodes beats a vanished group. diff --git a/src/graph/bubble_sets.js b/src/graph/bubble_sets.js index 3ee9c2b..031237a 100644 --- a/src/graph/bubble_sets.js +++ b/src/graph/bubble_sets.js @@ -95,6 +95,12 @@ class GraphBubbleSetManager { case "Stroke Opacity": bStyle.strokeOpacity = value; break; + case "Padding": + bStyle.padding = value; + break; + case "Corridor Width": + bStyle.corridor = value; + break; case "Label": bStyle.label = value; break; @@ -176,6 +182,8 @@ class GraphBubbleSetManager { if (slider) slider.value = val; if (numInput) numInput.value = val; }; + syncSliderInput("Padding", bubbleStyle.padding); + syncSliderInput("Corridor Width", bubbleStyle.corridor); syncSliderInput("Label Font Size", bubbleStyle.labelFontSize); syncSliderInput("Label Offset X", bubbleStyle.labelOffsetX); syncSliderInput("Label Offset Y", bubbleStyle.labelOffsetY); diff --git a/src/managers/ui_style_div.js b/src/managers/ui_style_div.js index 1d14549..ce8e96a 100644 --- a/src/managers/ui_style_div.js +++ b/src/managers/ui_style_div.js @@ -1206,6 +1206,22 @@ function createStyleDiv(cache) { createNumericalSlider(rowStrokeOpacity, `Bubble Set ${group} Stroke Opacity`, bs.strokeOpacity, {min: 0, max: 1, step: 0.01}, `Define the stroke opacity of bubble set ${tabIndex}.`, false); + // Padding (node influence field multiplier) + const rowPadding = createNewRow(panel); + appendLabel(rowPadding, "Padding", + "How far the bubble body extends past its member nodes — lower for a tighter hug, higher for more breathing room."); + createNumericalSlider(rowPadding, `Bubble Set ${group} Padding`, bs.padding ?? 1, + {min: 0.25, max: 3, step: 0.05}, + `How far bubble set ${tabIndex} extends past its member nodes.`, false); + + // Corridor Width (virtual-edge influence field multiplier) + const rowCorridor = createNewRow(panel); + appendLabel(rowCorridor, "Corridor Width", + "Thickness of the connecting arms that reach outlying member nodes."); + createNumericalSlider(rowCorridor, `Bubble Set ${group} Corridor Width`, bs.corridor ?? 1, + {min: 0.25, max: 3, step: 0.05}, + `Thickness of bubble set ${tabIndex}'s connecting arms to outlying members.`, false); + // Label (toggle + text input) const rowLabel = createNewRow(panel); appendLabel(rowLabel, "Label Text"); diff --git a/tests/bubble-geometry.test.js b/tests/bubble-geometry.test.js index 50d540d..c6ac4af 100644 --- a/tests/bubble-geometry.test.js +++ b/tests/bubble-geometry.test.js @@ -364,3 +364,74 @@ describe("styleKey", () => { expect(styleKey({ fill: null })).toBe(styleKey({})); }); }); + +// ========================================================================== +// Geometry knobs (padding / corridor) — user-tunable influence-field +// multipliers, and node-size in the position checksum. +// ========================================================================== + +const polygonArea = (points) => { + let area = 0; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + area += a.x * b.y - b.x * a.y; + } + return Math.abs(area / 2); +}; + +describe("computeOutlinePoints padding/corridor knobs", () => { + const pair = [rectAt(100, 100), rectAt(140, 100)]; + + it("padding < 1 shrinks the outline, > 1 grows it (still enclosing members)", () => { + const tight = computeOutlinePoints(pair, [], { padding: 0.5 }); + const base = computeOutlinePoints(pair, [], {}); + const loose = computeOutlinePoints(pair, [], { padding: 2 }); + expect(polygonArea(tight)).toBeLessThan(polygonArea(base)); + expect(polygonArea(base)).toBeLessThan(polygonArea(loose)); + for (const outline of [tight, base, loose]) { + for (const m of pair) { + expect(pointInPolygon({ x: m.x + m.width / 2, y: m.y + m.height / 2 }, outline)).toBe(true); + } + } + }); + + it("corridor width scales the arm to an outlying member", () => { + const spread = [rectAt(50, 50), rectAt(400, 60)]; + const thin = computeOutlinePoints(spread, [], { corridor: 0.5 }); + const thick = computeOutlinePoints(spread, [], { corridor: 2.5 }); + expect(polygonArea(thin)).toBeLessThan(polygonArea(thick)); + for (const outline of [thin, thick]) { + for (const m of spread) { + expect(pointInPolygon({ x: m.x + m.width / 2, y: m.y + m.height / 2 }, outline)).toBe(true); + } + } + }); + + it("clamps out-of-range multipliers instead of collapsing the outline", () => { + expect(computeOutlinePoints(pair, [], { padding: 100 })) + .toEqual(computeOutlinePoints(pair, [], { padding: 4 })); + expect(computeOutlinePoints(pair, [], { padding: 0 })) + .toEqual(computeOutlinePoints(pair, [], {})); + expect(computeOutlinePoints(pair, [], { corridor: NaN })) + .toEqual(computeOutlinePoints(pair, [], {})); + }); + + it("styleKey invalidates on padding/corridor changes", () => { + expect(styleKey({ padding: 1 })).not.toBe(styleKey({ padding: 1.5 })); + expect(styleKey({ corridor: 1 })).not.toBe(styleKey({ corridor: 2 })); + }); +}); + +describe("positionsChecksum node-size fold", () => { + it("changes when a member's on-screen radius changes (same positions)", () => { + const small = [{ x: 1, y: 2, s: 5 }, { x: 3, y: 4, s: 5 }]; + const grown = [{ x: 1, y: 2, s: 5 }, { x: 3, y: 4, s: 9 }]; + expect(positionsChecksum(grown)).not.toBe(positionsChecksum(small)); + }); + + it("treats missing size as stable (legacy point arrays keep working)", () => { + const pts = [{ x: 1, y: 2 }, { x: 3, y: 4 }]; + expect(positionsChecksum(pts)).toBe(positionsChecksum(pts.map((p) => ({ ...p })))); + }); +}); From 3ab23d89df1f4f1e8edefb5683b3be626612a103 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Tue, 21 Jul 2026 16:46:18 +0200 Subject: [PATCH 02/18] fix(graph): rework bubble-set geometry for node-size-aware, guaranteed hulls The influence field now scales with the group's mean member radius instead of bubblesets-js' absolute pixel constants (tuned for ~15px nodes), so hulls stay visually proportional at any configured node size and the padding/ corridor knobs read as fractions of a node. The legacy scale option is gone; resolution floors keep every field radius at marching-grid scale and pixelGroup is clamped to [1, 4]px. Members are now guaranteed enclosed: bubblesets-js validates only center points, so avoid-node pressure plus B-spline shrinkage could clip a node by its own hull (measured -12px). Grazed members get full-padding discs unioned in; disconnected pieces are joined with corridor-width capsule links so the result is always one simple ring. Rings are snapped to a 1/32px grid before clipping (polygon-clipping throws on float-noise segments, which silently disabled the guarantee) and the sample stride adapts to the thinnest field feature to prevent B-spline hooks. Interior non-members are carved as holes via polygon difference (the field can only fjord the boundary), rendered even-odd on canvas, PNG and SVG. Avoid-node moves now invalidate the outline cache; morphBuffer no longer scales with the knobs (corridor rerouting painted phantom lobes). --- src/graph/bubble_geometry.js | 475 ++++++++++++++++-- src/graph/bubble_layer.js | 111 ++-- src/graph/export_svg.js | 11 +- tests/bubble-geometry.test.js | 224 +++++++-- tests/bubble-layer-avoid-invalidation.test.js | 106 ++++ tests/bubble-layer-malformed.test.js | 36 +- tests/export-svg.test.js | 23 +- 7 files changed, 847 insertions(+), 139 deletions(-) create mode 100644 tests/bubble-layer-avoid-invalidation.test.js diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index 4c9c31e..397861b 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -8,12 +8,16 @@ * this module under node. */ import { bubblesets, polygonClipping } from "../lib/graphology.bundle.mjs"; +import { pointInPolygon } from "./lasso_geometry.js"; // PointPath post-processing per the bubblesets-js README: sample the raw // marching-squares outline, B-spline it and drop collinear points. 8 is the -// library's own example default and keeps point counts in the low hundreds. -// Scaled with opts.scale (a step of 8 would flatten a zoomed-out outline -// whose features are ~2 points) but never below 1. +// library's own example default; the stride ADAPTS downward when the field's +// thinnest feature approaches grid scale — control points spaced far coarser +// than a corridor neck make the B-spline oscillate into hooks and jaggies +// (stride 8 × 4 px grid = ~32 px control spacing around 4 px features). +// Target: control spacing ≈ 2× the thinnest field radius. A hard cap on +// sampled points keeps huge perimeters from exploding the O(n²) repair scan. // // NOTE: bubblesets-js sample(step) treats `step` as an array INDEX stride // (points.get(i += step)), NOT a pixel distance. PointPath.get() does @@ -21,27 +25,74 @@ import { bubblesets, polygonClipping } from "../lib/graphology.bundle.mjs"; // returns undefined, which then crashes bSplines()/simplify() with // "Cannot read properties of undefined". The stride must stay an integer. const OUTLINE_SAMPLE_STEP = 8; -const OUTLINE_SAMPLE_MIN_STEP = 1; +const OUTLINE_MAX_SAMPLED_POINTS = 1200; +// Final simplification tolerance as a fraction of the marching-grid cell: +// the dense adaptive-stride spline carries grid-scale micro-wiggle that (a) +// reads as jaggies and (b) produces near-degenerate segments that make +// polygon-clipping throw ("Unable to complete output ring"), silently +// disabling the enclosure guarantee. A quarter-cell tolerance erases the +// noise without touching real features. +const OUTLINE_SIMPLIFY_TOLERANCE_RATIO = 0.25; +// Snap grid (px⁻¹) for rings handed to polygon-clipping — collapses the +// float-noise duplicate/degenerate vertices that trip its sweep line. +const CLIP_SNAP = 32; -// bubblesets-js influence-field defaults (the library's own pixel-tuned -// constants). They are absolute pixel radii, so the caller must scale them -// with the on-screen node size (opts.scale): zoomed far out, an unscaled -// 50 px negative disc around every avoid node swallows the (shrunken) -// members' positive field and the outline collapses to nothing. -const FIELD_NODE_R0 = 15; -const FIELD_NODE_R1 = 50; -const FIELD_EDGE_R0 = 10; -const FIELD_EDGE_R1 = 20; -const FIELD_MORPH_BUFFER = 10; -const FIELD_PIXEL_GROUP = 4; +// bubblesets-js' influence-field defaults (nodeR0 15, nodeR1 50, edgeR0 10, +// edgeR1 20, morphBuffer 10, pixelGroup 4) are ABSOLUTE pixel radii tuned +// for ~15 px-radius nodes. Used raw they give every group a constant ~15 px +// margin: bloated around small nodes, skimpy around large ones, and zoomed +// far out the fixed 50 px negative disc around every avoid node swallows the +// members' field entirely. Dividing by that 15 px reference turns them into +// RATIOS of the group's mean member radius, so padding, corridor thickness +// and grid resolution all track the user's configured node size. +const REFERENCE_NODE_RADIUS = 15; +const FIELD_NODE_R0_RATIO = 15 / REFERENCE_NODE_RADIUS; +const FIELD_NODE_R1_RATIO = 50 / REFERENCE_NODE_RADIUS; +const FIELD_EDGE_R0_RATIO = 10 / REFERENCE_NODE_RADIUS; +const FIELD_EDGE_R1_RATIO = 20 / REFERENCE_NODE_RADIUS; +const FIELD_MORPH_BUFFER_RATIO = 10 / REFERENCE_NODE_RADIUS; +const FIELD_PIXEL_GROUP_RATIO = 4 / REFERENCE_NODE_RADIUS; // User-tunable multipliers on the influence field (per-group style): // padding scales the node field (how far the body extends past members), -// corridor scales the virtual-edge field (arm thickness to outliers). -const GEOMETRY_MULTIPLIER_MIN = 0.25; +// corridor scales the virtual-edge field (arm thickness to outliers), +// avoidance scales the negative field of non-member nodes (0 = the hull may +// freely cover them, higher = it steers around them harder). +// morphBuffer deliberately takes NONE of them: it is the obstacle-routing +// clearance for virtual edges, and scaling it with the corridor knob made +// wider corridors also REROUTE around avoid nodes in long detours that +// painted as phantom lobes in empty space. +const GEOMETRY_MULTIPLIER_MIN = 0.05; const GEOMETRY_MULTIPLIER_MAX = 4; -// Marching-squares grid cell can never go below 1 px (pixelGroup 0 hangs -// the library; sub-pixel cells just waste time). +// bubblesets-js' own default non-member (negative) energy factor; the +// avoidance knob multiplies it. Unlike padding/corridor, 0 is a valid value +// (avoidance off — the library skips the negative pass entirely). +const NON_MEMBER_INFLUENCE_FACTOR = -0.8; +const AVOIDANCE_MAX = 4; +// Interior avoid-node holes: the energy field can only carve FJORDS from the +// hull boundary — marching squares yields one outer contour, so a non-member +// fully inside the hull is topologically invisible to it. carveAvoidHoles +// punches discs for those via polygon difference instead (rendered with +// even-odd fill). A hole squeezed by nearby members below this fraction of +// the node's own radius is skipped — the node lives in a corridor pinch +// where a carve would read as noise. +const HOLE_MIN_RADIUS_RATIO = 0.6; +// Enclosure guarantee: bubblesets-js only validates that each member's CENTER +// is inside the contour (PointPath.containsElements), and the B-spline +// smoothing shrinks lobes inward, so avoid-node pressure can leave a member +// half outside its own hull. After smoothing, every member circle must clear +// the outline by at least this fraction of the padding radius; a violator +// gets a full-padding disc unioned into the hull (the disc always overlaps +// the hull because the member at least touches it, so the union stays one +// ring). +const ENCLOSURE_MIN_CLEARANCE_RATIO = 0.4; +const ENCLOSURE_DISC_SEGMENTS = 32; +// Marching-squares grid cell: never below 1 px (pixelGroup 0 hangs the +// library; sub-pixel cells just waste time) and never above the library's +// 4 px default — a coarser grid visibly wobbles the outline around large +// nodes (jaggies/spline hooks at pinch points), it only ever gets finer for +// small nodes. const FIELD_PIXEL_GROUP_MIN = 1; +const FIELD_PIXEL_GROUP_MAX = 4; /** * Axis-aligned square around a node's viewport position. @@ -55,56 +106,124 @@ function nodeViewportRect(x, y, radius) { return { x: x - radius, y: y - radius, width: 2 * radius, height: 2 * radius }; } +/** + * Mean member radius — the size unit the influence field scales with. + * Rects are the squares nodeViewportRect builds, so radius = half the larger + * side. Falls back to REFERENCE_NODE_RADIUS for degenerate (zero-size) rects + * so the field never collapses to nothing. + * + * @param {Array<{width: number, height: number}>} memberRects + * @returns {number} strictly positive radius (px) + */ +function meanMemberRadius(memberRects) { + let sum = 0; + for (const rect of memberRects) sum += Math.max(rect.width, rect.height) / 2; + const mean = sum / memberRects.length; + return Number.isFinite(mean) && mean > 0 ? mean : REFERENCE_NODE_RADIUS; +} + /** * Compute one group's outline polygon from member/avoid rects. * + * The influence field is sized from the group's MEAN MEMBER RADIUS (see the + * ratio constants above), so hulls stay visually proportional whatever node + * size the user configured, at any zoom — and the padding/corridor knobs + * read as "fractions of a node", not absolute pixels. + * * @param {Array<{x,y,width,height}>} memberRects * @param {Array<{x,y,width,height}>} avoidRects - * @param {{virtualEdges?: boolean, scale?: number, padding?: number, - * corridor?: number}} [opts] + * @param {{virtualEdges?: boolean, padding?: number, corridor?: number, + * avoidance?: number}} [opts] * virtualEdges routes connecting corridors around avoid rects (the * per-group style enables it); its cost is O(members × avoid) — see * MAX_NODES_BEFORE_DISABLING_AVOID_MEMBERS_IN_BUBBLE_GROUPS in config.js * for the measured budget. - * scale multiplies the bubblesets influence-field pixel constants so the - * field stays proportional to the rects at any zoom (1 = library - * defaults; pass sigma's node-size zoom factor). * padding multiplies the node influence radii (body extent past members); * corridor multiplies the edge influence radii (arm thickness). Both are - * user style knobs, clamped to [0.25, 4], default 1 = library defaults. + * user style knobs, clamped to [0.05, 4]; 1 = one mean-radius of margin. + * avoidance multiplies the negative energy of avoid rects, clamped to + * [0, 4]: 0 lets the hull cover non-members, >1 steers around them harder. * @returns {Array<{x: number, y: number}>} closed polygon (empty when no * members or the outline collapsed) */ function computeOutlinePoints(memberRects, avoidRects = [], opts = {}) { - if (!memberRects || memberRects.length === 0) return []; - const s = opts.scale ?? 1; + return computeOutlineGeometry(memberRects, avoidRects, opts).outer; +} + +/** + * Full group geometry: the outer hull ring plus interior HOLES carved around + * non-members the hull swallowed (see carveAvoidHoles — the field alone can + * only fjord the boundary). Render with even-odd fill. Same options as + * computeOutlinePoints. + * + * @param {Array<{x,y,width,height}>} memberRects + * @param {Array<{x,y,width,height}>} avoidRects + * @param {{virtualEdges?: boolean, padding?: number, corridor?: number, + * avoidance?: number}} [opts] + * @returns {{outer: Array<{x: number, y: number}>, + * holes: Array>}} + */ +function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { + if (!memberRects || memberRects.length === 0) return { outer: [], holes: [] }; const clampMult = (v) => Math.min(GEOMETRY_MULTIPLIER_MAX, Math.max(GEOMETRY_MULTIPLIER_MIN, Number(v) || 1)); const pad = clampMult(opts.padding ?? 1); const cor = clampMult(opts.corridor ?? 1); - const path = bubblesets.createOutline(memberRects, avoidRects, [], { + // 0 is meaningful for avoidance (off), so NaN/missing fall back to 1 + // explicitly instead of through the || 1 shortcut the other knobs use. + const avoidRaw = Number(opts.avoidance ?? 1); + const avoidance = Number.isFinite(avoidRaw) + ? Math.min(AVOIDANCE_MAX, Math.max(0, avoidRaw)) + : 1; + const unit = meanMemberRadius(memberRects); + const pixelGroupPx = Math.min( + FIELD_PIXEL_GROUP_MAX, + Math.max(FIELD_PIXEL_GROUP_MIN, FIELD_PIXEL_GROUP_RATIO * unit) + ); + // Resolution floors: marching squares cannot trace a feature thinner than + // a grid cell, so ultra-low knob values would silently disconnect the + // corridors and drop margins below drawability. The floors keep every + // field radius at grid scale (with R1 > R0 preserved). + const nodeR0px = Math.max(FIELD_NODE_R0_RATIO * unit * pad, pixelGroupPx); + const nodeR1px = Math.max(FIELD_NODE_R1_RATIO * unit * pad, 3 * pixelGroupPx); + const edgeR0px = Math.max(FIELD_EDGE_R0_RATIO * unit * cor, pixelGroupPx); + const edgeR1px = Math.max(FIELD_EDGE_R1_RATIO * unit * cor, 2 * pixelGroupPx); + // Avoidance 0 means "ignore non-members" — drop the rects entirely so + // virtual-edge routing stops detouring around them too (and the + // O(members × avoid) routing cost disappears with them). + const effectiveAvoidRects = avoidance === 0 ? [] : avoidRects; + const path = bubblesets.createOutline(memberRects, effectiveAvoidRects, [], { virtualEdges: opts.virtualEdges !== false, - nodeR0: FIELD_NODE_R0 * s * pad, - nodeR1: FIELD_NODE_R1 * s * pad, - edgeR0: FIELD_EDGE_R0 * s * cor, - edgeR1: FIELD_EDGE_R1 * s * cor, - morphBuffer: FIELD_MORPH_BUFFER * s * Math.max(pad, cor), - pixelGroup: Math.max(FIELD_PIXEL_GROUP_MIN, FIELD_PIXEL_GROUP * s), + nodeR0: nodeR0px, + nodeR1: nodeR1px, + edgeR0: edgeR0px, + edgeR1: edgeR1px, + morphBuffer: FIELD_MORPH_BUFFER_RATIO * unit, + pixelGroup: pixelGroupPx, + nonMemberInfluenceFactor: NON_MEMBER_INFLUENCE_FACTOR * avoidance, }); - // Integer stride only (see OUTLINE_SAMPLE_STEP note): Math.round keeps the - // ~8-point cadence proportional to zoom without ever passing a fractional - // index into bubblesets-js' PointPath.get(). - const sampleStep = Math.max(OUTLINE_SAMPLE_MIN_STEP, Math.round(OUTLINE_SAMPLE_STEP * s)); + // Adaptive stride (see OUTLINE_SAMPLE_STEP note): control spacing ≈ 2× the + // thinnest field radius, never denser than the point cap allows. + const minFeaturePx = Math.min(nodeR0px, edgeR0px); + const stride = Math.max( + Math.min(OUTLINE_SAMPLE_STEP, Math.round((2 * minFeaturePx) / pixelGroupPx)), + 1, + Math.ceil(path.length / OUTLINE_MAX_SAMPLED_POINTS) + ); let sampled; try { - sampled = path.sample(sampleStep).simplify(0).bSplines().simplify(0); + sampled = path + .sample(stride) + .simplify(0) + .bSplines() + .simplify(pixelGroupPx * OUTLINE_SIMPLIFY_TOLERANCE_RATIO); } catch { // Boundary guard for bubblesets-js: a degenerate/collapsed outline must // resolve to "no outline" (this function's documented contract) rather // than throwing into the render loop and freezing the bubble canvas. - return []; + return { outer: [], holes: [] }; } - const smoothed = pointPathToArray(sampled); + let outline = pointPathToArray(sampled); // bubblesets can return a self-intersecting ring — virtualEdges corridor // routing crosses itself at some scales, and the bSpline smoothing overshoots // into self-loops when members spread faster than the influence field grows. @@ -112,11 +231,254 @@ function computeOutlinePoints(memberRects, avoidRects = [], opts = {}) { // convex-hull fallback was worse). Repair via polygon self-union, which // resolves the crossings into valid simple rings; keep the largest. The // result still hugs every member — never a blocky hull. - if (polygonSelfIntersects(smoothed)) { - const repaired = repairSelfIntersections(smoothed); - if (repaired.length >= 3) return repaired; + if (polygonSelfIntersects(outline)) { + const repaired = repairSelfIntersections(outline); + if (repaired.length >= 3) outline = repaired; } - return smoothed; + outline = ensureMembersEnclosed(outline, memberRects, nodeR0px, edgeR0px); + const gapPx = Math.min(nodeR0px * avoidance, nodeR1px); + return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, gapPx, nodeR0px); +} + +/** + * Punch interior holes for avoid nodes the hull swallowed. Only nodes whose + * CENTER lies inside the outer ring get a disc (boundary-adjacent ones are + * the field's fjord job); each disc is clipped so it never eats into a + * member's guaranteed clearance, and skipped entirely when that squeezes it + * below HOLE_MIN_RADIUS_RATIO of the node. If the difference would split the + * hull so that a member center leaves the largest piece, the holes are + * dropped wholesale — the one-ring member guarantee outranks avoidance. + * + * @param {Array<{x: number, y: number}>} outer simple hull ring + * @param {Array<{x, y, width, height}>} memberRects + * @param {Array<{x, y, width, height}>} avoidRects + * @param {number} gapPx visual gap around a carved node (scaled by avoidance) + * @param {number} padPx member clearance unit (floored nodeR0) + * @returns {{outer: Array<{x,y}>, holes: Array>}} + */ +function carveAvoidHoles(outer, memberRects, avoidRects, gapPx, padPx) { + const unholed = { outer, holes: [] }; + if (outer.length < 3 || avoidRects.length === 0 || gapPx <= 0) return unholed; + const keepPx = ENCLOSURE_MIN_CLEARANCE_RATIO * padPx; + const discs = []; + for (const rect of avoidRects) { + const r = Math.max(rect.width, rect.height) / 2; + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + if (!pointInPolygon({ x: cx, y: cy }, outer)) continue; + let holeR = r + gapPx; + for (const m of memberRects) { + const mr = Math.max(m.width, m.height) / 2; + const dist = Math.hypot(cx - (m.x + m.width / 2), cy - (m.y + m.height / 2)); + holeR = Math.min(holeR, dist - mr - keepPx); + } + if (holeR < r * HOLE_MIN_RADIUS_RATIO) continue; + discs.push([discRing(cx, cy, holeR)]); + } + if (discs.length === 0) return unholed; + let result; + try { + result = polygonClipping.difference([toClipRing(outer)], ...discs); + } catch { + return unholed; + } + if (!result || result.length === 0) return unholed; + const largest = [...result].sort( + (a, b) => Math.abs(ringSignedArea(b[0])) - Math.abs(ringSignedArea(a[0])) + )[0]; + const newOuter = largest[0].slice(0, -1).map(([x, y]) => ({ x, y })); + if (newOuter.length < 3) return unholed; + // A hole chain may have severed a corridor: every member center must still + // live in the surviving piece, else avoidance loses to the guarantee. + for (const m of memberRects) { + const c = { x: m.x + m.width / 2, y: m.y + m.height / 2 }; + if (!pointInPolygon(c, newOuter)) return unholed; + } + const holes = largest + .slice(1) + .map((h) => h.slice(0, -1).map(([x, y]) => ({ x, y }))) + .filter((h) => h.length >= 3); + return { outer: newOuter, holes }; +} + +/** + * Enforce the enclosure guarantee: every member circle must sit fully inside + * the outline with at least ENCLOSURE_MIN_CLEARANCE_RATIO × padPx of visual + * clearance. Members the fit left grazed or clipped (bubblesets-js validates + * only the center point; avoid-node pressure and B-spline shrinkage do the + * rest) get a disc of radius (nodeRadius + padPx) unioned into the hull, so + * a repaired node ends up with the same visual padding as a well-fitted one. + * When the union comes back in several pieces (a member the field lost + * entirely — its disc floats beside the hull), the pieces are joined with + * corridor-width capsule links so the result is always ONE ring that holds + * every member. + * + * @param {Array<{x: number, y: number}>} points simple outline ring + * @param {Array<{x, y, width, height}>} memberRects + * @param {number} padPx intended padding in px (floored nodeR0) + * @param {number} linkR capsule half-width for joining pieces (floored edgeR0) + * @returns {Array<{x: number, y: number}>} + */ +function ensureMembersEnclosed(points, memberRects, padPx, linkR) { + if (points.length < 3) return points; + const minClearance = padPx * ENCLOSURE_MIN_CLEARANCE_RATIO; + const discs = []; + for (const rect of memberRects) { + const radius = Math.max(rect.width, rect.height) / 2; + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + const signedDist = pointInPolygon({ x: cx, y: cy }, points) ? 1 : -1; + const clearance = signedDist * distanceToPolygonEdge(cx, cy, points) - radius; + if (clearance < minClearance) discs.push([discRing(cx, cy, radius + padPx)]); + } + if (discs.length === 0) return points; + let merged = unionPolygons([[toClipRing(points)]], ...discs); + if (!merged) return points; + merged = connectPolygonComponents(merged, Math.max(linkR, 1)); + return largestOuterRing(merged) ?? points; +} + +/** + * Closed [x, y] ring for polygon-clipping, snapped to the CLIP_SNAP grid + * with consecutive duplicates dropped — dense spline rings otherwise carry + * float-noise degenerate segments that make the clipper throw. + * + * @param {Array<{x: number, y: number}>} points + * @returns {Array<[number, number]>} + */ +function toClipRing(points) { + const ring = []; + for (const p of points) { + const x = Math.round(p.x * CLIP_SNAP) / CLIP_SNAP; + const y = Math.round(p.y * CLIP_SNAP) / CLIP_SNAP; + const prev = ring[ring.length - 1]; + if (prev && prev[0] === x && prev[1] === y) continue; + ring.push([x, y]); + } + const first = ring[0]; + const last = ring[ring.length - 1]; + if (ring.length && (first[0] !== last[0] || first[1] !== last[1])) { + ring.push([first[0], first[1]]); + } + return ring; +} + +/** polygonClipping.union with the render-loop no-throw contract (null on failure). */ +function unionPolygons(...geoms) { + try { + return polygonClipping.union(geoms[0], ...geoms.slice(1)); + } catch (e) { + if (globalThis.__BUBBLE_DEBUG) console.error('union failed:', e.message); + return null; + } +} + +/** + * Join a MultiPolygon's disconnected components into one by unioning + * capsule links (thin rectangles, endpoints extended by linkR so they + * overlap both sides) between each secondary component and the point of the + * largest component nearest to it. Repeats until connected; bails to the + * current state after a few rounds or on a clipping failure, so the caller + * degrades to "largest piece" rather than crashing. + * + * @param {Array>>} multiPolygon + * @param {number} linkR capsule half-width (px) + * @returns {Array>>} + */ +function connectPolygonComponents(multiPolygon, linkR) { + const MAX_ROUNDS = 4; + let polys = multiPolygon; + for (let round = 0; round < MAX_ROUNDS && polys.length > 1; round++) { + const sorted = [...polys].sort( + (a, b) => Math.abs(ringSignedArea(b[0])) - Math.abs(ringSignedArea(a[0])) + ); + const primary = sorted[0][0]; + const links = []; + for (const poly of sorted.slice(1)) { + const [a, b] = nearestVertexPair(primary, poly[0]); + const capsule = capsuleRing(a, b, linkR); + if (capsule) links.push([capsule]); + } + if (links.length === 0) break; + const merged = unionPolygons(polys, ...links); + if (!merged) break; + polys = merged; + } + return polys; +} + +/** Closest pair of vertices between two closed rings of [x, y] pairs. */ +function nearestVertexPair(ringA, ringB) { + let best = [ringA[0], ringB[0]]; + let bestDistSq = Infinity; + for (const a of ringA) { + for (const b of ringB) { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = [a, b]; + } + } + } + return best; +} + +/** + * Closed rectangle ring of half-width r along segment a→b, extended by r + * beyond both endpoints so it always overlaps the shapes it links. + * Null for degenerate (coincident) endpoints. + * + * @param {[number, number]} a + * @param {[number, number]} b + * @param {number} r + * @returns {Array<[number, number]>|null} + */ +function capsuleRing(a, b, r) { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return null; + const ux = (dx / len) * r; + const uy = (dy / len) * r; + const ax = a[0] - ux; + const ay = a[1] - uy; + const bx = b[0] + ux; + const by = b[1] + uy; + return [ + [ax - uy, ay + ux], + [bx - uy, by + ux], + [bx + uy, by - ux], + [ax + uy, ay - ux], + [ax - uy, ay + ux], + ]; +} + +/** Minimum distance from a point to any edge segment of a ring. */ +function distanceToPolygonEdge(x, y, points) { + let min = Infinity; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy; + let t = lengthSq === 0 ? 0 : ((x - a.x) * dx + (y - a.y) * dy) / lengthSq; + t = Math.max(0, Math.min(1, t)); + min = Math.min(min, Math.hypot(x - (a.x + t * dx), y - (a.y + t * dy))); + } + return min; +} + +/** Closed circle ring (as [x, y] pairs) for polygon-clipping input. */ +function discRing(cx, cy, radius) { + const ring = []; + for (let i = 0; i <= ENCLOSURE_DISC_SEGMENTS; i++) { + const angle = (2 * Math.PI * i) / ENCLOSURE_DISC_SEGMENTS; + ring.push([cx + radius * Math.cos(angle), cy + radius * Math.sin(angle)]); + } + return ring; } /** Materialize a bubblesets-js PointPath into a plain {x,y} array. */ @@ -141,19 +503,29 @@ function pointPathToArray(pointPath) { */ function repairSelfIntersections(points) { if (points.length < 4) return points; - const ring = points.map((p) => [p.x, p.y]); - ring.push([points[0].x, points[0].y]); // polygon-clipping expects closed rings let result; try { - result = polygonClipping.union([ring]); + result = polygonClipping.union([toClipRing(points)]); } catch { // A clipping edge case must never throw into the render loop and freeze // the bubble canvas; the caller keeps the (self-intersecting) input. return points; } + return largestOuterRing(result) ?? points; +} + +/** + * Largest outer ring of a polygon-clipping MultiPolygon result, converted + * back to an open {x, y} ring (closing vertex dropped). Null when the result + * holds no usable ring. + * + * @param {Array>>} multiPolygon + * @returns {Array<{x: number, y: number}>|null} + */ +function largestOuterRing(multiPolygon) { let best = null; let bestArea = -1; - for (const poly of result) { + for (const poly of multiPolygon) { const outer = poly[0]; const area = Math.abs(ringSignedArea(outer)); if (area > bestArea) { @@ -161,8 +533,7 @@ function repairSelfIntersections(points) { best = outer; } } - if (!best || best.length < 4) return points; - // Drop polygon-clipping's repeated closing vertex; back to {x, y}. + if (!best || best.length < 4) return null; return best.slice(0, -1).map(([x, y]) => ({ x, y })); } @@ -315,6 +686,7 @@ const STYLE_KEY_FIELDS = [ "virtualEdges", "padding", "corridor", + "avoidance", "label", "labelText", "labelFill", @@ -346,6 +718,7 @@ function styleKey(opts = {}) { export { nodeViewportRect, computeOutlinePoints, + computeOutlineGeometry, polygonSelfIntersects, outlineLabelAnchor, idsKey, diff --git a/src/graph/bubble_layer.js b/src/graph/bubble_layer.js index 82bafb9..a962619 100644 --- a/src/graph/bubble_layer.js +++ b/src/graph/bubble_layer.js @@ -26,7 +26,7 @@ import { DEFAULTS } from '../config.js'; import { nodeViewportRect, - computeOutlinePoints, + computeOutlineGeometry, polygonSelfIntersects, outlineLabelAnchor, idsKey, @@ -54,8 +54,8 @@ class BubbleSetLayer { /** @type {Map, avoidMembers: string[], opts: object}>} */ this.groups = new Map(); // Per-group outline cache: identity key (membership, style, positions, - // viewport size, ratio bucket) + graph-space points. - /** @type {Map}>} */ + // viewport size, ratio bucket) + graph-space rings (outer + avoid holes). + /** @type {Map, graphHoles: Array>}>} */ this.outlines = new Map(); this.rafHandle = null; @@ -195,9 +195,11 @@ 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 cached = this.outlines.get(group); + const points = cached.graphPoints.map((p) => sigma.graphToViewport(p)); + const holes = (cached.graphHoles ?? []).map((h) => h.map((p) => sigma.graphToViewport(p))); const defaults = this.cache.DEFAULTS.BUBBLE_GROUP_STYLE[group] ?? {}; - const drawn = this.#drawGroup(this.ctx, points, state, defaults); + const drawn = this.#drawGroup(this.ctx, points, state, defaults, holes); // Labels paint on the top canvas (afterLayer: "labels") so they read // over member-node labels; the body/outline stayed on the bottom one. if (drawn && state.opts.label) { @@ -223,7 +225,9 @@ class BubbleSetLayer { const out = []; for (const [group, state] of this.groups) { if (state.members.size === 0) continue; - let graphPoints = this.outlines.get(group)?.graphPoints; + const cached = this.outlines.get(group); + let graphPoints = cached?.graphPoints; + let graphHoles = cached?.graphHoles ?? []; if (!graphPoints?.length) { const visibleMembers = []; for (const id of state.members.keys()) { @@ -232,12 +236,13 @@ class BubbleSetLayer { if (!attrs.hidden) visibleMembers.push({ id, attrs }); } if (visibleMembers.length === 0) continue; - graphPoints = this.#fitGraphOutline(state, visibleMembers); + ({ outer: graphPoints, holes: graphHoles } = this.#fitGraphOutline(state, visibleMembers)); } if (!graphPoints || graphPoints.length < 2) continue; out.push({ group, points: graphPoints.map((p) => sigma.graphToViewport(p)), + holes: graphHoles.map((h) => h.map((p) => sigma.graphToViewport(p))), opts: state.opts, defaults: this.cache.DEFAULTS.BUBBLE_GROUP_STYLE[group] ?? {}, }); @@ -260,8 +265,8 @@ class BubbleSetLayer { ctx.save(); try { ctx.setTransform(scale, 0, 0, scale, 0, 0); - for (const { points, opts, defaults } of groups) { - this.#drawGroup(ctx, points, { opts }, defaults); + for (const { points, holes, opts, defaults } of groups) { + this.#drawGroup(ctx, points, { opts }, defaults, holes ?? []); } } finally { ctx.restore(); @@ -334,14 +339,37 @@ class BubbleSetLayer { }); } + // Avoid-node MOVES reshape the fit too (their negative field pushes the + // outline), so their positions/sizes fold into the key as well — without + // this, dragging a non-member into the hull never re-fit it. Skipped when + // avoidance is 0 (the fit ignores avoid rects entirely, so their moves + // must NOT trigger refits). Cost is bounded: past the config node budget + // the manager passes no avoid members at all (see getAvoidMembers). + const avoidanceRaw = Number(state.opts.avoidance ?? 1); + const avoidanceActive = !Number.isFinite(avoidanceRaw) || avoidanceRaw !== 0; + const avoidPositions = []; + if (avoidanceActive) { + for (const id of state.avoidMembers) { + if (!graph.hasNode(id)) continue; + const attrs = graph.getNodeAttributes(id); + if (attrs.hidden) continue; + avoidPositions.push({ + x: attrs.x, + y: attrs.y, + s: sigma.scaleSize(attrs.size ?? DEFAULTS.NODE.SIZE / 2, 1), + }); + } + } + // membersKey/avoidKey are precomputed in #updateGroup, so the per-frame - // cost here is the O(n) position checksum. Hidden flips stay correct: an + // cost here is the O(n) position checksums. Hidden flips stay correct: an // unhide changes visibleMembers.length, and a same-count hidden swap // changes the checksum. No camera/viewport term — the graph-space outline // is the same at every zoom. const key = `${state.membersKey}|${state.avoidKey}|${visibleMembers.length}` + - `|${styleKey(state.opts)}|${positionsChecksum(memberPositions)}`; + `|${styleKey(state.opts)}|${positionsChecksum(memberPositions)}` + + `|${positionsChecksum(avoidPositions)}`; const cached = this.outlines.get(group); if (cached && cached.key === key) return false; @@ -350,36 +378,41 @@ class BubbleSetLayer { return cached !== undefined; } - const graphPoints = this.#fitGraphOutline(state, visibleMembers); - // computeOutlinePoints repairs self-intersections, so a bad fit here is a - // collapse to nothing or the rare unrepairable self-cross. Never let it + const { outer: graphPoints, holes: graphHoles } = this.#fitGraphOutline(state, visibleMembers); + // computeOutlineGeometry repairs self-intersections, so a bad fit here is + // a collapse to nothing or the rare unrepairable self-cross. Never let it // replace a good outline. const selfIntersects = graphPoints.length >= 4 && polygonSelfIntersects(graphPoints); const collapsed = graphPoints.length < 3; if (selfIntersects || collapsed) { if (cached?.graphPoints.length) { - this.outlines.set(group, { key, graphPoints: cached.graphPoints }); + this.outlines.set(group, { + key, + graphPoints: cached.graphPoints, + graphHoles: cached.graphHoles ?? [], + }); return true; } // No prior good outline yet: stay absent until a clean fit appears. return this.outlines.delete(group); } - this.outlines.set(group, { key, graphPoints }); + this.outlines.set(group, { key, graphPoints, graphHoles }); return true; } /** * Fit a group's outline at the ratio-1 REFERENCE viewport scale, then map it - * to graph space for the cache. bubblesets-js' field constants are tuned for - * on-screen pixels, so the fit must run where node sizes are pixel-scale — - * the ratio-1 viewport, which is independent of the current camera. That + * to graph space for the cache. The influence field scales with the mean + * member radius (bubble_geometry.js), and the ratio-1 viewport gives the + * fit real on-screen node radii independent of the current camera. That * makes the outline zoom-invariant: a member is always enclosed and the same * cached hull reprojects to hug the nodes at any zoom. Shared by * #syncGroupOutline and the export path. * * @param {object} state group state (avoidMembers, opts) * @param {Array<{id: string, attrs: object}>} visibleMembers - * @returns {Array<{x: number, y: number}>} graph-space outline (may be empty) + * @returns {{outer: Array<{x: number, y: number}>, + * holes: Array>}} graph-space rings */ #fitGraphOutline(state, visibleMembers) { const graph = this.adapter.graph; @@ -409,41 +442,49 @@ class BubbleSetLayer { const outlineOpts = { virtualEdges: state.opts.virtualEdges, - scale: 1, padding: state.opts.padding, corridor: state.opts.corridor, + avoidance: state.opts.avoidance, }; - let refPoints = computeOutlinePoints(memberRects, avoidRects, outlineOpts); + let geometry = computeOutlineGeometry(memberRects, avoidRects, outlineOpts); // Safety net: if the avoid nodes' negative field collapses the outline, a // hull that ignores avoid nodes beats a vanished group. - if (refPoints.length === 0 && avoidRects.length > 0) { - refPoints = computeOutlinePoints(memberRects, [], outlineOpts); + if (geometry.outer.length === 0 && avoidRects.length > 0) { + geometry = computeOutlineGeometry(memberRects, [], outlineOpts); } // 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 }) - ); + const toGraph = (p) => + sigma.viewportToGraph({ x: cx + (p.x - cx) / r, y: cy + (p.y - cy) / r }); + return { + outer: geometry.outer.map(toGraph), + holes: geometry.holes.map((h) => h.map(toGraph)), + }; } /** - * Paint one group's body + outline from viewport-projected points. Returns - * true when something was painted (false when the outline is too small), - * so the caller knows whether a label belongs on the top canvas. + * Paint one group's body + outline from viewport-projected rings (outer + + * avoid holes, even-odd fill; stroke() outlines every ring). Returns true + * when something was painted (false when the outline is too small), so the + * caller knows whether a label belongs on the top canvas. */ - #drawGroup(ctx, points, { opts }, defaults) { + #drawGroup(ctx, points, { opts }, defaults, holes = []) { if (!points || points.length < 2) return false; const path = new Path2D(); - path.moveTo(points[0].x, points[0].y); - for (let i = 1; i < points.length; i++) path.lineTo(points[i].x, points[i].y); - path.closePath(); + const addRing = (ring) => { + path.moveTo(ring[0].x, ring[0].y); + for (let i = 1; i < ring.length; i++) path.lineTo(ring[i].x, ring[i].y); + path.closePath(); + }; + addRing(points); + for (const hole of holes) if (hole.length >= 3) addRing(hole); ctx.save(); try { ctx.globalAlpha = opts.fillOpacity ?? defaults.fillOpacity ?? 0.25; ctx.fillStyle = opts.fill ?? defaults.fill ?? '#403C53'; - ctx.fill(path); + ctx.fill(path, 'evenodd'); ctx.globalAlpha = opts.strokeOpacity ?? defaults.strokeOpacity ?? 1; ctx.strokeStyle = opts.stroke ?? defaults.stroke ?? '#403C53'; ctx.lineWidth = OUTLINE_STROKE_WIDTH; diff --git a/src/graph/export_svg.js b/src/graph/export_svg.js index 9dbef1b..56f2d87 100644 --- a/src/graph/export_svg.js +++ b/src/graph/export_svg.js @@ -577,16 +577,20 @@ function edgeLabelPrimitives(edge, env) { // --- bubble group primitives --------------------------------------------------- -/** bubble_layer #drawGroup parity: closed body polygon + 2px outline. */ -function bubbleBodyPrimitives({ points, opts = {}, defaults = {} }) { +/** bubble_layer #drawGroup parity: closed body rings (outer + avoid holes, + * even-odd fill) + 2px outline on every ring. */ +function bubbleBodyPrimitives({ points, holes = [], opts = {}, defaults = {} }) { if (!points || points.length < 2) return []; - const d = points.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)} ${fmt(p.y)}`).join(" ") + " Z"; + const ringD = (ring) => + ring.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)} ${fmt(p.y)}`).join(" ") + " Z"; + const d = [points, ...holes.filter((h) => h.length >= 3)].map(ringD).join(" "); return [ { kind: "path", d, fill: safeColor(opts.fill ?? defaults.fill ?? "#403C53"), fillOpacity: opts.fillOpacity ?? defaults.fillOpacity ?? 0.25, + fillRule: "evenodd", stroke: safeColor(opts.stroke ?? defaults.stroke ?? "#403C53"), strokeWidth: OUTLINE_STROKE_WIDTH, strokeOpacity: opts.strokeOpacity ?? defaults.strokeOpacity ?? 1, @@ -699,6 +703,7 @@ function paintAttrs(p) { let s = ""; if (p.fill !== undefined) s += ` fill="${escapeXml(p.fill)}"`; if (p.fillOpacity !== undefined) s += ` fill-opacity="${fmt(p.fillOpacity)}"`; + if (p.fillRule !== undefined) s += ` fill-rule="${escapeXml(p.fillRule)}"`; if (p.stroke !== undefined) s += ` stroke="${escapeXml(p.stroke)}"`; if (p.strokeWidth !== undefined) s += ` stroke-width="${fmt(p.strokeWidth)}"`; if (p.strokeOpacity !== undefined) s += ` stroke-opacity="${fmt(p.strokeOpacity)}"`; diff --git a/tests/bubble-geometry.test.js b/tests/bubble-geometry.test.js index c6ac4af..068650e 100644 --- a/tests/bubble-geometry.test.js +++ b/tests/bubble-geometry.test.js @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { nodeViewportRect, computeOutlinePoints, + computeOutlineGeometry, polygonSelfIntersects, outlineLabelAnchor, idsKey, @@ -102,34 +103,41 @@ describe("computeOutlinePoints", () => { expect(pointInPolygon({ x: 75, y: 50 }, outline)).toBe(true); }); - describe("scale option (zoom-invariant influence field)", () => { - // The avoid-member scenario shrunk by the scale factor must keep its - // shape: members enclosed, avoid node excluded. Without scaling the - // influence constants, the fixed 50 px negative disc of the avoid node - // would swallow the shrunken members' field. - it.each([0.25, 0.1])("preserves member/avoid geometry rescaled by %f", (s) => { + describe("node-size-aware influence field", () => { + // The field derives from the group's MEAN MEMBER RADIUS (the rects carry + // the size information), so the same scene rescaled — different node + // sizes, or a zoomed-out reference viewport — keeps its shape with no + // scale hint: members enclosed, avoid node excluded. Sub-pixel radii are + // excluded here: the 1 px marching-grid floor cannot carve a 1 px avoid + // node (production ratio-1 radii are ≥ 7.5 px). + it.each([1, 0.25])("preserves member/avoid geometry rescaled by %f", (s) => { const members = [rectAt(50 * s, 100 * s, 10 * s), rectAt(250 * s, 100 * s, 10 * s)]; const avoid = [rectAt(150 * s, 100 * s, 10 * s)]; - const outline = computeOutlinePoints(members, avoid, { scale: s }); + const outline = computeOutlinePoints(members, avoid); expect(outline.length).toBeGreaterThan(3); expect(pointInPolygon({ x: 50 * s, y: 100 * s }, outline)).toBe(true); expect(pointInPolygon({ x: 250 * s, y: 100 * s }, outline)).toBe(true); expect(pointInPolygon({ x: 150 * s, y: 100 * s }, outline)).toBe(false); }); + // The legacy opts.scale hint is gone; passing it must not change the fit. + it("ignores a legacy scale option (field self-scales from the rects)", () => { + const members = [rectAt(10, 10, 5), rectAt(60, 30, 5)]; + expect(computeOutlinePoints(members, [], { scale: 0.1 })) + .toEqual(computeOutlinePoints(members, [])); + }); + // Regression: bubblesets-js sample(step) uses `step` as an array index - // stride, so a fractional OUTLINE_SAMPLE_STEP * scale used to index between - // points and return undefined, crashing bSplines()/simplify() with - // "Cannot read properties of undefined". These scales are the real zoom - // field factors (1/sqrt(camera.ratio)) that produce fractional 8*scale - // strides (e.g. 8*0.354 = 2.83) — the exact ones that froze the bubble - // canvas on zoom. They must produce a valid outline, never throw. + // stride — a fractional stride indexes between points and returns + // undefined, crashing bSplines()/simplify(). The stride is a constant + // integer now, but node sizes that used to produce fractional strides + // must keep producing valid outlines at any radius, never throw. it.each([0.354, 0.2, 0.7, 1.58, 2.3, 5])( - "does not throw and returns a hull at fractional-step scale %f", + "does not throw and returns a hull with node radii scaled by %f", (s) => { const members = [rectAt(0, 0, 20 * s), rectAt(20 * s, 0, 20 * s), rectAt(10 * s, 20 * s, 20 * s)]; let outline; - expect(() => { outline = computeOutlinePoints(members, [], { scale: s }); }).not.toThrow(); + expect(() => { outline = computeOutlinePoints(members, []); }).not.toThrow(); expect(outline.length).toBeGreaterThan(3); expect(outline.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); }, @@ -146,7 +154,7 @@ describe("computeOutlinePoints", () => { const s = 0.3; const members = [rectAt(0, 0, 12 * s), rectAt(300 * s, 0, 12 * s), rectAt(600 * s, 0, 12 * s)]; const avoid = [rectAt(150 * s, 40 * s, 12 * s), rectAt(450 * s, -40 * s, 12 * s)]; - const outline = computeOutlinePoints(members, avoid, { scale: s }); + const outline = computeOutlinePoints(members, avoid); expect(outline.length).toBeGreaterThan(3); expect(polygonSelfIntersects(outline)).toBe(false); for (const m of members) { @@ -154,12 +162,12 @@ describe("computeOutlinePoints", () => { } }); - it("keeps a dense zoomed-out group intact where unscaled constants lose it", () => { + it("keeps a dense zoomed-out group intact (vanishing-outline repro)", () => { // Zoomed-out screen geometry: two 1 px members 6 px apart inside two // rings of 32 avoid nodes (the dense-graph repro for the vanishing - // outline). Unscaled, the avoid nodes' negative field corrupts the - // outline (a member ends up outside); scaled it hugs both members - // and excludes the ring. + // outline). With the absolute-pixel constants this collapsed or leaked + // the ring in; the size-aware field hugs both members AND excludes the + // ring with no scale hint at all. const members = [rectAt(0, 0, 1), rectAt(6, 0, 1)]; const avoid = []; for (const radius of [14, 22]) { @@ -169,16 +177,11 @@ describe("computeOutlinePoints", () => { } } - const unscaled = computeOutlinePoints(members, avoid); - const coversBoth = (outline) => - outline.length > 0 && - pointInPolygon({ x: 0, y: 0 }, outline) && - pointInPolygon({ x: 6, y: 0 }, outline); - expect(coversBoth(unscaled)).toBe(false); - - const scaled = computeOutlinePoints(members, avoid, { scale: 0.1 }); - expect(coversBoth(scaled)).toBe(true); - expect(pointInPolygon({ x: 17, y: 0 }, scaled)).toBe(false); + const outline = computeOutlinePoints(members, avoid); + expect(outline.length).toBeGreaterThan(3); + expect(pointInPolygon({ x: 0, y: 0 }, outline)).toBe(true); + expect(pointInPolygon({ x: 6, y: 0 }, outline)).toBe(true); + expect(pointInPolygon({ x: 17, y: 0 }, outline)).toBe(false); }); }); }); @@ -417,9 +420,168 @@ describe("computeOutlinePoints padding/corridor knobs", () => { .toEqual(computeOutlinePoints(pair, [], {})); }); - it("styleKey invalidates on padding/corridor changes", () => { + it("styleKey invalidates on padding/corridor/avoidance changes", () => { expect(styleKey({ padding: 1 })).not.toBe(styleKey({ padding: 1.5 })); expect(styleKey({ corridor: 1 })).not.toBe(styleKey({ corridor: 2 })); + expect(styleKey({ avoidance: 1 })).not.toBe(styleKey({ avoidance: 0 })); + }); +}); + +// Interior avoid holes: the field can only carve fjords from the boundary, +// so a non-member fully INSIDE the hull gets a disc hole punched via polygon +// difference (computeOutlineGeometry.holes; rendered even-odd). +describe("computeOutlineGeometry avoid holes", () => { + // 8 members in a ring leave a covered pocket at (60,60); the avoid node + // sits in that pocket, fully interior to the hull. + const ringMembers = [ + [0, 0], [60, 0], [120, 0], [0, 60], [120, 60], [0, 120], [60, 120], [120, 120], + ].map(([x, y]) => rectAt(x, y, 20)); + const interiorAvoid = [rectAt(60, 60, 15)]; + + it("punches a hole around an interior non-member", () => { + const { outer, holes } = computeOutlineGeometry(ringMembers, interiorAvoid, {}); + expect(pointInPolygon({ x: 60, y: 60 }, outer)).toBe(true); // interior of the OUTER ring + expect(holes.length).toBeGreaterThanOrEqual(1); + expect(holes.some((h) => pointInPolygon({ x: 60, y: 60 }, h))).toBe(true); + }); + + it("punches no holes at avoidance 0", () => { + const { holes } = computeOutlineGeometry(ringMembers, interiorAvoid, { avoidance: 0 }); + expect(holes).toEqual([]); + }); + + it("skips the hole when the non-member is squeezed against a member", () => { + // Avoid node overlapping a member's clearance zone: carving would eat + // into the guarantee, so it must be skipped. + const { holes } = computeOutlineGeometry(ringMembers, [rectAt(85, 60, 15)], {}); + expect(holes).toEqual([]); + }); + + it("computeOutlinePoints returns the same outer ring (holes dropped)", () => { + const geometry = computeOutlineGeometry(ringMembers, interiorAvoid, {}); + expect(computeOutlinePoints(ringMembers, interiorAvoid, {})).toEqual(geometry.outer); + }); +}); + +describe("computeOutlinePoints avoidance knob", () => { + // Two members with a non-member between them: at default avoidance the + // corridor routes around it (excluded); at 0 the negative field is off and + // the hull may cover it; higher values keep steering around it. + const members = [rectAt(50, 100), rectAt(250, 100)]; + const avoid = [rectAt(150, 100)]; + + it("avoidance 0 disables the negative field (hull covers the non-member)", () => { + const outline = computeOutlinePoints(members, avoid, { avoidance: 0 }); + expect(pointInPolygon({ x: 50, y: 100 }, outline)).toBe(true); + expect(pointInPolygon({ x: 250, y: 100 }, outline)).toBe(true); + expect(pointInPolygon({ x: 150, y: 100 }, outline)).toBe(true); + // With the field off, ignoring avoid rects entirely gives the same hull. + expect(outline).toEqual(computeOutlinePoints(members, [], { avoidance: 0 })); + }); + + it("default and strong avoidance keep the non-member excluded", () => { + for (const avoidance of [undefined, 1, 3]) { + const outline = computeOutlinePoints(members, avoid, { avoidance }); + expect(pointInPolygon({ x: 50, y: 100 }, outline)).toBe(true); + expect(pointInPolygon({ x: 250, y: 100 }, outline)).toBe(true); + expect(pointInPolygon({ x: 150, y: 100 }, outline)).toBe(false); + } + }); + + it("clamps invalid avoidance values to the default instead of collapsing", () => { + expect(computeOutlinePoints(members, avoid, { avoidance: NaN })) + .toEqual(computeOutlinePoints(members, avoid, {})); + expect(computeOutlinePoints(members, avoid, { avoidance: -5 })) + .toEqual(computeOutlinePoints(members, avoid, { avoidance: 0 })); + expect(computeOutlinePoints(members, avoid, { avoidance: 100 })) + .toEqual(computeOutlinePoints(members, avoid, { avoidance: 4 })); + }); +}); + +// Enclosure guarantee: bubblesets-js validates only member CENTERS, so +// avoid-node pressure plus B-spline shrinkage could leave a member circle +// clipped by its own hull (measured -12 px on the ringed scenario below). +// computeOutlinePoints must union a padding disc for any grazed member so +// every member circle sits fully inside the outline. +describe("computeOutlinePoints member-enclosure guarantee", () => { + /** Signed clearance between a member circle and the outline: distance from + * center to the nearest outline edge (negative when the center is outside) + * minus the radius. >= 0 means the full circle is inside. */ + const circleClearance = (x, y, r, outline) => { + let min = Infinity; + for (let i = 0; i < outline.length; i++) { + const a = outline[i]; + const b = outline[(i + 1) % outline.length]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSq = dx * dx + dy * dy; + let t = lengthSq === 0 ? 0 : ((x - a.x) * dx + (y - a.y) * dy) / lengthSq; + t = Math.max(0, Math.min(1, t)); + min = Math.min(min, Math.hypot(x - (a.x + t * dx), y - (a.y + t * dy))); + } + return (pointInPolygon({ x, y }, outline) ? min : -min) - r; + }; + + it("fully encloses a member ringed by avoid nodes (regression: clipped hull)", () => { + // Without the guarantee this exact layout clips the (380,110) member by + // ~12 px: the avoid ring squeezes the contour through the node body while + // its center stays inside (the only thing bubblesets-js checks). + const members = [rectAt(100, 100, 25), rectAt(160, 120, 25), rectAt(380, 110, 25)]; + const avoid = [rectAt(430, 60, 25), rectAt(440, 160, 25), rectAt(330, 170, 25), rectAt(330, 50, 25)]; + const outline = computeOutlinePoints(members, avoid); + for (const [x, y] of [[100, 100], [160, 120], [380, 110]]) { + expect(circleClearance(x, y, 25, outline)).toBeGreaterThanOrEqual(0); + } + }); + + it("keeps every member circle inside across dense mixed fields", () => { + const members = [rectAt(100, 100, 20), rectAt(150, 140, 20), rectAt(300, 120, 20), rectAt(420, 200, 20)]; + const avoid = [ + rectAt(200, 80, 20), rectAt(240, 140, 20), rectAt(360, 90, 20), rectAt(350, 180, 20), + rectAt(460, 140, 20), rectAt(120, 180, 20), rectAt(470, 260, 20), + ]; + const outline = computeOutlinePoints(members, avoid); + for (const m of members) { + const r = m.width / 2; + expect(circleClearance(m.x + r, m.y + r, r, outline)).toBeGreaterThanOrEqual(0); + } + }); + + it("repaired outlines stay simple polygons (no self-intersections)", () => { + const members = [rectAt(100, 100, 25), rectAt(160, 120, 25), rectAt(380, 110, 25)]; + const avoid = [rectAt(430, 60, 25), rectAt(440, 160, 25), rectAt(330, 170, 25), rectAt(330, 50, 25)]; + const outline = computeOutlinePoints(members, avoid); + expect(outline.length).toBeGreaterThan(3); + expect(polygonSelfIntersects(outline)).toBe(false); + }); + + it("connects members the field lost entirely (ultra-low padding, one ring)", () => { + // At padding 0.1 the influence field is near grid resolution: distant + // members disconnect and their repair discs float beside the main hull. + // The union must come back as ONE simple ring (capsule links) that still + // encloses every member — this exact layout used to drop 5 of 10 members. + const spots = [ + [250, 200], [300, 180], [280, 250], [220, 270], [340, 230], + [310, 300], [260, 340], [370, 170], [200, 210], [230, 620], + ]; + const members = spots.map(([x, y]) => rectAt(x, y, 9)); + const outline = computeOutlinePoints(members, [], { padding: 0.1, corridor: 0.25 }); + expect(outline.length).toBeGreaterThan(3); + expect(polygonSelfIntersects(outline)).toBe(false); + for (const [x, y] of spots) { + expect(circleClearance(x, y, 9, outline)).toBeGreaterThanOrEqual(0); + } + }); + + it("guarantee holds for small node radii (zoomed-out reference fit)", () => { + const s = 0.25; + const members = [rectAt(100 * s, 100 * s, 25 * s), rectAt(160 * s, 120 * s, 25 * s), rectAt(380 * s, 110 * s, 25 * s)]; + const avoid = [rectAt(430 * s, 60 * s, 25 * s), rectAt(440 * s, 160 * s, 25 * s), rectAt(330 * s, 170 * s, 25 * s), rectAt(330 * s, 50 * s, 25 * s)]; + const outline = computeOutlinePoints(members, avoid); + for (const m of members) { + const r = m.width / 2; + expect(circleClearance(m.x + r, m.y + r, r, outline)).toBeGreaterThanOrEqual(0); + } }); }); diff --git a/tests/bubble-layer-avoid-invalidation.test.js b/tests/bubble-layer-avoid-invalidation.test.js new file mode 100644 index 0000000..fe78172 --- /dev/null +++ b/tests/bubble-layer-avoid-invalidation.test.js @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// ========================================================================== +// Avoid-node cache invalidation: moving a NON-member reshapes the fit (its +// negative field pushes the outline), so it must re-fit — unless avoidance +// is 0, where the fit ignores avoid rects and a move must NOT refit. +// computeOutlineGeometry is mocked to count refits. +// ========================================================================== + +vi.mock("../src/graph/bubble_geometry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, computeOutlineGeometry: vi.fn() }; +}); + +import { BubbleSetLayer } from "../src/graph/bubble_layer.js"; +import { computeOutlineGeometry } from "../src/graph/bubble_geometry.js"; + +const CLEAN = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }]; + +function makeSigma(camera) { + const handlers = new Map(); + const ctx = new Proxy({}, { get: () => () => {} }); + const canvas = { width: 0, height: 0, style: {}, getContext: () => ctx, remove: () => {} }; + const sigma = { + pixelRatio: 1, + createCanvasContext: () => sigma, + getCanvases: () => ({ bubbleSets: canvas, bubbleSetsLabels: canvas }), + getDimensions: () => ({ width: 800, height: 600 }), + getCamera: () => ({ getState: () => ({ ...camera }) }), + graphToViewport: (g) => ({ x: g.x, y: g.y }), + viewportToGraph: (v) => ({ x: v.x, y: v.y }), + scaleSize: (s, ratio = camera.ratio) => s / Math.sqrt(ratio), + on: (ev, fn) => handlers.set(ev, fn), + off: () => {}, + emit: (ev) => handlers.get(ev)?.(), + }; + return { sigma }; +} + +function makeGraph() { + const map = new Map([ + ["a", { x: 0, y: 0, size: 10, hidden: false }], + ["b", { x: 10, y: 10, size: 10, hidden: false }], + ["c", { x: 100, y: 100, size: 10, hidden: false }], + ]); + return { + hasNode: (id) => map.has(id), + getNodeAttributes: (id) => map.get(id), + moveNode: (id, x, y) => Object.assign(map.get(id), { x, y }), + }; +} + +const makeCache = () => ({ + DEFAULTS: { BUBBLE_GROUP_STYLE: { groupOne: { label: false } } }, +}); + +let rafQueue = []; +beforeEach(() => { + rafQueue = []; + vi.mocked(computeOutlineGeometry).mockReset(); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); + globalThis.Path2D = class { moveTo() {} lineTo() {} closePath() {} }; + globalThis.requestAnimationFrame = (cb) => { rafQueue.push(cb); return rafQueue.length; }; + globalThis.cancelAnimationFrame = () => {}; + if (!globalThis.window) globalThis.window = {}; + globalThis.window.devicePixelRatio = 1; +}); +const flush = () => { const q = rafQueue; rafQueue = []; q.forEach((cb) => cb()); }; + +const STYLE = { fill: "#e74c3c", stroke: "#e74c3c", fillOpacity: 0.25, strokeOpacity: 1, label: false }; + +function makeLayerWithGroup(style) { + const camera = { x: 0, y: 0, ratio: 1, angle: 0 }; + const { sigma } = makeSigma(camera); + const graph = makeGraph(); + const layer = new BubbleSetLayer({ sigma, graph }, makeCache()); + layer.getGroupHandle("groupOne").update({ members: ["a", "b"], avoidMembers: ["c"], ...style }); + flush(); + expect(vi.mocked(computeOutlineGeometry)).toHaveBeenCalledTimes(1); + return { layer, graph }; +} + +describe("BubbleSetLayer — avoid-node move invalidation", () => { + it("re-fits when an avoid node moves (avoidance active)", () => { + const { layer, graph } = makeLayerWithGroup(STYLE); + graph.moveNode("c", 5, 5); // into the group's area + layer.scheduleRedraw(); + flush(); + expect(vi.mocked(computeOutlineGeometry)).toHaveBeenCalledTimes(2); + }); + + it("does NOT re-fit on an avoid move when avoidance is 0", () => { + const { layer, graph } = makeLayerWithGroup({ ...STYLE, avoidance: 0 }); + graph.moveNode("c", 5, 5); + layer.scheduleRedraw(); + flush(); + expect(vi.mocked(computeOutlineGeometry)).toHaveBeenCalledTimes(1); + }); + + it("does not re-fit when nothing moved (cache stays hot)", () => { + const { layer } = makeLayerWithGroup(STYLE); + layer.scheduleRedraw(); + flush(); + expect(vi.mocked(computeOutlineGeometry)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/bubble-layer-malformed.test.js b/tests/bubble-layer-malformed.test.js index e93e7b1..ff83777 100644 --- a/tests/bubble-layer-malformed.test.js +++ b/tests/bubble-layer-malformed.test.js @@ -4,17 +4,17 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Malformed-fit guard: when bubblesets' bSpline smoothing loops the contour // over itself (rough edges / phantom lobe, seen on deep zoom-in), the layer // must KEEP the last good outline instead of caching the self-intersecting -// one. computeOutlinePoints is mocked so we control exactly which fit each +// one. computeOutlineGeometry is mocked so we control exactly which fit each // refit returns; polygonSelfIntersects stays real (it decides the fallback). // ========================================================================== vi.mock("../src/graph/bubble_geometry.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, computeOutlinePoints: vi.fn() }; + return { ...actual, computeOutlineGeometry: vi.fn() }; }); import { BubbleSetLayer } from "../src/graph/bubble_layer.js"; -import { computeOutlinePoints } from "../src/graph/bubble_geometry.js"; +import { computeOutlineGeometry } from "../src/graph/bubble_geometry.js"; const CLEAN = [{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }]; // bow-tie: edges (0→1) and (2→3) cross @@ -55,7 +55,7 @@ const makeCache = () => ({ let rafQueue = []; beforeEach(() => { rafQueue = []; - vi.mocked(computeOutlinePoints).mockReset(); + vi.mocked(computeOutlineGeometry).mockReset(); globalThis.Path2D = class { moveTo() {} lineTo() {} closePath() {} }; globalThis.requestAnimationFrame = (cb) => { rafQueue.push(cb); return rafQueue.length; }; globalThis.cancelAnimationFrame = () => {}; @@ -73,13 +73,13 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); // First fit → clean square, cached. - vi.mocked(computeOutlinePoints).mockReturnValue(CLEAN); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); expect(layer.outlines.get("groupOne").graphPoints).toEqual(CLEAN); // A style change forces an identity-key re-fit; this fit is malformed. - vi.mocked(computeOutlinePoints).mockReturnValue(BOWTIE); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: BOWTIE, holes: [] }); layer.getGroupHandle("groupOne").update({ ...STYLE, fillOpacity: 0.5 }); flush(); @@ -93,14 +93,14 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); // First fit → clean square, cached. - vi.mocked(computeOutlinePoints).mockReturnValue(CLEAN); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); // Export reprojects the cached graph-space outline (zoom-invariant); a // later malformed fit can't leak in because export uses the cache, not a // fresh fit. Projection is identity in this harness, so points compare equal. - vi.mocked(computeOutlinePoints).mockReturnValue(BOWTIE); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: BOWTIE, holes: [] }); const groups = layer.exportOutlines(); expect(groups).toHaveLength(1); @@ -112,12 +112,12 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const { sigma } = makeSigma(camera); const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); - vi.mocked(computeOutlinePoints).mockReturnValue(CLEAN); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); const NEXT = [{ x: 1, y: 1 }, { x: 9, y: 1 }, { x: 9, y: 9 }, { x: 1, y: 9 }]; - vi.mocked(computeOutlinePoints).mockReturnValue(NEXT); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: NEXT, holes: [] }); layer.getGroupHandle("groupOne").update({ ...STYLE, fillOpacity: 0.5 }); // identity-key re-fit flush(); @@ -131,11 +131,11 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const { sigma } = makeSigma(camera); const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); - vi.mocked(computeOutlinePoints).mockReturnValue(CLEAN); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); - vi.mocked(computeOutlinePoints).mockReturnValue([]); // fit collapses + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: [], holes: [] }); // fit collapses layer.getGroupHandle("groupOne").update({ ...STYLE, fillOpacity: 0.5 }); // identity-key re-fit flush(); @@ -147,11 +147,11 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const { sigma } = makeSigma(camera); const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); - // computeOutlinePoints repairs self-intersections in the real path; here it + // computeOutlineGeometry repairs self-intersections in the real path; here it // is mocked to a bow-tie (repair bypassed) with no prior good outline, so // the layer must NOT paint the self-crossing ring — it stays absent until a // clean fit appears (never a phantom shape or blocky hull). - vi.mocked(computeOutlinePoints).mockReturnValue(BOWTIE); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: BOWTIE, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); @@ -163,20 +163,20 @@ describe("BubbleSetLayer — malformed-fit guard", () => { const { sigma, emit } = makeSigma(camera); const layer = new BubbleSetLayer({ sigma, graph: makeGraph() }, makeCache()); - vi.mocked(computeOutlinePoints).mockReturnValue(CLEAN); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); layer.getGroupHandle("groupOne").update({ members: ["a", "b"], ...STYLE }); flush(); // Any subsequent fit would change the cached points; assert none happens on // zoom. The cached graph outline stays CLEAN and is only reprojected. - vi.mocked(computeOutlinePoints).mockClear(); - vi.mocked(computeOutlinePoints).mockReturnValue(BOWTIE); + vi.mocked(computeOutlineGeometry).mockClear(); + vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: BOWTIE, holes: [] }); for (const ratio of [8, 0.5, 0.1, 4]) { camera.ratio = ratio; emit("afterRender"); flush(); } - expect(computeOutlinePoints).not.toHaveBeenCalled(); + expect(computeOutlineGeometry).not.toHaveBeenCalled(); expect(layer.outlines.get("groupOne").graphPoints).toEqual(CLEAN); }); }); diff --git a/tests/export-svg.test.js b/tests/export-svg.test.js index 06f66ae..5bdcff1 100644 --- a/tests/export-svg.test.js +++ b/tests/export-svg.test.js @@ -519,10 +519,31 @@ describe("bubble groups", () => { }); expect(svg).toContain( - '', + '', ); }); + it("renders avoid holes as extra even-odd subpaths", () => { + const scene = makeScene(); + + const svg = build(scene, { + bubbleGroups: [ + { + group: "groupOne", + points: SQUARE, + holes: [[{ x: 140, y: 140 }, { x: 160, y: 140 }, { x: 160, y: 160 }, { x: 140, y: 160 }]], + opts: { fill: "#e74c3c", fillOpacity: 0.3, stroke: "#111111", strokeOpacity: 0.8 }, + defaults: {}, + }, + ], + }); + + expect(svg).toContain( + 'd="M 100 100 L 200 100 L 200 200 L 100 200 Z M 140 140 L 160 140 L 160 160 L 140 160 Z"', + ); + expect(svg).toContain('fill-rule="evenodd"'); + }); + it("pushes an off-path label along the outward normal by the standoff", () => { const scene = makeScene(); From 4b4186f9d375bbc1e699e79936ec59235c502054 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Tue, 21 Jul 2026 16:46:44 +0200 Subject: [PATCH 03/18] feat(styling): add bubble-set avoidance control and retune defaults New per-group Avoidance slider (0-3): multiplies the non-member negative field; 0 lets the hull cover other nodes (and skips avoid routing entirely), higher steers around them harder. Defaults retuned for the node-size-aware field: padding 0.1, corridor 0.25 (razor-tight hugs with thin finger corridors, per live feedback); padding/corridor slider minimum lowered to 0.05. Saved workspaces keep their stored values; missing avoidance fills to 1 via the existing defaults merge. --- src/config.js | 20 ++++++++++++-------- src/graph/bubble_sets.js | 4 ++++ src/managers/ui_style_div.js | 12 ++++++++++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/config.js b/src/config.js index 296e2a4..3253302 100644 --- a/src/config.js +++ b/src/config.js @@ -202,8 +202,9 @@ const DEFAULTS = { stroke: '#C33D35', strokeOpacity: 1, virtualEdges: true, - padding: 1, - corridor: 1, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group one', labelFill: '#fff', @@ -224,8 +225,9 @@ const DEFAULTS = { stroke: '#403c53', strokeOpacity: 1, virtualEdges: true, - padding: 1, - corridor: 1, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group two', labelFill: '#fff', @@ -246,8 +248,9 @@ const DEFAULTS = { stroke: '#8CA6D9', strokeOpacity: 1, virtualEdges: true, - padding: 1, - corridor: 1, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group three', labelFill: '#fff', @@ -268,8 +271,9 @@ const DEFAULTS = { stroke: '#EFB0AA', strokeOpacity: 1, virtualEdges: true, - padding: 1, - corridor: 1, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group four', labelFill: '#fff', diff --git a/src/graph/bubble_sets.js b/src/graph/bubble_sets.js index 031237a..7454fc2 100644 --- a/src/graph/bubble_sets.js +++ b/src/graph/bubble_sets.js @@ -101,6 +101,9 @@ class GraphBubbleSetManager { case "Corridor Width": bStyle.corridor = value; break; + case "Avoidance": + bStyle.avoidance = value; + break; case "Label": bStyle.label = value; break; @@ -184,6 +187,7 @@ class GraphBubbleSetManager { }; syncSliderInput("Padding", bubbleStyle.padding); syncSliderInput("Corridor Width", bubbleStyle.corridor); + syncSliderInput("Avoidance", bubbleStyle.avoidance); syncSliderInput("Label Font Size", bubbleStyle.labelFontSize); syncSliderInput("Label Offset X", bubbleStyle.labelOffsetX); syncSliderInput("Label Offset Y", bubbleStyle.labelOffsetY); diff --git a/src/managers/ui_style_div.js b/src/managers/ui_style_div.js index ce8e96a..87bdfa4 100644 --- a/src/managers/ui_style_div.js +++ b/src/managers/ui_style_div.js @@ -1211,7 +1211,7 @@ function createStyleDiv(cache) { appendLabel(rowPadding, "Padding", "How far the bubble body extends past its member nodes — lower for a tighter hug, higher for more breathing room."); createNumericalSlider(rowPadding, `Bubble Set ${group} Padding`, bs.padding ?? 1, - {min: 0.25, max: 3, step: 0.05}, + {min: 0.05, max: 3, step: 0.05}, `How far bubble set ${tabIndex} extends past its member nodes.`, false); // Corridor Width (virtual-edge influence field multiplier) @@ -1219,9 +1219,17 @@ function createStyleDiv(cache) { appendLabel(rowCorridor, "Corridor Width", "Thickness of the connecting arms that reach outlying member nodes."); createNumericalSlider(rowCorridor, `Bubble Set ${group} Corridor Width`, bs.corridor ?? 1, - {min: 0.25, max: 3, step: 0.05}, + {min: 0.05, max: 3, step: 0.05}, `Thickness of bubble set ${tabIndex}'s connecting arms to outlying members.`, false); + // Avoidance (non-member negative influence multiplier) + const rowAvoidance = createNewRow(panel); + appendLabel(rowAvoidance, "Avoidance", + "How strongly the bubble steers around nodes that are not in the group — 0 lets it cover them freely."); + createNumericalSlider(rowAvoidance, `Bubble Set ${group} Avoidance`, bs.avoidance ?? 1, + {min: 0, max: 3, step: 0.05}, + `How strongly bubble set ${tabIndex} steers around non-member nodes.`, false); + // Label (toggle + text input) const rowLabel = createNewRow(panel); appendLabel(rowLabel, "Label Text"); From b8132ab211a6a7d927ab5e821bc94ce875dd2609 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Tue, 21 Jul 2026 16:47:14 +0200 Subject: [PATCH 04/18] chore(scripts): fix ruff findings in LaTeX submission packager Remove an unused variable and four extraneous f-string prefixes. --- scripts/package_latex_submission.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/package_latex_submission.py b/scripts/package_latex_submission.py index c3ee20a..f8d9bdc 100755 --- a/scripts/package_latex_submission.py +++ b/scripts/package_latex_submission.py @@ -135,13 +135,12 @@ def clean_build_artifacts(work_dir: Path) -> None: def main() -> None: venue = pick_venue() - venue_dir = MANUSCRIPT / venue print(f"Packaging '{venue}' submission …\n") files = collect_files(venue) missing = [src for src, _ in files if not src.exists()] if missing: - die(f"Missing files:\n " + "\n ".join(str(p) for p in missing)) + die("Missing files:\n " + "\n ".join(str(p) for p in missing)) # 1. Create temp directory and copy files tmp = Path(tempfile.mkdtemp(prefix="gll-submission-")) @@ -152,10 +151,10 @@ def main() -> None: # 2. Flatten paths in main.tex flatten_main_tex(tmp / "main.tex") - print(f"\n Flattened paths in main.tex") + print("\n Flattened paths in main.tex") # 3. Compile test PDF - print(f"\n Compiling test PDF …") + print("\n Compiling test PDF …") pdf = compile_pdf(tmp) print(f" OK — {pdf.stat().st_size / 1024:.0f} KB\n") @@ -181,7 +180,7 @@ def main() -> None: shutil.rmtree(tmp) print(f"\n Created {final_zip.relative_to(REPO_ROOT)} ({size_mb:.1f} MB)") - print(f" Temp directory removed.") + print(" Temp directory removed.") if __name__ == "__main__": From 2910934288374009b0e41a89e24aacf2735f05a1 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 10:01:32 +0200 Subject: [PATCH 05/18] feat(graph): smooth bubble curves, honest guarantees, hole fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paint bubble rings as Catmull-Rom cubic Béziers (tension 1/6) on both the live canvas and the SVG export, consuming identical control points from the new bubble_smoothing.js — the polygon stays the source of truth for all math, only the painters interpolate. Raise the outline simplify tolerance to half a grid cell (fewer control points now mean smoother curves, not facets) and replace rectangular straggler links with stadium capsules. The member-enclosure guarantee now measures clearance against the sampled smoothed curve (what is actually painted) and repairs in up to three rounds with growing discs, since disc-union junctions can themselves smooth inward past a member. Avoidance becomes boolean in the geometry (any value > 0 = ON; multiplying bubblesets' non-member factor never worked — the library's marching iteration fights back), and squeezed avoid holes fall back to the largest member-clearance-respecting radius instead of silently swallowing the node (floor 0.35× its radius, documented ceiling). --- src/graph/bubble_geometry.js | 140 +++++++++++------- src/graph/bubble_layer.js | 15 +- src/graph/bubble_smoothing.js | 82 ++++++++++ src/graph/export_svg.js | 18 ++- tests/bubble-geometry.test.js | 99 +++++++++++-- tests/bubble-layer-avoid-invalidation.test.js | 2 +- tests/bubble-layer-canvas-css-size.test.js | 1 + tests/bubble-layer-export.test.js | 3 + tests/bubble-layer-malformed.test.js | 2 +- tests/bubble-layer-repro.test.js | 13 +- tests/export-svg.test.js | 28 +++- 11 files changed, 315 insertions(+), 88 deletions(-) create mode 100644 src/graph/bubble_smoothing.js diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index 397861b..ea3bbde 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -9,6 +9,7 @@ */ import { bubblesets, polygonClipping } from "../lib/graphology.bundle.mjs"; import { pointInPolygon } from "./lasso_geometry.js"; +import { sampleSmoothedRing } from "./bubble_smoothing.js"; // PointPath post-processing per the bubblesets-js README: sample the raw // marching-squares outline, B-spline it and drop collinear points. 8 is the @@ -30,9 +31,11 @@ const OUTLINE_MAX_SAMPLED_POINTS = 1200; // the dense adaptive-stride spline carries grid-scale micro-wiggle that (a) // reads as jaggies and (b) produces near-degenerate segments that make // polygon-clipping throw ("Unable to complete output ring"), silently -// disabling the enclosure guarantee. A quarter-cell tolerance erases the -// noise without touching real features. -const OUTLINE_SIMPLIFY_TOLERANCE_RATIO = 0.25; +// disabling the enclosure guarantee. Half a cell erases the noise without +// touching real features — the painters render the ring through +// smoothClosedPath, so FEWER control points mean a smoother curve, not +// visible facets. +const OUTLINE_SIMPLIFY_TOLERANCE_RATIO = 0.5; // Snap grid (px⁻¹) for rings handed to polygon-clipping — collapses the // float-noise duplicate/degenerate vertices that trip its sweep line. const CLIP_SNAP = 32; @@ -54,28 +57,31 @@ const FIELD_MORPH_BUFFER_RATIO = 10 / REFERENCE_NODE_RADIUS; const FIELD_PIXEL_GROUP_RATIO = 4 / REFERENCE_NODE_RADIUS; // User-tunable multipliers on the influence field (per-group style): // padding scales the node field (how far the body extends past members), -// corridor scales the virtual-edge field (arm thickness to outliers), -// avoidance scales the negative field of non-member nodes (0 = the hull may -// freely cover them, higher = it steers around them harder). +// corridor scales the virtual-edge field (arm thickness to outliers). // morphBuffer deliberately takes NONE of them: it is the obstacle-routing // clearance for virtual edges, and scaling it with the corridor knob made // wider corridors also REROUTE around avoid nodes in long detours that // painted as phantom lobes in empty space. const GEOMETRY_MULTIPLIER_MIN = 0.05; const GEOMETRY_MULTIPLIER_MAX = 4; -// bubblesets-js' own default non-member (negative) energy factor; the -// avoidance knob multiplies it. Unlike padding/corridor, 0 is a valid value -// (avoidance off — the library skips the negative pass entirely). +// bubblesets-js' own default non-member (negative) energy factor. Avoidance +// is a SWITCH, not a multiplier: above ~1× the library's marching iteration +// fights back (it boosts member energy and decays this factor per iteration +// until the contour holds all members), so scaling it bought distorted +// shapes, not more avoidance. Persisted values stay numeric for JSON +// back-compat: 0 = off, anything > 0 = on. const NON_MEMBER_INFLUENCE_FACTOR = -0.8; -const AVOIDANCE_MAX = 4; // Interior avoid-node holes: the energy field can only carve FJORDS from the // hull boundary — marching squares yields one outer contour, so a non-member // fully inside the hull is topologically invisible to it. carveAvoidHoles // punches discs for those via polygon difference instead (rendered with -// even-odd fill). A hole squeezed by nearby members below this fraction of -// the node's own radius is skipped — the node lives in a corridor pinch -// where a carve would read as noise. -const HOLE_MIN_RADIUS_RATIO = 0.6; +// even-odd fill). A member-clearance-squeezed hole falls back to the largest +// radius that still respects the clearance (body-hugging carve) — no silent +// swallowing — down to this floor, below which a carve no longer reads. +// ponytail: documented ceiling — a non-member fused into a member's +// clearance zone (holeR < 0.35 × its radius) stays covered; doing better +// needs an outline algorithm with organic field-carved holes. +const HOLE_MIN_RADIUS_RATIO = 0.35; // Enclosure guarantee: bubblesets-js only validates that each member's CENTER // is inside the contour (PointPath.containsElements), and the B-spline // smoothing shrinks lobes inward, so avoid-node pressure can leave a member @@ -86,6 +92,12 @@ const HOLE_MIN_RADIUS_RATIO = 0.6; // ring). const ENCLOSURE_MIN_CLEARANCE_RATIO = 0.4; const ENCLOSURE_DISC_SEGMENTS = 32; +// Disc-union junction corners can themselves smooth inward past a member, so +// the repair re-checks the smoothed result and retries with a grown disc. +const ENCLOSURE_MAX_REPAIR_ROUNDS = 3; +// Hole breathing room never drops below this fraction of the carved node's +// own radius — the grid-floored padding gap reads too snug on large nodes. +const HOLE_GAP_RADIUS_RATIO = 0.25; // Marching-squares grid cell: never below 1 px (pixelGroup 0 hangs the // library; sub-pixel cells just waste time) and never above the library's // 4 px default — a coarser grid visibly wobbles the outline around large @@ -93,6 +105,8 @@ const ENCLOSURE_DISC_SEGMENTS = 32; // small nodes. const FIELD_PIXEL_GROUP_MIN = 1; const FIELD_PIXEL_GROUP_MAX = 4; +// Semicircular cap resolution for the stadium-shaped straggler links. +const CAPSULE_CAP_SEGMENTS = 8; /** * Axis-aligned square around a node's viewport position. @@ -141,8 +155,8 @@ function meanMemberRadius(memberRects) { * padding multiplies the node influence radii (body extent past members); * corridor multiplies the edge influence radii (arm thickness). Both are * user style knobs, clamped to [0.05, 4]; 1 = one mean-radius of margin. - * avoidance multiplies the negative energy of avoid rects, clamped to - * [0, 4]: 0 lets the hull cover non-members, >1 steers around them harder. + * avoidance is a switch (numeric for JSON back-compat): 0 lets the hull + * cover non-members, anything > 0 steers around them and carves holes. * @returns {Array<{x: number, y: number}>} closed polygon (empty when no * members or the outline collapsed) */ @@ -169,12 +183,10 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { Math.min(GEOMETRY_MULTIPLIER_MAX, Math.max(GEOMETRY_MULTIPLIER_MIN, Number(v) || 1)); const pad = clampMult(opts.padding ?? 1); const cor = clampMult(opts.corridor ?? 1); - // 0 is meaningful for avoidance (off), so NaN/missing fall back to 1 - // explicitly instead of through the || 1 shortcut the other knobs use. + // Avoidance is boolean (see NON_MEMBER_INFLUENCE_FACTOR): any legacy saved + // value > 0 means ON; NaN/missing fall back to ON. const avoidRaw = Number(opts.avoidance ?? 1); - const avoidance = Number.isFinite(avoidRaw) - ? Math.min(AVOIDANCE_MAX, Math.max(0, avoidRaw)) - : 1; + const avoidance = Number.isFinite(avoidRaw) && avoidRaw <= 0 ? 0 : 1; const unit = meanMemberRadius(memberRects); const pixelGroupPx = Math.min( FIELD_PIXEL_GROUP_MAX, @@ -200,7 +212,7 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { edgeR1: edgeR1px, morphBuffer: FIELD_MORPH_BUFFER_RATIO * unit, pixelGroup: pixelGroupPx, - nonMemberInfluenceFactor: NON_MEMBER_INFLUENCE_FACTOR * avoidance, + nonMemberInfluenceFactor: avoidance > 0 ? NON_MEMBER_INFLUENCE_FACTOR : 0, }); // Adaptive stride (see OUTLINE_SAMPLE_STEP note): control spacing ≈ 2× the // thinnest field radius, never denser than the point cap allows. @@ -236,23 +248,25 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { if (repaired.length >= 3) outline = repaired; } outline = ensureMembersEnclosed(outline, memberRects, nodeR0px, edgeR0px); - const gapPx = Math.min(nodeR0px * avoidance, nodeR1px); - return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, gapPx, nodeR0px); + // Hole breathing room = the padding radius: carved non-members get the + // same visual gap members do (symmetric, does not scale with avoidance). + return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, nodeR0px, nodeR0px); } /** * Punch interior holes for avoid nodes the hull swallowed. Only nodes whose * CENTER lies inside the outer ring get a disc (boundary-adjacent ones are * the field's fjord job); each disc is clipped so it never eats into a - * member's guaranteed clearance, and skipped entirely when that squeezes it - * below HOLE_MIN_RADIUS_RATIO of the node. If the difference would split the - * hull so that a member center leaves the largest piece, the holes are + * member's guaranteed clearance — a squeezed disc hugs the node body rather + * than vanishing, and is only skipped below HOLE_MIN_RADIUS_RATIO of the + * node (fused into a member's clearance zone). If the difference would split + * the hull so that a member center leaves the largest piece, the holes are * dropped wholesale — the one-ring member guarantee outranks avoidance. * * @param {Array<{x: number, y: number}>} outer simple hull ring * @param {Array<{x, y, width, height}>} memberRects * @param {Array<{x, y, width, height}>} avoidRects - * @param {number} gapPx visual gap around a carved node (scaled by avoidance) + * @param {number} gapPx visual gap around a carved node (the padding radius) * @param {number} padPx member clearance unit (floored nodeR0) * @returns {{outer: Array<{x,y}>, holes: Array>}} */ @@ -266,7 +280,7 @@ function carveAvoidHoles(outer, memberRects, avoidRects, gapPx, padPx) { const cx = rect.x + rect.width / 2; const cy = rect.y + rect.height / 2; if (!pointInPolygon({ x: cx, y: cy }, outer)) continue; - let holeR = r + gapPx; + let holeR = r + Math.max(gapPx, HOLE_GAP_RADIUS_RATIO * r); for (const m of memberRects) { const mr = Math.max(m.width, m.height) / 2; const dist = Math.hypot(cx - (m.x + m.width / 2), cy - (m.y + m.height / 2)); @@ -322,20 +336,31 @@ function carveAvoidHoles(outer, memberRects, avoidRects, gapPx, padPx) { function ensureMembersEnclosed(points, memberRects, padPx, linkR) { if (points.length < 3) return points; const minClearance = padPx * ENCLOSURE_MIN_CLEARANCE_RATIO; - const discs = []; - for (const rect of memberRects) { - const radius = Math.max(rect.width, rect.height) / 2; - const cx = rect.x + rect.width / 2; - const cy = rect.y + rect.height / 2; - const signedDist = pointInPolygon({ x: cx, y: cy }, points) ? 1 : -1; - const clearance = signedDist * distanceToPolygonEdge(cx, cy, points) - radius; - if (clearance < minClearance) discs.push([discRing(cx, cy, radius + padPx)]); + let ring = points; + // The painters render the Catmull-Rom smoothing of this ring, so clearance + // is measured against the sampled CURVE — the guarantee stays honest + // against what is actually painted, not the raw polygon. A repair round + // can itself introduce union-junction corners that smooth inward past a + // member, so the result is re-checked and retried with a disc grown by one + // extra padding per round. + for (let round = 1; round <= ENCLOSURE_MAX_REPAIR_ROUNDS; round++) { + const painted = sampleSmoothedRing(ring); + const discs = []; + for (const rect of memberRects) { + const radius = Math.max(rect.width, rect.height) / 2; + const cx = rect.x + rect.width / 2; + const cy = rect.y + rect.height / 2; + const signedDist = pointInPolygon({ x: cx, y: cy }, painted) ? 1 : -1; + const clearance = signedDist * distanceToPolygonEdge(cx, cy, painted) - radius; + if (clearance < minClearance) discs.push([discRing(cx, cy, radius + padPx * round)]); + } + if (discs.length === 0) return ring; + let merged = unionPolygons([[toClipRing(ring)]], ...discs); + if (!merged) return ring; + merged = connectPolygonComponents(merged, Math.max(linkR, 1)); + ring = largestOuterRing(merged) ?? ring; } - if (discs.length === 0) return points; - let merged = unionPolygons([[toClipRing(points)]], ...discs); - if (!merged) return points; - merged = connectPolygonComponents(merged, Math.max(linkR, 1)); - return largestOuterRing(merged) ?? points; + return ring; } /** @@ -426,8 +451,10 @@ function nearestVertexPair(ringA, ringB) { } /** - * Closed rectangle ring of half-width r along segment a→b, extended by r - * beyond both endpoints so it always overlaps the shapes it links. + * Closed stadium ring of half-width r along segment a→b: straight sides with + * semicircular caps centered on the endpoints, so straggler links join the + * hull without corners (the curve painter rounds them further). The caps + * extend r beyond both endpoints, so the shape always overlaps what it links. * Null for degenerate (coincident) endpoints. * * @param {[number, number]} a @@ -440,19 +467,18 @@ function capsuleRing(a, b, r) { const dy = b[1] - a[1]; const len = Math.hypot(dx, dy); if (len < 1e-9) return null; - const ux = (dx / len) * r; - const uy = (dy / len) * r; - const ax = a[0] - ux; - const ay = a[1] - uy; - const bx = b[0] + ux; - const by = b[1] + uy; - return [ - [ax - uy, ay + ux], - [bx - uy, by + ux], - [bx + uy, by - ux], - [ax + uy, ay - ux], - [ax - uy, ay + ux], - ]; + const angle = Math.atan2(dy, dx); + const ring = []; + const arc = (cx, cy, from) => { + for (let i = 0; i <= CAPSULE_CAP_SEGMENTS; i++) { + const t = from + (Math.PI * i) / CAPSULE_CAP_SEGMENTS; + ring.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); + } + }; + arc(b[0], b[1], angle - Math.PI / 2); // cap around b + arc(a[0], a[1], angle + Math.PI / 2); // cap around a + ring.push([ring[0][0], ring[0][1]]); + return ring; } /** Minimum distance from a point to any edge segment of a ring. */ diff --git a/src/graph/bubble_layer.js b/src/graph/bubble_layer.js index a962619..e522e7a 100644 --- a/src/graph/bubble_layer.js +++ b/src/graph/bubble_layer.js @@ -33,6 +33,7 @@ import { positionsChecksum, styleKey, } from './bubble_geometry.js'; +import { smoothClosedPath } from './bubble_smoothing.js'; const LAYER_NAME = 'bubbleSets'; const LABEL_LAYER_NAME = 'bubbleSetsLabels'; @@ -472,9 +473,19 @@ class BubbleSetLayer { if (!points || points.length < 2) return false; const path = new Path2D(); + // Rings paint as Catmull-Rom curves (smoothClosedPath — same control + // points the SVG export emits); rings too small to smooth fall back to + // the polyline. The polygon stays the geometric source of truth. const addRing = (ring) => { - path.moveTo(ring[0].x, ring[0].y); - for (let i = 1; i < ring.length; i++) path.lineTo(ring[i].x, ring[i].y); + const segments = smoothClosedPath(ring); + if (!segments) { + path.moveTo(ring[0].x, ring[0].y); + for (let i = 1; i < ring.length; i++) path.lineTo(ring[i].x, ring[i].y); + path.closePath(); + return; + } + path.moveTo(segments[0].x0, segments[0].y0); + for (const s of segments) path.bezierCurveTo(s.c1x, s.c1y, s.c2x, s.c2y, s.x, s.y); path.closePath(); }; addRing(points); diff --git a/src/graph/bubble_smoothing.js b/src/graph/bubble_smoothing.js new file mode 100644 index 0000000..0664698 --- /dev/null +++ b/src/graph/bubble_smoothing.js @@ -0,0 +1,82 @@ +/** + * Catmull-Rom smoothing for bubble-group rings (node-safe, pure). + * + * The outline POLYGON (bubble_geometry.js) stays the source of truth for all + * math — enclosure guarantee, clipping, hit tests; only the painters + * interpolate this curve. Both painters — the canvas Path2D in + * bubble_layer.js and the SVG `C` commands in export_svg.js — consume the + * same control points, so live and exported curves are identical. + */ + +// Catmull-Rom → cubic-Bézier conversion (uniform, tension 1/6): each control +// point sits a sixth of the neighbor chord from its endpoint. +const SMOOTH_TENSION_DIVISOR = 6; +// Samples per Bézier segment when densifying a ring to what is actually +// painted (t = 0 reproduces the segment's start vertex). +const SMOOTH_SAMPLES_PER_SEGMENT = 4; + +/** + * Cubic-Bézier control points rendering a closed ring as a smooth Catmull-Rom + * curve. Segment i runs from (x0, y0) = points[i] to (x, y) = points[i+1] + * (wrapping). + * + * @param {Array<{x: number, y: number}>} points closed ring (open form; a + * duplicated closing vertex is tolerated and dropped) + * @returns {Array<{x0, y0, c1x, c1y, c2x, c2y, x, y}>|null} null when the + * ring is too small to smooth (< 3 distinct points) — callers fall back to + * the polyline. + */ +function smoothClosedPath(points) { + if (!points || points.length < 3) return null; + let ring = points; + const first = ring[0]; + const last = ring[ring.length - 1]; + if (first.x === last.x && first.y === last.y) ring = ring.slice(0, -1); + const n = ring.length; + if (n < 3) return null; + const segments = []; + for (let i = 0; i < n; i++) { + const p0 = ring[(i - 1 + n) % n]; + const p1 = ring[i]; + const p2 = ring[(i + 1) % n]; + const p3 = ring[(i + 2) % n]; + segments.push({ + x0: p1.x, + y0: p1.y, + c1x: p1.x + (p2.x - p0.x) / SMOOTH_TENSION_DIVISOR, + c1y: p1.y + (p2.y - p0.y) / SMOOTH_TENSION_DIVISOR, + c2x: p2.x - (p3.x - p1.x) / SMOOTH_TENSION_DIVISOR, + c2y: p2.y - (p3.y - p1.y) / SMOOTH_TENSION_DIVISOR, + x: p2.x, + y: p2.y, + }); + } + return segments; +} + +/** + * Densify a ring with points sampled from its smoothed (painted) curve, so + * geometric checks can measure against what the user actually sees. Returns + * the input unchanged when the ring is too small to smooth. + * + * @param {Array<{x: number, y: number}>} points closed ring + * @returns {Array<{x: number, y: number}>} + */ +function sampleSmoothedRing(points) { + const segments = smoothClosedPath(points); + if (!segments) return points; + const out = []; + for (const s of segments) { + for (let k = 0; k < SMOOTH_SAMPLES_PER_SEGMENT; k++) { + const t = k / SMOOTH_SAMPLES_PER_SEGMENT; + const u = 1 - t; + out.push({ + x: u * u * u * s.x0 + 3 * u * u * t * s.c1x + 3 * u * t * t * s.c2x + t * t * t * s.x, + y: u * u * u * s.y0 + 3 * u * u * t * s.c1y + 3 * u * t * t * s.c2y + t * t * t * s.y, + }); + } + } + return out; +} + +export { smoothClosedPath, sampleSmoothedRing }; diff --git a/src/graph/export_svg.js b/src/graph/export_svg.js index 56f2d87..200717c 100644 --- a/src/graph/export_svg.js +++ b/src/graph/export_svg.js @@ -30,6 +30,7 @@ */ import { DEFAULTS } from "../config.js"; import { outlineLabelAnchor } from "./bubble_geometry.js"; +import { smoothClosedPath } from "./bubble_smoothing.js"; import { placementVector, BAKED_DEFAULT_LABEL_COLOR } from "./label_renderers.js"; // Conservative paint-value allowlist (shape_textures.js SAFE_PAINT_RE): @@ -578,11 +579,22 @@ function edgeLabelPrimitives(edge, env) { // --- bubble group primitives --------------------------------------------------- /** bubble_layer #drawGroup parity: closed body rings (outer + avoid holes, - * even-odd fill) + 2px outline on every ring. */ + * even-odd fill) + 2px outline on every ring, smoothed with the SAME + * Catmull-Rom control points the live canvas paints (smoothClosedPath). */ function bubbleBodyPrimitives({ points, holes = [], opts = {}, defaults = {} }) { if (!points || points.length < 2) return []; - const ringD = (ring) => - ring.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)} ${fmt(p.y)}`).join(" ") + " Z"; + const ringD = (ring) => { + const segments = smoothClosedPath(ring); + if (!segments) + return ring.map((p, i) => `${i === 0 ? "M" : "L"} ${fmt(p.x)} ${fmt(p.y)}`).join(" ") + " Z"; + return ( + `M ${fmt(segments[0].x0)} ${fmt(segments[0].y0)} ` + + segments + .map((s) => `C ${fmt(s.c1x)} ${fmt(s.c1y)} ${fmt(s.c2x)} ${fmt(s.c2y)} ${fmt(s.x)} ${fmt(s.y)}`) + .join(" ") + + " Z" + ); + }; const d = [points, ...holes.filter((h) => h.length >= 3)].map(ringD).join(" "); return [ { diff --git a/tests/bubble-geometry.test.js b/tests/bubble-geometry.test.js index 068650e..9b99207 100644 --- a/tests/bubble-geometry.test.js +++ b/tests/bubble-geometry.test.js @@ -9,6 +9,7 @@ import { positionsChecksum, styleKey, } from "../src/graph/bubble_geometry.js"; +import { smoothClosedPath, sampleSmoothedRing } from "../src/graph/bubble_smoothing.js"; import { pointInPolygon } from "../src/graph/lasso_geometry.js"; // ========================================================================== @@ -450,10 +451,22 @@ describe("computeOutlineGeometry avoid holes", () => { expect(holes).toEqual([]); }); - it("skips the hole when the non-member is squeezed against a member", () => { - // Avoid node overlapping a member's clearance zone: carving would eat - // into the guarantee, so it must be skipped. + it("wedged non-member gets a clearance-limited, body-hugging hole (no silent swallow)", () => { + // The (85,60) node is squeezed between members: the full r+gap disc would + // eat into a member's guaranteed clearance, so the hole falls back to the + // largest clearance-respecting radius — smaller than r+gap but still a + // visible carve (≥ 0.35 × its radius). const { holes } = computeOutlineGeometry(ringMembers, [rectAt(85, 60, 15)], {}); + expect(holes.length).toBe(1); + const radii = holes[0].map((p) => Math.hypot(p.x - 85, p.y - 60)); + expect(Math.max(...radii)).toBeLessThan(15 + 20); // below the full r+gap disc + expect(Math.min(...radii)).toBeGreaterThanOrEqual(15 * 0.35); + }); + + it("fully-fused non-member (inside a member's clearance zone) stays covered", () => { + // At (100,60) even the minimum readable hole would violate the member + // guarantee — the documented ceiling: it stays covered. + const { holes } = computeOutlineGeometry(ringMembers, [rectAt(100, 60, 15)], {}); expect(holes).toEqual([]); }); @@ -463,10 +476,10 @@ describe("computeOutlineGeometry avoid holes", () => { }); }); -describe("computeOutlinePoints avoidance knob", () => { - // Two members with a non-member between them: at default avoidance the - // corridor routes around it (excluded); at 0 the negative field is off and - // the hull may cover it; higher values keep steering around it. +describe("computeOutlinePoints avoidance switch", () => { + // Two members with a non-member between them: avoidance ON routes the + // corridor around it (excluded); OFF (0) drops the negative field and the + // hull may cover it. Persisted numeric: any legacy value > 0 means ON. const members = [rectAt(50, 100), rectAt(250, 100)]; const avoid = [rectAt(150, 100)]; @@ -479,7 +492,7 @@ describe("computeOutlinePoints avoidance knob", () => { expect(outline).toEqual(computeOutlinePoints(members, [], { avoidance: 0 })); }); - it("default and strong avoidance keep the non-member excluded", () => { + it("avoidance ON (default or any legacy value > 0) keeps the non-member excluded", () => { for (const avoidance of [undefined, 1, 3]) { const outline = computeOutlinePoints(members, avoid, { avoidance }); expect(pointInPolygon({ x: 50, y: 100 }, outline)).toBe(true); @@ -488,13 +501,13 @@ describe("computeOutlinePoints avoidance knob", () => { } }); - it("clamps invalid avoidance values to the default instead of collapsing", () => { - expect(computeOutlinePoints(members, avoid, { avoidance: NaN })) - .toEqual(computeOutlinePoints(members, avoid, {})); + it("normalizes every non-zero/invalid value to plain ON (boolean semantics)", () => { + const on = computeOutlinePoints(members, avoid, { avoidance: 1 }); + expect(computeOutlinePoints(members, avoid, { avoidance: 3 })).toEqual(on); + expect(computeOutlinePoints(members, avoid, { avoidance: 100 })).toEqual(on); + expect(computeOutlinePoints(members, avoid, { avoidance: NaN })).toEqual(on); expect(computeOutlinePoints(members, avoid, { avoidance: -5 })) .toEqual(computeOutlinePoints(members, avoid, { avoidance: 0 })); - expect(computeOutlinePoints(members, avoid, { avoidance: 100 })) - .toEqual(computeOutlinePoints(members, avoid, { avoidance: 4 })); }); }); @@ -560,6 +573,8 @@ describe("computeOutlinePoints member-enclosure guarantee", () => { // members disconnect and their repair discs float beside the main hull. // The union must come back as ONE simple ring (capsule links) that still // encloses every member — this exact layout used to drop 5 of 10 members. + // Clearance is measured against the SMOOTHED curve (what the painters + // render): the polygon is math truth, the curve is visual truth. const spots = [ [250, 200], [300, 180], [280, 250], [220, 270], [340, 230], [310, 300], [260, 340], [370, 170], [200, 210], [230, 620], @@ -568,8 +583,9 @@ describe("computeOutlinePoints member-enclosure guarantee", () => { const outline = computeOutlinePoints(members, [], { padding: 0.1, corridor: 0.25 }); expect(outline.length).toBeGreaterThan(3); expect(polygonSelfIntersects(outline)).toBe(false); + const curve = sampleSmoothedRing(outline); for (const [x, y] of spots) { - expect(circleClearance(x, y, 9, outline)).toBeGreaterThanOrEqual(0); + expect(circleClearance(x, y, 9, curve)).toBeGreaterThanOrEqual(0); } }); @@ -585,6 +601,61 @@ describe("computeOutlinePoints member-enclosure guarantee", () => { }); }); +// The painters render rings through smoothClosedPath (Catmull-Rom → cubic +// Bézier, tension 1/6); both the canvas layer and the SVG export consume the +// same control points, and the enclosure guarantee measures against the +// sampled curve. +describe("smoothClosedPath", () => { + const square = [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ]; + + it("returns null for rings too small to smooth", () => { + expect(smoothClosedPath([])).toBeNull(); + expect(smoothClosedPath([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toBeNull(); + }); + + it("emits one segment per vertex, each starting where the previous ended", () => { + const segments = smoothClosedPath(square); + expect(segments).toHaveLength(4); + for (let i = 0; i < segments.length; i++) { + const next = segments[(i + 1) % segments.length]; + expect(segments[i].x).toBe(next.x0); + expect(segments[i].y).toBe(next.y0); + } + }); + + it("joins segments C1-continuously (tangent in equals tangent out at every vertex)", () => { + const segments = smoothClosedPath(square); + for (let i = 0; i < segments.length; i++) { + const prev = segments[(i - 1 + segments.length) % segments.length]; + const s = segments[i]; + // Uniform Catmull-Rom: outgoing (c1 − p1) and incoming (p1 − c2_prev) + // control offsets are both (p2 − p0)/6. + expect(s.c1x - s.x0).toBeCloseTo(prev.x - prev.c2x, 10); + expect(s.c1y - s.y0).toBeCloseTo(prev.y - prev.c2y, 10); + } + }); + + it("is deterministic and tolerates a duplicated closing vertex", () => { + const closed = [...square, { x: 0, y: 0 }]; + expect(smoothClosedPath(closed)).toEqual(smoothClosedPath(square)); + expect(smoothClosedPath(square)).toEqual(smoothClosedPath(square)); + }); + + it("sampleSmoothedRing reproduces each vertex at t=0 and densifies between", () => { + const sampled = sampleSmoothedRing(square); + expect(sampled.length).toBe(square.length * 4); + for (const v of square) { + expect(sampled.some((p) => p.x === v.x && p.y === v.y)).toBe(true); + } + }); + +}); + describe("positionsChecksum node-size fold", () => { it("changes when a member's on-screen radius changes (same positions)", () => { const small = [{ x: 1, y: 2, s: 5 }, { x: 3, y: 4, s: 5 }]; diff --git a/tests/bubble-layer-avoid-invalidation.test.js b/tests/bubble-layer-avoid-invalidation.test.js index fe78172..7d8c437 100644 --- a/tests/bubble-layer-avoid-invalidation.test.js +++ b/tests/bubble-layer-avoid-invalidation.test.js @@ -59,7 +59,7 @@ beforeEach(() => { rafQueue = []; vi.mocked(computeOutlineGeometry).mockReset(); vi.mocked(computeOutlineGeometry).mockReturnValue({ outer: CLEAN, holes: [] }); - globalThis.Path2D = class { moveTo() {} lineTo() {} closePath() {} }; + globalThis.Path2D = class { moveTo() {} lineTo() {} bezierCurveTo() {} closePath() {} }; globalThis.requestAnimationFrame = (cb) => { rafQueue.push(cb); return rafQueue.length; }; globalThis.cancelAnimationFrame = () => {}; if (!globalThis.window) globalThis.window = {}; diff --git a/tests/bubble-layer-canvas-css-size.test.js b/tests/bubble-layer-canvas-css-size.test.js index ed0c973..f60e155 100644 --- a/tests/bubble-layer-canvas-css-size.test.js +++ b/tests/bubble-layer-canvas-css-size.test.js @@ -84,6 +84,7 @@ function makeCache() { class FakePath2D { moveTo() {} lineTo() {} + bezierCurveTo() {} closePath() {} } diff --git a/tests/bubble-layer-export.test.js b/tests/bubble-layer-export.test.js index ec0c106..504f261 100644 --- a/tests/bubble-layer-export.test.js +++ b/tests/bubble-layer-export.test.js @@ -102,6 +102,9 @@ class FakePath2D { constructor() { this.points = []; } moveTo(x, y) { this.points.push({ x, y }); } lineTo(x, y) { this.points.push({ x, y }); } + // Record the segment endpoint (= original polygon vertex) — control points + // are painter detail. + bezierCurveTo(c1x, c1y, c2x, c2y, x, y) { this.points.push({ x, y }); } closePath() {} } diff --git a/tests/bubble-layer-malformed.test.js b/tests/bubble-layer-malformed.test.js index ff83777..f81ef16 100644 --- a/tests/bubble-layer-malformed.test.js +++ b/tests/bubble-layer-malformed.test.js @@ -56,7 +56,7 @@ let rafQueue = []; beforeEach(() => { rafQueue = []; vi.mocked(computeOutlineGeometry).mockReset(); - globalThis.Path2D = class { moveTo() {} lineTo() {} closePath() {} }; + globalThis.Path2D = class { moveTo() {} lineTo() {} bezierCurveTo() {} closePath() {} }; globalThis.requestAnimationFrame = (cb) => { rafQueue.push(cb); return rafQueue.length; }; globalThis.cancelAnimationFrame = () => {}; if (!globalThis.window) globalThis.window = {}; diff --git a/tests/bubble-layer-repro.test.js b/tests/bubble-layer-repro.test.js index 583a5cf..e103676 100644 --- a/tests/bubble-layer-repro.test.js +++ b/tests/bubble-layer-repro.test.js @@ -105,6 +105,9 @@ class FakePath2D { constructor() { this.points = []; } moveTo(x, y) { this.points.push({ x, y }); } lineTo(x, y) { this.points.push({ x, y }); } + // Record the segment endpoint (= original polygon vertex) — control points + // are painter detail. + bezierCurveTo(c1x, c1y, c2x, c2y, x, y) { this.points.push({ x, y }); } closePath() {} } @@ -168,9 +171,11 @@ describe("BubbleSetLayer — zoom reprojection (symptoms 1-3)", () => { // ... and the drawn points must equal the cached graph points reprojected // at the new camera (same cache, just reprojected — never re-fitted). + // The curve painter records moveTo(start) + one endpoint per segment, so + // the ring closes back onto its first vertex (length + 1). const expected = layer.outlines.get("groupOne").graphPoints.map((g) => sigma.graphToViewport(g)); - expect(pointsAt04.length).toBe(expected.length); - pointsAt04.forEach((p, i) => { + expect(pointsAt04.length).toBe(expected.length + 1); + pointsAt04.slice(0, -1).forEach((p, i) => { expect(p.x).toBeCloseTo(expected[i].x, 5); expect(p.y).toBeCloseTo(expected[i].y, 5); }); @@ -193,7 +198,9 @@ describe("BubbleSetLayer — zoom reprojection (symptoms 1-3)", () => { expect(filledPaths.length, "hull stays drawn during a pan").toBeGreaterThan(before); const drawn = filledPaths.at(-1); const expected = layer.outlines.get("groupOne").graphPoints.map((g) => sigma.graphToViewport(g)); - drawn.forEach((p, i) => { + // moveTo(start) + per-segment endpoints: the last point closes the ring. + expect(drawn.length).toBe(expected.length + 1); + drawn.slice(0, -1).forEach((p, i) => { expect(p.x).toBeCloseTo(expected[i].x, 5); expect(p.y).toBeCloseTo(expected[i].y, 5); }); diff --git a/tests/export-svg.test.js b/tests/export-svg.test.js index 5bdcff1..b356123 100644 --- a/tests/export-svg.test.js +++ b/tests/export-svg.test.js @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { buildGraphSvg, collectSvgScene, primitivesToSvg } from "../src/graph/export_svg.js"; +import { smoothClosedPath } from "../src/graph/bubble_smoothing.js"; import { DEFAULTS } from "../src/config.js"; // ========================================================================== @@ -504,7 +505,21 @@ describe("bubble groups", () => { { x: 100, y: 200 }, ]; - it("renders the body as a closed path with fill/stroke opacities and 2px outline", () => { + // Expected `d` built from the SAME control points the live canvas painter + // consumes (smoothClosedPath) — this IS the canvas/SVG parity assertion. + const fmt2 = (v) => String(Math.round(v * 100) / 100); + const smoothD = (ring) => { + const segments = smoothClosedPath(ring); + return ( + `M ${fmt2(segments[0].x0)} ${fmt2(segments[0].y0)} ` + + segments + .map((s) => `C ${fmt2(s.c1x)} ${fmt2(s.c1y)} ${fmt2(s.c2x)} ${fmt2(s.c2y)} ${fmt2(s.x)} ${fmt2(s.y)}`) + .join(" ") + + " Z" + ); + }; + + it("renders the body as a smoothed closed path with fill/stroke opacities and 2px outline", () => { const scene = makeScene(); const svg = build(scene, { @@ -519,28 +534,27 @@ describe("bubble groups", () => { }); expect(svg).toContain( - '', + ``, ); }); - it("renders avoid holes as extra even-odd subpaths", () => { + it("renders avoid holes as extra even-odd subpaths (same smoothed control points)", () => { const scene = makeScene(); + const HOLE = [{ x: 140, y: 140 }, { x: 160, y: 140 }, { x: 160, y: 160 }, { x: 140, y: 160 }]; const svg = build(scene, { bubbleGroups: [ { group: "groupOne", points: SQUARE, - holes: [[{ x: 140, y: 140 }, { x: 160, y: 140 }, { x: 160, y: 160 }, { x: 140, y: 160 }]], + holes: [HOLE], opts: { fill: "#e74c3c", fillOpacity: 0.3, stroke: "#111111", strokeOpacity: 0.8 }, defaults: {}, }, ], }); - expect(svg).toContain( - 'd="M 100 100 L 200 100 L 200 200 L 100 200 Z M 140 140 L 160 140 L 160 160 L 140 160 Z"', - ); + expect(svg).toContain(`d="${smoothD(SQUARE)} ${smoothD(HOLE)}"`); expect(svg).toContain('fill-rule="evenodd"'); }); From 59978a4be3b79041dbc018fef542586ae5935848 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 10:01:47 +0200 Subject: [PATCH 06/18] feat(styling): make bubble avoidance a switch and retune defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Avoidance slider with an 'Avoid Other Nodes' switch (the value had no useful continuous range) — persisted as numeric 0/1 so saved workspaces stay compatible; legacy values > 0 load as ON. The switch names its checkbox via aria-label since the row label is a separate element. Retune group defaults from padding 0.1 / corridor 0.25 to 0.15 / 0.3: the marching-grid floor gives small nodes proportionally more margin, so large nodes rendered visibly strangled at the old values (side-by-side r=9 vs r=35 renders). Hole breathing room gets a matching floor of 0.25× the carved node's radius. --- src/config.js | 16 ++++++++-------- src/graph/bubble_sets.js | 3 ++- src/managers/ui_style_div.js | 18 ++++++++++++------ 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/config.js b/src/config.js index 3253302..6794c9f 100644 --- a/src/config.js +++ b/src/config.js @@ -202,8 +202,8 @@ const DEFAULTS = { stroke: '#C33D35', strokeOpacity: 1, virtualEdges: true, - padding: 0.1, - corridor: 0.25, + padding: 0.15, + corridor: 0.3, avoidance: 1, label: true, labelText: 'group one', @@ -225,8 +225,8 @@ const DEFAULTS = { stroke: '#403c53', strokeOpacity: 1, virtualEdges: true, - padding: 0.1, - corridor: 0.25, + padding: 0.15, + corridor: 0.3, avoidance: 1, label: true, labelText: 'group two', @@ -248,8 +248,8 @@ const DEFAULTS = { stroke: '#8CA6D9', strokeOpacity: 1, virtualEdges: true, - padding: 0.1, - corridor: 0.25, + padding: 0.15, + corridor: 0.3, avoidance: 1, label: true, labelText: 'group three', @@ -271,8 +271,8 @@ const DEFAULTS = { stroke: '#EFB0AA', strokeOpacity: 1, virtualEdges: true, - padding: 0.1, - corridor: 0.25, + padding: 0.15, + corridor: 0.3, avoidance: 1, label: true, labelText: 'group four', diff --git a/src/graph/bubble_sets.js b/src/graph/bubble_sets.js index 7454fc2..dab86ce 100644 --- a/src/graph/bubble_sets.js +++ b/src/graph/bubble_sets.js @@ -187,7 +187,6 @@ class GraphBubbleSetManager { }; syncSliderInput("Padding", bubbleStyle.padding); syncSliderInput("Corridor Width", bubbleStyle.corridor); - syncSliderInput("Avoidance", bubbleStyle.avoidance); syncSliderInput("Label Font Size", bubbleStyle.labelFontSize); syncSliderInput("Label Offset X", bubbleStyle.labelOffsetX); syncSliderInput("Label Offset Y", bubbleStyle.labelOffsetY); @@ -199,6 +198,8 @@ class GraphBubbleSetManager { }; syncSwitch("Label Close To Path", bubbleStyle.labelCloseToPath); syncSwitch("Label Auto Rotate", bubbleStyle.labelAutoRotate); + // Numeric 0/1 (legacy values > 0 read as ON) → checked state. + syncSwitch("Avoidance", (bubbleStyle.avoidance ?? 1) > 0); // Sync dropdown by data-property attribute const placementDropdown = card.querySelector(`[data-property="Bubble Set ${group} Label Placement"]`); diff --git a/src/managers/ui_style_div.js b/src/managers/ui_style_div.js index 87bdfa4..0354a37 100644 --- a/src/managers/ui_style_div.js +++ b/src/managers/ui_style_div.js @@ -1222,13 +1222,19 @@ function createStyleDiv(cache) { {min: 0.05, max: 3, step: 0.05}, `Thickness of bubble set ${tabIndex}'s connecting arms to outlying members.`, false); - // Avoidance (non-member negative influence multiplier) + // Avoidance (non-member field on/off; persisted numeric 0/1 for + // JSON back-compat — legacy saved values > 0 read as ON) const rowAvoidance = createNewRow(panel); - appendLabel(rowAvoidance, "Avoidance", - "How strongly the bubble steers around nodes that are not in the group — 0 lets it cover them freely."); - createNumericalSlider(rowAvoidance, `Bubble Set ${group} Avoidance`, bs.avoidance ?? 1, - {min: 0, max: 3, step: 0.05}, - `How strongly bubble set ${tabIndex} steers around non-member nodes.`, false); + appendLabel(rowAvoidance, "Avoid Other Nodes", + "Steer the hull around nodes that are not in the group and carve holes for enclosed ones."); + const avoidanceSwitch = createSwitch(async () => { + await cache.bs.updateBubbleSetStyle(`Bubble Set ${group} Avoidance`, avoidanceSwitch.isChecked() ? 1 : 0); + }, undefined, (bs.avoidance ?? 1) > 0); + avoidanceSwitch.dataset.property = `Bubble Set ${group} Avoidance`; + // The row label is a separate element, so name the checkbox directly. + avoidanceSwitch.querySelector("input").setAttribute("aria-label", + `Avoid other nodes (bubble set ${tabIndex})`); + rowAvoidance.appendChild(avoidanceSwitch); // Label (toggle + text input) const rowLabel = createNewRow(panel); From f0dc01e738d4f1f2c810b86f7721af3a42cafd23 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 11:47:32 +0200 Subject: [PATCH 07/18] =?UTF-8?q?fix(styling):=20trade=20avoidance=20for?= =?UTF-8?q?=20smoothness=20=E2=80=94=20default=20off,=20tighter=20knobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live feedback on a dense STRING network: the negative field of hundreds of non-members makes the contour visibly wobble wherever it threads between them, and one corridor-severing hole dropped ALL carves wholesale, leaving swallowed non-members with no visual out. The user prefers smooth hulls over avoidance, so: - avoidance now defaults to OFF (per-group switch turns carving back on); saved workspaces keep their stored value - padding/corridor floors drop to 0.01 (slider min/step 0.01) and defaults tighten to 0.05 / 0.1 — the resolution floors plus the enclosure guarantee make near-zero knobs safe, giving hug-tight bodies and finger-like arms - carveAvoidHoles retries discs one by one when the batch difference breaks the one-ring guarantee, so a single bad hole no longer silently swallows every other non-member (subtractHoleDiscs) --- src/config.js | 24 ++++++++-------- src/graph/bubble_geometry.js | 52 +++++++++++++++++++++++++++-------- src/managers/ui_style_div.js | 4 +-- tests/bubble-geometry.test.js | 15 ++++++++++ 4 files changed, 70 insertions(+), 25 deletions(-) diff --git a/src/config.js b/src/config.js index 6794c9f..2edea99 100644 --- a/src/config.js +++ b/src/config.js @@ -202,9 +202,9 @@ const DEFAULTS = { stroke: '#C33D35', strokeOpacity: 1, virtualEdges: true, - padding: 0.15, - corridor: 0.3, - avoidance: 1, + padding: 0.05, + corridor: 0.1, + avoidance: 0, label: true, labelText: 'group one', labelFill: '#fff', @@ -225,9 +225,9 @@ const DEFAULTS = { stroke: '#403c53', strokeOpacity: 1, virtualEdges: true, - padding: 0.15, - corridor: 0.3, - avoidance: 1, + padding: 0.05, + corridor: 0.1, + avoidance: 0, label: true, labelText: 'group two', labelFill: '#fff', @@ -248,9 +248,9 @@ const DEFAULTS = { stroke: '#8CA6D9', strokeOpacity: 1, virtualEdges: true, - padding: 0.15, - corridor: 0.3, - avoidance: 1, + padding: 0.05, + corridor: 0.1, + avoidance: 0, label: true, labelText: 'group three', labelFill: '#fff', @@ -271,9 +271,9 @@ const DEFAULTS = { stroke: '#EFB0AA', strokeOpacity: 1, virtualEdges: true, - padding: 0.15, - corridor: 0.3, - avoidance: 1, + padding: 0.05, + corridor: 0.1, + avoidance: 0, label: true, labelText: 'group four', labelFill: '#fff', diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index ea3bbde..2737ced 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -62,7 +62,10 @@ const FIELD_PIXEL_GROUP_RATIO = 4 / REFERENCE_NODE_RADIUS; // clearance for virtual edges, and scaling it with the corridor knob made // wider corridors also REROUTE around avoid nodes in long detours that // painted as phantom lobes in empty space. -const GEOMETRY_MULTIPLIER_MIN = 0.05; +// Min is near-zero on purpose: the resolution floors below keep every field +// radius at grid scale, and the enclosure guarantee rebuilds whatever the +// field loses — so "as tight as it gets" is a valid, safe user choice. +const GEOMETRY_MULTIPLIER_MIN = 0.01; const GEOMETRY_MULTIPLIER_MAX = 4; // bubblesets-js' own default non-member (negative) energy factor. Avoidance // is a SWITCH, not a multiplier: above ~1× the library's marching iteration @@ -259,9 +262,11 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { * the field's fjord job); each disc is clipped so it never eats into a * member's guaranteed clearance — a squeezed disc hugs the node body rather * than vanishing, and is only skipped below HOLE_MIN_RADIUS_RATIO of the - * node (fused into a member's clearance zone). If the difference would split - * the hull so that a member center leaves the largest piece, the holes are - * dropped wholesale — the one-ring member guarantee outranks avoidance. + * node (fused into a member's clearance zone). If the batch difference would + * split the hull so that a member center leaves the largest piece, the discs + * are retried ONE BY ONE and only the offenders dropped — the one-ring + * member guarantee outranks avoidance, but one bad hole must not silently + * swallow every other carve. * * @param {Array<{x: number, y: number}>} outer simple hull ring * @param {Array<{x, y, width, height}>} memberRects @@ -290,23 +295,48 @@ function carveAvoidHoles(outer, memberRects, avoidRects, gapPx, padPx) { discs.push([discRing(cx, cy, holeR)]); } if (discs.length === 0) return unholed; + const batch = subtractHoleDiscs(unholed, memberRects, discs); + if (batch) return batch; + // The batch severed a corridor (or the clipper failed): retry disc by disc + // so only the offending holes are dropped, not all of them. + let carved = unholed; + for (const disc of discs) { + carved = subtractHoleDiscs(carved, memberRects, [disc]) ?? carved; + } + return carved; +} + +/** + * Subtract hole discs from a {outer, holes} geometry via polygon difference, + * keeping the largest resulting piece. Null when the result would violate + * the one-ring member guarantee (a member center leaves the largest piece, + * the ring collapses) or the clipper throws — callers treat null as "these + * discs cannot be applied". + * + * @param {{outer: Array<{x,y}>, holes: Array>}} geometry + * @param {Array<{x, y, width, height}>} memberRects + * @param {Array>>} discs clip-ready disc rings + * @returns {{outer: Array<{x,y}>, holes: Array>}|null} + */ +function subtractHoleDiscs(geometry, memberRects, discs) { let result; try { - result = polygonClipping.difference([toClipRing(outer)], ...discs); + result = polygonClipping.difference( + [toClipRing(geometry.outer), ...geometry.holes.map(toClipRing)], + ...discs + ); } catch { - return unholed; + return null; } - if (!result || result.length === 0) return unholed; + if (!result || result.length === 0) return null; const largest = [...result].sort( (a, b) => Math.abs(ringSignedArea(b[0])) - Math.abs(ringSignedArea(a[0])) )[0]; const newOuter = largest[0].slice(0, -1).map(([x, y]) => ({ x, y })); - if (newOuter.length < 3) return unholed; - // A hole chain may have severed a corridor: every member center must still - // live in the surviving piece, else avoidance loses to the guarantee. + if (newOuter.length < 3) return null; for (const m of memberRects) { const c = { x: m.x + m.width / 2, y: m.y + m.height / 2 }; - if (!pointInPolygon(c, newOuter)) return unholed; + if (!pointInPolygon(c, newOuter)) return null; } const holes = largest .slice(1) diff --git a/src/managers/ui_style_div.js b/src/managers/ui_style_div.js index 0354a37..695b124 100644 --- a/src/managers/ui_style_div.js +++ b/src/managers/ui_style_div.js @@ -1211,7 +1211,7 @@ function createStyleDiv(cache) { appendLabel(rowPadding, "Padding", "How far the bubble body extends past its member nodes — lower for a tighter hug, higher for more breathing room."); createNumericalSlider(rowPadding, `Bubble Set ${group} Padding`, bs.padding ?? 1, - {min: 0.05, max: 3, step: 0.05}, + {min: 0.01, max: 3, step: 0.01}, `How far bubble set ${tabIndex} extends past its member nodes.`, false); // Corridor Width (virtual-edge influence field multiplier) @@ -1219,7 +1219,7 @@ function createStyleDiv(cache) { appendLabel(rowCorridor, "Corridor Width", "Thickness of the connecting arms that reach outlying member nodes."); createNumericalSlider(rowCorridor, `Bubble Set ${group} Corridor Width`, bs.corridor ?? 1, - {min: 0.05, max: 3, step: 0.05}, + {min: 0.01, max: 3, step: 0.01}, `Thickness of bubble set ${tabIndex}'s connecting arms to outlying members.`, false); // Avoidance (non-member field on/off; persisted numeric 0/1 for diff --git a/tests/bubble-geometry.test.js b/tests/bubble-geometry.test.js index 9b99207..c604c50 100644 --- a/tests/bubble-geometry.test.js +++ b/tests/bubble-geometry.test.js @@ -470,6 +470,21 @@ describe("computeOutlineGeometry avoid holes", () => { expect(holes).toEqual([]); }); + it("drops only the corridor-severing hole, keeping the others (per-disc fallback)", () => { + // A far member hangs off the ring by a thin arm; an avoid node ON the arm + // would sever it (its disc is wider than the corridor), while the pocket + // avoid node has a clean hole. The batch difference fails the one-ring + // guarantee, so the discs retry one by one: pocket hole survives, arm + // node stays covered, far member stays inside. + const members = [...ringMembers, rectAt(320, 60, 20)]; + const pocket = rectAt(60, 60, 15); + const onArm = rectAt(225, 60, 15); + const { outer, holes } = computeOutlineGeometry(members, [pocket, onArm], {}); + expect(holes.some((h) => pointInPolygon({ x: 60, y: 60 }, h))).toBe(true); + expect(holes.some((h) => pointInPolygon({ x: 225, y: 60 }, h))).toBe(false); + expect(pointInPolygon({ x: 320, y: 60 }, outer)).toBe(true); + }); + it("computeOutlinePoints returns the same outer ring (holes dropped)", () => { const geometry = computeOutlineGeometry(ringMembers, interiorAvoid, {}); expect(computeOutlinePoints(ringMembers, interiorAvoid, {})).toEqual(geometry.outer); From b5465fd85b3c93a6b1b6f8aa2cb3497445dc427a Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:02:18 +0200 Subject: [PATCH 08/18] feat(graph): true-minimum bubble knobs via geometric reconstruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the marching-grid floors on the influence-field radii: they fattened minimum knobs to ~half a node radius on dense graphs (small ratio-1 node radii), which is why padding/corridor 0.01 still painted wide. A field thinner than a grid cell now simply registers nothing — skipped entirely when no radius reaches grid scale — and ensureMembersEnclosed reconstructs the hull geometrically: a disc per member (hugging each node's OWN radius, min 2 px pad) joined by thin stadium capsules (min 1.25 px half-width). connectPolygonComponents links each piece to its NEAREST component instead of everything to the largest one — nearest-neighbor rounds grow an MST-like tree of short fingers where the old rule painted a star radiating from one node. Catmull-Rom control offsets are clamped to 0.35× their segment's chord: uniform parameterization overshoots where a short junction segment neighbors a long capsule side, cutting through the discs it should hug (inactive for evenly spaced rings). Outline simplify tolerance rises to one full grid cell — with curve painting, fewer control points mean smoother hulls and less field-summation scallop. --- src/graph/bubble_geometry.js | 206 ++++++++++++++++++++-------------- src/graph/bubble_smoothing.js | 31 ++++- 2 files changed, 148 insertions(+), 89 deletions(-) diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index 2737ced..6d0c081 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -31,11 +31,11 @@ const OUTLINE_MAX_SAMPLED_POINTS = 1200; // the dense adaptive-stride spline carries grid-scale micro-wiggle that (a) // reads as jaggies and (b) produces near-degenerate segments that make // polygon-clipping throw ("Unable to complete output ring"), silently -// disabling the enclosure guarantee. Half a cell erases the noise without -// touching real features — the painters render the ring through -// smoothClosedPath, so FEWER control points mean a smoother curve, not -// visible facets. -const OUTLINE_SIMPLIFY_TOLERANCE_RATIO = 0.5; +// disabling the enclosure guarantee. One full cell erases the noise (and +// most field-summation scallop) without touching real features — the +// painters render the ring through smoothClosedPath, so FEWER control +// points mean a smoother curve, not visible facets. +const OUTLINE_SIMPLIFY_TOLERANCE_RATIO = 1; // Snap grid (px⁻¹) for rings handed to polygon-clipping — collapses the // float-noise duplicate/degenerate vertices that trip its sweep line. const CLIP_SNAP = 32; @@ -110,6 +110,11 @@ const FIELD_PIXEL_GROUP_MIN = 1; const FIELD_PIXEL_GROUP_MAX = 4; // Semicircular cap resolution for the stadium-shaped straggler links. const CAPSULE_CAP_SEGMENTS = 8; +// Visual minimums for the geometric reconstruction (repair discs + capsule +// corridors): the tightest hull still clears the node body by roughly the +// outline stroke, and a corridor stays a visible hairline. +const DISC_PAD_MIN_PX = 2; +const LINK_HALF_WIDTH_MIN_PX = 1.25; /** * Axis-aligned square around a node's viewport position. @@ -160,8 +165,8 @@ function meanMemberRadius(memberRects) { * user style knobs, clamped to [0.05, 4]; 1 = one mean-radius of margin. * avoidance is a switch (numeric for JSON back-compat): 0 lets the hull * cover non-members, anything > 0 steers around them and carves holes. - * @returns {Array<{x: number, y: number}>} closed polygon (empty when no - * members or the outline collapsed) + * @returns {Array<{x: number, y: number}>} closed polygon (empty only when + * there are no members — a lost field reconstructs geometrically) */ function computeOutlinePoints(memberRects, avoidRects = [], opts = {}) { return computeOutlineGeometry(memberRects, avoidRects, opts).outer; @@ -195,65 +200,78 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { FIELD_PIXEL_GROUP_MAX, Math.max(FIELD_PIXEL_GROUP_MIN, FIELD_PIXEL_GROUP_RATIO * unit) ); - // Resolution floors: marching squares cannot trace a feature thinner than - // a grid cell, so ultra-low knob values would silently disconnect the - // corridors and drop margins below drawability. The floors keep every - // field radius at grid scale (with R1 > R0 preserved). - const nodeR0px = Math.max(FIELD_NODE_R0_RATIO * unit * pad, pixelGroupPx); - const nodeR1px = Math.max(FIELD_NODE_R1_RATIO * unit * pad, 3 * pixelGroupPx); - const edgeR0px = Math.max(FIELD_EDGE_R0_RATIO * unit * cor, pixelGroupPx); - const edgeR1px = Math.max(FIELD_EDGE_R1_RATIO * unit * cor, 2 * pixelGroupPx); + // Field radii track the knobs all the way down — NO grid floor. A field + // thinner than a marching cell simply registers nothing, and the enclosure + // guarantee rebuilds the hull geometrically (per-node discs + capsule + // corridors), which IS the barely-encapsulating, finger-like minimum the + // knobs promise. The old floors fattened minimum knobs to ~half a node + // radius on dense graphs where the ratio-1 node radius is small. + const nodeR0px = FIELD_NODE_R0_RATIO * unit * pad; + const nodeR1px = FIELD_NODE_R1_RATIO * unit * pad; + const edgeR0px = FIELD_EDGE_R0_RATIO * unit * cor; + const edgeR1px = FIELD_EDGE_R1_RATIO * unit * cor; + // Repair-disc padding and capsule half-width keep a small visual minimum + // so the tightest hull still clears the node body by the stroke width. + const discPadPx = Math.max(nodeR0px, DISC_PAD_MIN_PX); + const linkRPx = Math.max(edgeR0px, LINK_HALF_WIDTH_MIN_PX); // Avoidance 0 means "ignore non-members" — drop the rects entirely so // virtual-edge routing stops detouring around them too (and the // O(members × avoid) routing cost disappears with them). const effectiveAvoidRects = avoidance === 0 ? [] : avoidRects; - const path = bubblesets.createOutline(memberRects, effectiveAvoidRects, [], { - virtualEdges: opts.virtualEdges !== false, - nodeR0: nodeR0px, - nodeR1: nodeR1px, - edgeR0: edgeR0px, - edgeR1: edgeR1px, - morphBuffer: FIELD_MORPH_BUFFER_RATIO * unit, - pixelGroup: pixelGroupPx, - nonMemberInfluenceFactor: avoidance > 0 ? NON_MEMBER_INFLUENCE_FACTOR : 0, - }); - // Adaptive stride (see OUTLINE_SAMPLE_STEP note): control spacing ≈ 2× the - // thinnest field radius, never denser than the point cap allows. - const minFeaturePx = Math.min(nodeR0px, edgeR0px); - const stride = Math.max( - Math.min(OUTLINE_SAMPLE_STEP, Math.round((2 * minFeaturePx) / pixelGroupPx)), - 1, - Math.ceil(path.length / OUTLINE_MAX_SAMPLED_POINTS) - ); - let sampled; - try { - sampled = path - .sample(stride) - .simplify(0) - .bSplines() - .simplify(pixelGroupPx * OUTLINE_SIMPLIFY_TOLERANCE_RATIO); - } catch { - // Boundary guard for bubblesets-js: a degenerate/collapsed outline must - // resolve to "no outline" (this function's documented contract) rather - // than throwing into the render loop and freezing the bubble canvas. - return { outer: [], holes: [] }; - } - let outline = pointPathToArray(sampled); - // bubblesets can return a self-intersecting ring — virtualEdges corridor - // routing crosses itself at some scales, and the bSpline smoothing overshoots - // into self-loops when members spread faster than the influence field grows. - // Drawn directly these paint as phantom chords / lobes (and a downstream - // convex-hull fallback was worse). Repair via polygon self-union, which - // resolves the crossings into valid simple rings; keep the largest. The - // result still hugs every member — never a blocky hull. - if (polygonSelfIntersects(outline)) { - const repaired = repairSelfIntersections(outline); - if (repaired.length >= 3) outline = repaired; + // Sub-grid fields cannot register on the marching grid; skip the whole + // pass (and its cost) when nothing could. Sub-grid EDGE fields also skip + // virtual-edge routing — the capsule links take over as corridors. + const nodeFieldVisible = nodeR1px >= pixelGroupPx; + const edgeFieldVisible = edgeR1px >= pixelGroupPx; + let outline = []; + if (nodeFieldVisible || edgeFieldVisible) { + const path = bubblesets.createOutline(memberRects, effectiveAvoidRects, [], { + virtualEdges: opts.virtualEdges !== false && edgeFieldVisible, + nodeR0: nodeR0px, + nodeR1: nodeR1px, + edgeR0: edgeR0px, + edgeR1: edgeR1px, + morphBuffer: FIELD_MORPH_BUFFER_RATIO * unit, + pixelGroup: pixelGroupPx, + nonMemberInfluenceFactor: avoidance > 0 ? NON_MEMBER_INFLUENCE_FACTOR : 0, + }); + // Adaptive stride (see OUTLINE_SAMPLE_STEP note): control spacing ≈ 2× + // the thinnest TRACEABLE feature — nothing below a grid cell can appear, + // so the stride never drops below one cell's worth of spacing. + const minFeaturePx = Math.max(pixelGroupPx, Math.min(nodeR0px, edgeR0px)); + const stride = Math.max( + Math.min(OUTLINE_SAMPLE_STEP, Math.round((2 * minFeaturePx) / pixelGroupPx)), + 1, + Math.ceil(path.length / OUTLINE_MAX_SAMPLED_POINTS) + ); + try { + const sampled = path + .sample(stride) + .simplify(0) + .bSplines() + .simplify(pixelGroupPx * OUTLINE_SIMPLIFY_TOLERANCE_RATIO); + outline = pointPathToArray(sampled); + } catch { + // Boundary guard for bubblesets-js: a degenerate/collapsed outline + // must never throw into the render loop — fall through with an empty + // ring and let the geometric reconstruction below take over. + outline = []; + } + // bubblesets can return a self-intersecting ring — virtualEdges corridor + // routing crosses itself at some scales, and the bSpline smoothing + // overshoots into self-loops when members spread faster than the field + // grows. Drawn directly these paint as phantom chords / lobes. Repair via + // polygon self-union, which resolves the crossings into valid simple + // rings; keep the largest. + if (polygonSelfIntersects(outline)) { + const repaired = repairSelfIntersections(outline); + if (repaired.length >= 3) outline = repaired; + } } - outline = ensureMembersEnclosed(outline, memberRects, nodeR0px, edgeR0px); - // Hole breathing room = the padding radius: carved non-members get the - // same visual gap members do (symmetric, does not scale with avoidance). - return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, nodeR0px, nodeR0px); + outline = ensureMembersEnclosed(outline, memberRects, discPadPx, linkRPx); + // Hole breathing room = the disc padding: carved non-members get the same + // visual gap members do (symmetric, does not scale with avoidance). + return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, discPadPx, discPadPx); } /** @@ -355,18 +373,20 @@ function subtractHoleDiscs(geometry, memberRects, discs) { * When the union comes back in several pieces (a member the field lost * entirely — its disc floats beside the hull), the pieces are joined with * corridor-width capsule links so the result is always ONE ring that holds - * every member. + * every member. An EMPTY input ring (the field registered nothing — sub-grid + * knobs) reconstructs the whole hull geometrically: every member gets a disc + * and the capsule links become the corridors. * - * @param {Array<{x: number, y: number}>} points simple outline ring + * @param {Array<{x: number, y: number}>} points simple outline ring (may be + * empty) * @param {Array<{x, y, width, height}>} memberRects - * @param {number} padPx intended padding in px (floored nodeR0) - * @param {number} linkR capsule half-width for joining pieces (floored edgeR0) + * @param {number} padPx intended padding in px (visual-min-floored nodeR0) + * @param {number} linkR capsule half-width for joining pieces * @returns {Array<{x: number, y: number}>} */ function ensureMembersEnclosed(points, memberRects, padPx, linkR) { - if (points.length < 3) return points; const minClearance = padPx * ENCLOSURE_MIN_CLEARANCE_RATIO; - let ring = points; + let ring = points.length >= 3 ? points : []; // The painters render the Catmull-Rom smoothing of this ring, so clearance // is measured against the sampled CURVE — the guarantee stays honest // against what is actually painted, not the raw polygon. A repair round @@ -374,18 +394,23 @@ function ensureMembersEnclosed(points, memberRects, padPx, linkR) { // member, so the result is re-checked and retried with a disc grown by one // extra padding per round. for (let round = 1; round <= ENCLOSURE_MAX_REPAIR_ROUNDS; round++) { - const painted = sampleSmoothedRing(ring); + const painted = ring.length >= 3 ? sampleSmoothedRing(ring) : null; const discs = []; for (const rect of memberRects) { const radius = Math.max(rect.width, rect.height) / 2; const cx = rect.x + rect.width / 2; const cy = rect.y + rect.height / 2; - const signedDist = pointInPolygon({ x: cx, y: cy }, painted) ? 1 : -1; - const clearance = signedDist * distanceToPolygonEdge(cx, cy, painted) - radius; - if (clearance < minClearance) discs.push([discRing(cx, cy, radius + padPx * round)]); + if (painted) { + const signedDist = pointInPolygon({ x: cx, y: cy }, painted) ? 1 : -1; + const clearance = signedDist * distanceToPolygonEdge(cx, cy, painted) - radius; + if (clearance >= minClearance) continue; + } + discs.push([discRing(cx, cy, radius + padPx * round)]); } if (discs.length === 0) return ring; - let merged = unionPolygons([[toClipRing(ring)]], ...discs); + let merged = ring.length >= 3 + ? unionPolygons([[toClipRing(ring)]], ...discs) + : unionPolygons(discs[0], ...discs.slice(1)); if (!merged) return ring; merged = connectPolygonComponents(merged, Math.max(linkR, 1)); ring = largestOuterRing(merged) ?? ring; @@ -430,28 +455,39 @@ function unionPolygons(...geoms) { /** * Join a MultiPolygon's disconnected components into one by unioning - * capsule links (thin rectangles, endpoints extended by linkR so they - * overlap both sides) between each secondary component and the point of the - * largest component nearest to it. Repeats until connected; bails to the - * current state after a few rounds or on a clipping failure, so the caller - * degrades to "largest piece" rather than crashing. + * capsule links (stadium shapes, caps extended by linkR so they overlap both + * sides) between each component and its NEAREST other component — + * nearest-neighbor links grow an MST-like tree of short fingers, where + * linking everything to the largest piece painted as a star radiating from + * one node. Repeats until connected (each round at least halves the + * component count); bails to the current state after a few rounds or on a + * clipping failure, so the caller degrades to "largest piece" rather than + * crashing. * * @param {Array>>} multiPolygon * @param {number} linkR capsule half-width (px) * @returns {Array>>} */ function connectPolygonComponents(multiPolygon, linkR) { - const MAX_ROUNDS = 4; + const MAX_ROUNDS = 8; let polys = multiPolygon; for (let round = 0; round < MAX_ROUNDS && polys.length > 1; round++) { - const sorted = [...polys].sort( - (a, b) => Math.abs(ringSignedArea(b[0])) - Math.abs(ringSignedArea(a[0])) - ); - const primary = sorted[0][0]; const links = []; - for (const poly of sorted.slice(1)) { - const [a, b] = nearestVertexPair(primary, poly[0]); - const capsule = capsuleRing(a, b, linkR); + for (let i = 0; i < polys.length; i++) { + let best = null; + let bestDistSq = Infinity; + for (let j = 0; j < polys.length; j++) { + if (j === i) continue; + const [a, b] = nearestVertexPair(polys[i][0], polys[j][0]); + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + const distSq = dx * dx + dy * dy; + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = [a, b]; + } + } + const capsule = best && capsuleRing(best[0], best[1], linkR); if (capsule) links.push([capsule]); } if (links.length === 0) break; diff --git a/src/graph/bubble_smoothing.js b/src/graph/bubble_smoothing.js index 0664698..15c0765 100644 --- a/src/graph/bubble_smoothing.js +++ b/src/graph/bubble_smoothing.js @@ -11,6 +11,12 @@ // Catmull-Rom → cubic-Bézier conversion (uniform, tension 1/6): each control // point sits a sixth of the neighbor chord from its endpoint. const SMOOTH_TENSION_DIVISOR = 6; +// Control offsets are clamped to this fraction of their own segment's chord. +// For evenly spaced points the uniform offset is ≤ chord/3, so the clamp is +// inactive; it binds only where a short segment neighbors a very long one +// (disc–capsule junctions), where unclamped uniform Catmull-Rom overshoots +// far enough to cut through the shapes it should hug. +const SMOOTH_MAX_CONTROL_RATIO = 0.35; // Samples per Bézier segment when densifying a ring to what is actually // painted (t = 0 reproduces the segment's start vertex). const SMOOTH_SAMPLES_PER_SEGMENT = 4; @@ -34,19 +40,36 @@ function smoothClosedPath(points) { if (first.x === last.x && first.y === last.y) ring = ring.slice(0, -1); const n = ring.length; if (n < 3) return null; + const clamp = (vx, vy, maxLen) => { + const len = Math.hypot(vx, vy); + if (len <= maxLen || len === 0) return [vx, vy]; + const s = maxLen / len; + return [vx * s, vy * s]; + }; const segments = []; for (let i = 0; i < n; i++) { const p0 = ring[(i - 1 + n) % n]; const p1 = ring[i]; const p2 = ring[(i + 1) % n]; const p3 = ring[(i + 2) % n]; + const maxOffset = Math.hypot(p2.x - p1.x, p2.y - p1.y) * SMOOTH_MAX_CONTROL_RATIO; + const [o1x, o1y] = clamp( + (p2.x - p0.x) / SMOOTH_TENSION_DIVISOR, + (p2.y - p0.y) / SMOOTH_TENSION_DIVISOR, + maxOffset + ); + const [o2x, o2y] = clamp( + (p3.x - p1.x) / SMOOTH_TENSION_DIVISOR, + (p3.y - p1.y) / SMOOTH_TENSION_DIVISOR, + maxOffset + ); segments.push({ x0: p1.x, y0: p1.y, - c1x: p1.x + (p2.x - p0.x) / SMOOTH_TENSION_DIVISOR, - c1y: p1.y + (p2.y - p0.y) / SMOOTH_TENSION_DIVISOR, - c2x: p2.x - (p3.x - p1.x) / SMOOTH_TENSION_DIVISOR, - c2y: p2.y - (p3.y - p1.y) / SMOOTH_TENSION_DIVISOR, + c1x: p1.x + o1x, + c1y: p1.y + o1y, + c2x: p2.x - o2x, + c2y: p2.y - o2y, x: p2.x, y: p2.y, }); From 764f4e66dd23752a6db87a37258d10cfafed77e4 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:02:34 +0200 Subject: [PATCH 09/18] feat(data-editor): merge-import Excel files into the loaded graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New ⤒ Import button in the data editor header loads an Excel workbook into the current graph instead of replacing it. A preview modal shows what the merge will do before anything is applied: new / updated node and edge counts, new property columns, unchanged and skipped rows, and capped id lists. - src/utilities/excel_merge.js: pure computeMergePlan (id-matched overlay of label/description/type/style + deep D4Data merge, group- contiguous header union, change detection) and the preview modal. - parseExcelToJson gains merge options: a single nodes/edges sheet suffices and edge endpoints validate against existing graph nodes, so a workbook can attach new edges to nodes already in the graph. - DataTable.update() rebuild path extracted into rebuildGraph() and shared with the import flow; workspaces, positions, filters and styles are preserved. X/Y coordinates in the file seed positions for new nodes only. No version bump or changelog entry yet — happens on main after testing. --- src/graph_lens_lite.html | 2 + src/managers/io.js | 61 +++-- src/style.css | 88 ++++++ src/utilities/data_editor.js | 164 +++++++++--- src/utilities/excel_merge.js | 281 +++++++++++++++++++ tests/excel-merge.test.js | 503 +++++++++++++++++++++++++++++++++++ 6 files changed, 1045 insertions(+), 54 deletions(-) create mode 100644 src/utilities/excel_merge.js create mode 100644 tests/excel-merge.test.js diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index 1edb178..2f72463 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -274,6 +274,8 @@
Shown: + diff --git a/src/managers/io.js b/src/managers/io.js index 6224a2e..fd5a4c7 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -718,17 +718,29 @@ class IOManager { * Parses an Excel file into the required JSON structure. * * @param {File} file - The Excel file to be parsed. + * @param {Object} [options] + * @param {boolean} [options.merge] - Merge mode: a single "nodes" or "edges" + * sheet suffices, and empty sheets are tolerated (one of them must have rows). + * @param {Set} [options.knownNodeIDs] - Node ids already in the graph; + * edge endpoints are validated against the file's nodes plus this set. * @returns {Object} - Parsed JSON structure compatible with the existing system. */ - async parseExcelToJson(file) { + async parseExcelToJson(file, options = {}) { + const merge = options.merge === true; + const knownNodeIDs = options.knownNodeIDs ?? new Set(); + const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file); const nodesSheet = workbook.getWorksheet('nodes'); const edgesSheet = workbook.getWorksheet('edges'); - if (!nodesSheet || !edgesSheet) { - this.cache.ui.error('The Excel file must contain a "nodes" and "edges" sheet.'); + if (merge ? !nodesSheet && !edgesSheet : !nodesSheet || !edgesSheet) { + this.cache.ui.error( + merge + ? 'The Excel file must contain a "nodes" and/or "edges" sheet.' + : 'The Excel file must contain a "nodes" and "edges" sheet.' + ); return; } @@ -783,6 +795,8 @@ class IOManager { }; const removeEmptyColumns = (sheetJson, sheetDescriptor) => { + if (!sheetJson || sheetJson.length === 0) return; + const propertyDefs = sheetDescriptor === 'edges' ? EXCEL_EDGE_PROPERTIES : EXCEL_NODE_PROPERTIES; const requiredCols = propertyDefs.filter((prop) => prop.required).map((prop) => prop.column); @@ -835,7 +849,7 @@ class IOManager { }; const worksheetToJson = (worksheet) => { - if (!worksheet) return []; + if (!worksheet) return { headers: [], jsonData: [] }; const jsonData = []; const headers = []; @@ -897,33 +911,42 @@ class IOManager { const nodesData = nodesDataDict.jsonData; const edgesData = edgesDataDict.jsonData; - if (nodesData.length === 0) { + if (nodesData.length === 0 && !merge) { this.cache.ui.error('The "nodes" sheet is empty or invalid.'); return; } - if (edgesData.length === 0) { + if (edgesData.length === 0 && !merge) { this.cache.ui.error('The "edges" sheet is empty or invalid.'); return; } + if (merge && nodesData.length === 0 && edgesData.length === 0) { + this.cache.ui.error('The Excel file contains no node or edge rows.'); + return; + } + sanitizeColumns(nodesData, 'nodes'); sanitizeColumns(edgesData, 'edges'); removeEmptyColumns(nodesData, 'nodes'); removeEmptyColumns(edgesData, 'edges'); - const firstNodeRowKeys = nodesDataDict.headers.map((k) => k.toLowerCase().trim()); - const requiredNodeColumns = EXCEL_NODE_PROPERTIES.filter((node) => node.required).map((node) => - node.column.toLowerCase().trim() - ); - validateColumns(requiredNodeColumns, firstNodeRowKeys, 'nodes'); + if (nodesData.length > 0) { + const firstNodeRowKeys = nodesDataDict.headers.map((k) => k.toLowerCase().trim()); + const requiredNodeColumns = EXCEL_NODE_PROPERTIES.filter((node) => node.required).map( + (node) => node.column.toLowerCase().trim() + ); + validateColumns(requiredNodeColumns, firstNodeRowKeys, 'nodes'); + } - const firstEdgeRowKeys = edgesDataDict.headers.map((k) => k.toLowerCase().trim()); - const requiredEdgeColumns = EXCEL_EDGE_PROPERTIES.filter((edge) => edge.required).map((edge) => - edge.column.toLowerCase().trim() - ); - validateColumns(requiredEdgeColumns, firstEdgeRowKeys, 'edges'); + if (edgesData.length > 0) { + const firstEdgeRowKeys = edgesDataDict.headers.map((k) => k.toLowerCase().trim()); + const requiredEdgeColumns = EXCEL_EDGE_PROPERTIES.filter((edge) => edge.required).map( + (edge) => edge.column.toLowerCase().trim() + ); + validateColumns(requiredEdgeColumns, firstEdgeRowKeys, 'edges'); + } const nonDataNodeColumns = new Set( EXCEL_NODE_PROPERTIES.map((p) => p.column.toLowerCase().trim()) @@ -1110,7 +1133,7 @@ class IOManager { return null; } - if (!nodeIDs.has(sourceID)) { + if (!nodeIDs.has(sourceID) && !knownNodeIDs.has(String(sourceID))) { this.cache.ui.warning( `Edge in row ${edgeRowNum} has an invalid/missing Source ID (${sourceID}) and will be skipped.` ); @@ -1125,7 +1148,7 @@ class IOManager { return null; } - if (!nodeIDs.has(targetID)) { + if (!nodeIDs.has(targetID) && !knownNodeIDs.has(String(targetID))) { this.cache.ui.warning( `Edge in row ${edgeRowNum} has an invalid/missing Target ID (${targetID}) and will be skipped.` ); @@ -1165,6 +1188,8 @@ class IOManager { edges: parsedEdges, nodeDataHeaders: nodeDataHeaders, edgeDataHeaders: edgeDataHeaders, + skippedNodeRows: nodesData.length - parsedNodes.length, + skippedEdgeRows: edgesData.length - parsedEdges.length, }; } diff --git a/src/style.css b/src/style.css index a4c92ff..ef04c25 100644 --- a/src/style.css +++ b/src/style.css @@ -5825,3 +5825,91 @@ input:checked + .slider:before { border-color: #e3b5b5; background-color: #fdf7f7; } + +/* ==== Data editor: Excel import & merge preview ==== */ + +.merge-preview-file { + font-weight: bold; + margin-bottom: 10px; + word-break: break-all; +} + +.merge-preview-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px; + margin-bottom: 12px; +} + +.merge-stat { + background-color: var(--surface-2); + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: 8px 12px; +} + +.merge-stat-num { + display: block; + font-size: 20px; + font-weight: bold; +} + +.merge-stat.add .merge-stat-num { + color: var(--success-text); +} + +.merge-stat.mod .merge-stat-num { + color: var(--brand-text); +} + +.merge-stat.zero .merge-stat-num { + color: var(--text-faint); +} + +.merge-stat-label { + font-size: 12px; + color: var(--text-muted); +} + +.merge-preview-columns { + margin-bottom: 10px; +} + +.merge-preview-columns-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 4px; +} + +.merge-preview-col-badge { + display: inline-block; + background-color: var(--surface-3); + border-radius: 4px; + padding: 1px 6px; + margin: 0 4px 4px 0; + font-size: 12px; +} + +.merge-preview-note { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.merge-preview-details { + margin-bottom: 6px; +} + +.merge-preview-details summary { + cursor: pointer; + font-size: 13px; +} + +.merge-preview-ids { + font-size: 12px; + color: var(--text-muted); + margin: 4px 0 4px 14px; + word-break: break-all; + max-height: 120px; + overflow-y: auto; +} diff --git a/src/utilities/data_editor.js b/src/utilities/data_editor.js index de0313e..57d12e5 100644 --- a/src/utilities/data_editor.js +++ b/src/utilities/data_editor.js @@ -2,6 +2,7 @@ import { Popup } from './popup.js'; import { StaticUtilities } from './static.js'; import { EXCEL_NODE_PROPERTIES, EXCEL_EDGE_PROPERTIES } from '../managers/io.js'; +import { computeMergePlan, showMergePreview } from './excel_merge.js'; let dataTable; @@ -961,55 +962,145 @@ class DataTable { this.pendingChanges.clear(); - await this.cache.graph?.destroy(); - this.cache.graph = null; + await this.rebuildGraph(updatedFileData); - const status = document.getElementById('sidebarStatusContainer'); - status.innerHTML = ''; - status.style.height = '0'; + console.log('DATA TABLE UPDATE DONE!'); + } catch (err) { + this.cache.ui.error(`Error updating graph: ${err}`); + } finally { + await this.cache.ui.hideLoading(); + } + } - await this.cache.gcm.destroyGraphAndRollBackUI(); - this.cache.gcm.resetEventLocks(); + /** + * Tear down the current graph and rebuild it from updatedFileData (which + * must carry the preserved layouts/selectedLayout). Shared by the Apply + * button and the Excel import merge. + */ + async rebuildGraph(updatedFileData) { + await this.cache.graph?.destroy(); + this.cache.graph = null; + + const status = document.getElementById('sidebarStatusContainer'); + status.innerHTML = ''; + status.style.height = '0'; + + await this.cache.gcm.destroyGraphAndRollBackUI(); + this.cache.gcm.resetEventLocks(); + + // Reset lasso wrapper visual state to match default behavior (no lasso mode) + const lassoWrapper = document.getElementById('lassoWrapper'); + if (lassoWrapper) { + lassoWrapper.classList.remove('active'); + } + this.cache.io.preProcessData(updatedFileData); - // Reset lasso wrapper visual state to match default behavior (no lasso mode) - const lassoWrapper = document.getElementById('lassoWrapper'); - if (lassoWrapper) { - lassoWrapper.classList.remove('active'); - } - this.cache.io.preProcessData(updatedFileData); + // Clear any saved query to prevent filtering issues with new columns + const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout]; + if (currentLayout && currentLayout.query) { + delete currentLayout.query; + this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false; + } - // Clear any saved query to prevent filtering issues with new columns - const currentLayout = this.cache.data.layouts[this.cache.data.selectedLayout]; - if (currentLayout && currentLayout.query) { - delete currentLayout.query; - this.cache.EVENT_LOCKS.FILTERS_LOCKED_BY_MANUAL_QUERY = false; - } + this.cache.buildDataTable(updatedFileData); + this.cache.ui.buildUI(); - // this.cache.initialize(updatedFileData); - this.cache.buildDataTable(updatedFileData); - this.cache.ui.buildUI(); - // this.fileData = structuredClone(updatedFileData); + await this.cache.gcm.createGraphInstance(); + if (!this.cache.graph) { + this.cache.ui.error('Graph not initialized, aborting.'); + return; + } + await this.cache.graph.render(); - await this.cache.gcm.createGraphInstance(); - if (!this.cache.graph) { - this.cache.ui.error('Graph not initialized, aborting.'); - return; - } - await this.cache.graph.render(); + // Refresh UI to update node/edge counts and filter availability + this.cache.ui.refreshUI(); - // Refresh UI to update node/edge counts and filter availability - this.cache.ui.refreshUI(); + // Reload data table with updated fileData to ensure headers are in sync + this.fileData = updatedFileData; + this.loadTabData(); + } - // Reload data table with updated fileData to ensure headers are in sync - this.fileData = updatedFileData; - this.loadTabData(); + /** Open a file picker and merge the chosen Excel file into the loaded graph. */ + importExcel() { + if (!this.fileData) { + this.cache.ui.error('Load a graph before importing.'); + return; + } + if (this.pendingChanges.size > 0) { + this.cache.ui.error('Apply or reset the pending data editor changes before importing.'); + return; + } - console.log('DATA TABLE UPDATE DONE!'); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.xlsx,.xls,.ods'; + input.addEventListener('change', () => this.handleImportFile(input.files[0])); + input.click(); + } + + async handleImportFile(file) { + if (!file) return; + + let incoming = null; + await this.cache.ui.showLoading('Import', `Reading ${file.name} ..`); + try { + const buffer = await file.arrayBuffer(); + incoming = await this.cache.io.parseExcelToJson(buffer, { + merge: true, + knownNodeIDs: new Set((this.fileData.nodes || []).map((n) => String(n.id))), + }); } catch (err) { - this.cache.ui.error(`Error updating graph: ${err}`); + this.cache.ui.error(`Error reading Excel file: ${err}`); } finally { await this.cache.ui.hideLoading(); } + if (!incoming) return; + + const plan = computeMergePlan(this.fileData, incoming); + const confirmed = await showMergePreview(plan, file.name); + if (!confirmed) { + this.cache.ui.info('Import canceled'); + return; + } + + await this.cache.ui.showLoading('Import', `Merging ${file.name} into the graph ..`); + try { + this.seedImportedPositions(plan); + await this.rebuildGraph({ + ...plan.fileData, + layouts: this.cache.data.layouts, + selectedLayout: this.cache.data.selectedLayout, + }); + const s = plan.stats; + this.cache.ui.success( + `Imported "${file.name}": ${s.nodes.added.length} new / ${s.nodes.modified.length} updated nodes, ` + + `${s.edges.added.length} new / ${s.edges.modified.length} updated edges.` + ); + } catch (err) { + this.cache.ui.error(`Error merging Excel file: ${err}`); + } finally { + await this.cache.ui.hideLoading(); + } + } + + /** + * Honour X/Y coordinates from the imported file for NEW nodes by seeding the + * current workspace's positions map; existing node positions are never + * touched. New nodes without coordinates fall back to the renderer's + * deterministic placeholder ring. + */ + seedImportedPositions(plan) { + const positions = this.cache.data.layouts[this.cache.data.selectedLayout]?.positions; + if (!positions) return; + + const addedIds = new Set(plan.stats.nodes.added); + for (const node of plan.fileData.nodes) { + const id = String(node.id); + if (!addedIds.has(id) || positions.has(id)) continue; + if (Number.isFinite(node.style?.x) && Number.isFinite(node.style?.y)) { + positions.set(id, { style: { x: node.style.x, y: node.style.y } }); + } + } } validateNewElements() { @@ -1386,6 +1477,7 @@ class DataTable {
  • + Node — Create a new node in the graph
  • + Edge — Create a new edge between existing nodes
  • + Column — Add a new property column to the table
  • +
  • ⤒ Import — Merge an Excel file into the loaded graph: extend existing nodes/edges with new columns or values, or add entirely new ones — a preview shows what changes before anything is applied
  • ⤓ Export — Save current view as an Excel file
  • diff --git a/src/utilities/excel_merge.js b/src/utilities/excel_merge.js new file mode 100644 index 0000000..17ea094 --- /dev/null +++ b/src/utilities/excel_merge.js @@ -0,0 +1,281 @@ +import { CFG } from '../config.js'; +import { Popup } from './popup.js'; + +// Cap the id lists rendered in the preview modal; full counts are always shown. +const MAX_PREVIEW_IDS = 50; + +function headerLabel(header) { + return header.subGroup === CFG.EXCEL_UNCATEGORIZED_SUBHEADER + ? header.key + : `${header.key} [${header.subGroup}]`; +} + +// Serialized view of the fields a merge can touch — used to detect whether an +// incoming row effectively changes an existing element. +function snapshot(element) { + return JSON.stringify({ + label: element.label, + description: element.description, + type: element.type, + style: element.style || {}, + D4Data: element.D4Data || {}, + }); +} + +/** Deep-merge incoming user data (group → subGroup → prop) into a clone. */ +function mergeD4Data(existing, incoming) { + const merged = structuredClone(existing || {}); + for (const [group, subGroups] of Object.entries(incoming || {})) { + for (const [subGroup, props] of Object.entries(subGroups || {})) { + if (Object.keys(props).length === 0) continue; + if (!merged[group]) merged[group] = {}; + merged[group][subGroup] = { ...merged[group][subGroup], ...props }; + } + } + return merged; +} + +// Overlay an incoming (freshly parsed) element onto a clone of the existing +// one. Incoming style/D4Data only carry values that were actually present in +// the file, so spreading them never resets unrelated properties to defaults. +function mergeElement(existing, incoming) { + const merged = structuredClone(existing); + if (incoming.label !== undefined) merged.label = incoming.label; + if (incoming.description !== undefined) merged.description = incoming.description; + if (incoming.type !== undefined) merged.type = incoming.type; + merged.style = { ...(existing.style || {}), ...structuredClone(incoming.style || {}) }; + merged.D4Data = mergeD4Data(existing.D4Data, incoming.D4Data); + return merged; +} + +function mergeHeaders(currentHeaders, incomingHeaders) { + const headers = currentHeaders.map((h) => ({ ...h })); + const added = []; + for (const header of incomingHeaders || []) { + const exists = headers.some((c) => c.subGroup === header.subGroup && c.key === header.key); + if (exists) continue; + const entry = { subGroup: header.subGroup, key: header.key }; + // Insert next to existing group members to keep groups contiguous + const lastGroupIdx = headers.findLastIndex((c) => c.subGroup === header.subGroup); + if (lastGroupIdx !== -1) { + headers.splice(lastGroupIdx + 1, 0, entry); + } else { + headers.push(entry); + } + added.push(headerLabel(entry)); + } + return { headers, added }; +} + +// Ids are matched as strings: Excel numeric cells parse to numbers while ids +// that round-tripped through JSON or graphology are strings. +function mergeElements(currentList, incomingList) { + const indexById = new Map(currentList.map((el, idx) => [String(el.id), idx])); + const merged = currentList.map((el) => structuredClone(el)); + const added = []; + const modified = []; + let matchedUnchanged = 0; + + for (const incoming of incomingList || []) { + const id = String(incoming.id); + const idx = indexById.get(id); + if (idx === undefined) { + indexById.set(id, merged.length); + merged.push(structuredClone(incoming)); + added.push(id); + } else { + const mergedElement = mergeElement(merged[idx], incoming); + if (snapshot(merged[idx]) !== snapshot(mergedElement)) { + merged[idx] = mergedElement; + if (!modified.includes(id)) modified.push(id); + } else { + matchedUnchanged++; + } + } + } + + return { merged, added, modified, matchedUnchanged }; +} + +/** + * Compute a merge of freshly parsed Excel data into the currently loaded + * graph data. Pure: neither input is mutated. + * + * @param {Object} current - { nodes, edges, nodeDataHeaders, edgeDataHeaders } + * @param {Object} incoming - parseExcelToJson result (merge mode) + * @returns {{fileData: Object, stats: Object}} merged fileData plus preview stats + */ +function computeMergePlan(current, incoming) { + const nodes = mergeElements(current.nodes || [], incoming.nodes); + const edges = mergeElements(current.edges || [], incoming.edges); + const nodeHeaders = mergeHeaders(current.nodeDataHeaders || [], incoming.nodeDataHeaders); + const edgeHeaders = mergeHeaders(current.edgeDataHeaders || [], incoming.edgeDataHeaders); + + const hasChanges = + nodes.added.length > 0 || + nodes.modified.length > 0 || + edges.added.length > 0 || + edges.modified.length > 0 || + nodeHeaders.added.length > 0 || + edgeHeaders.added.length > 0; + + return { + fileData: { + nodes: nodes.merged, + edges: edges.merged, + nodeDataHeaders: nodeHeaders.headers, + edgeDataHeaders: edgeHeaders.headers, + }, + stats: { + nodes: { added: nodes.added, modified: nodes.modified, unchanged: nodes.matchedUnchanged }, + edges: { added: edges.added, modified: edges.modified, unchanged: edges.matchedUnchanged }, + newNodeColumns: nodeHeaders.added, + newEdgeColumns: edgeHeaders.added, + skippedNodeRows: incoming.skippedNodeRows || 0, + skippedEdgeRows: incoming.skippedEdgeRows || 0, + hasChanges, + }, + }; +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function statTile(count, label, kind) { + const tile = el('div', `merge-stat ${count > 0 ? kind : 'zero'}`); + const prefix = count > 0 ? (kind === 'add' ? '+' : '~') : ''; + tile.appendChild(el('span', 'merge-stat-num', `${prefix}${count}`)); + tile.appendChild(el('span', 'merge-stat-label', label)); + return tile; +} + +function idListDetails(title, ids) { + const details = el('details', 'merge-preview-details'); + details.appendChild(el('summary', null, `${title} (${ids.length})`)); + const shown = ids.slice(0, MAX_PREVIEW_IDS).join(', '); + const more = ids.length > MAX_PREVIEW_IDS ? ` … and ${ids.length - MAX_PREVIEW_IDS} more` : ''; + const list = el('div', 'merge-preview-ids', shown + more); + // Scrollable region must be keyboard-reachable (WCAG 2.1.1) + list.tabIndex = 0; + details.appendChild(list); + return details; +} + +function appendColumnsAndNotes(content, stats) { + const newColumns = [ + ...stats.newNodeColumns.map((c) => `${c} (nodes)`), + ...stats.newEdgeColumns.map((c) => `${c} (edges)`), + ]; + if (newColumns.length > 0) { + const columns = el('div', 'merge-preview-columns'); + columns.appendChild(el('div', 'merge-preview-columns-label', 'New property columns')); + newColumns.forEach((c) => columns.appendChild(el('span', 'merge-preview-col-badge', c))); + content.appendChild(columns); + } + + const notes = []; + if (stats.nodes.unchanged > 0 || stats.edges.unchanged > 0) { + notes.push( + `${stats.nodes.unchanged} node row(s) and ${stats.edges.unchanged} edge row(s) ` + + `match the current graph and stay unchanged.` + ); + } + if (stats.skippedNodeRows > 0 || stats.skippedEdgeRows > 0) { + notes.push( + `${stats.skippedNodeRows} node row(s) and ${stats.skippedEdgeRows} edge row(s) ` + + `were skipped while reading the file (see notifications).` + ); + } + notes.forEach((note) => content.appendChild(el('div', 'merge-preview-note', note))); +} + +function buildMergePreviewContent(stats, fileName) { + const content = el('div', 'merge-preview'); + content.appendChild(el('div', 'merge-preview-file', `📄 ${fileName}`)); + + const grid = el('div', 'merge-preview-grid'); + grid.appendChild(statTile(stats.nodes.added.length, 'New nodes', 'add')); + grid.appendChild(statTile(stats.nodes.modified.length, 'Updated nodes', 'mod')); + grid.appendChild(statTile(stats.edges.added.length, 'New edges', 'add')); + grid.appendChild(statTile(stats.edges.modified.length, 'Updated edges', 'mod')); + content.appendChild(grid); + + appendColumnsAndNotes(content, stats); + + if (stats.nodes.added.length > 0) content.appendChild(idListDetails('New nodes', stats.nodes.added)); + if (stats.nodes.modified.length > 0) + content.appendChild(idListDetails('Updated nodes', stats.nodes.modified)); + if (stats.edges.added.length > 0) content.appendChild(idListDetails('New edges', stats.edges.added)); + if (stats.edges.modified.length > 0) + content.appendChild(idListDetails('Updated edges', stats.edges.modified)); + + if (stats.hasChanges) { + const warning = el('div', 'alert-warning'); + const strong = el('strong', null, '⚠️ Important: '); + warning.appendChild(strong); + warning.appendChild( + document.createTextNode( + 'Merging updates the data across all workspaces and cannot be undone. ' + + 'Export your graph first if you need a fallback.' + ) + ); + content.appendChild(warning); + } else { + content.appendChild( + el('div', 'alert-info', 'No changes detected — this file matches the current graph.') + ); + } + + return content; +} + +/** + * Show the import preview modal for a computed merge plan. + * + * @param {Object} plan - computeMergePlan result + * @param {string} fileName - name of the imported file + * @returns {Promise} true when the user confirms the import + */ +function showMergePreview(plan, fileName) { + return new Promise((resolve) => { + const content = buildMergePreviewContent(plan.stats, fileName); + + const footer = el('div', 'p-footer'); + const cancelBtn = el('button', 'p-button p-button-secondary', 'Cancel'); + const importBtn = el('button', 'p-button p-button-primary', '✔ Import'); + importBtn.disabled = !plan.stats.hasChanges; + footer.appendChild(cancelBtn); + footer.appendChild(importBtn); + content.appendChild(footer); + + let isResolved = false; + const popup = new Popup(content, { + title: 'Import & Merge Excel', + width: '480px', + showFullscreenButton: false, + closeOnClickOutside: false, + onClose: () => { + if (!isResolved) resolve(false); + }, + }); + + importBtn.addEventListener('click', () => { + isResolved = true; + popup.close(); + resolve(true); + }); + cancelBtn.addEventListener('click', () => { + isResolved = true; + popup.close(); + resolve(false); + }); + + setTimeout(() => (plan.stats.hasChanges ? importBtn : cancelBtn).focus(), 0); + }); +} + +export { computeMergePlan, buildMergePreviewContent, showMergePreview }; diff --git a/tests/excel-merge.test.js b/tests/excel-merge.test.js new file mode 100644 index 0000000..acd2c12 --- /dev/null +++ b/tests/excel-merge.test.js @@ -0,0 +1,503 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest"; +import ExcelJS from "exceljs"; +import { IOManager } from "../src/managers/io.js"; +import { DataTable } from "../src/utilities/data_editor.js"; +import { + computeMergePlan, + buildMergePreviewContent, + showMergePreview, +} from "../src/utilities/excel_merge.js"; +import { CFG, DEFAULTS } from "../src/config.js"; + +// ========================================================================== +// Excel import & merge — the "⤒ Import" data-editor feature. Covers the pure +// merge plan (computeMergePlan), the merge-mode Excel parser options, and the +// preview modal. +// ========================================================================== + +beforeAll(() => { + // Production code references ExcelJS as a global (vendored script tag). + globalThis.ExcelJS = ExcelJS; + // Popup schedules a visibility check via rAF; jsdom lacks it. + globalThis.requestAnimationFrame ??= (cb) => setTimeout(cb, 0); +}); + +function createMockCache() { + return { + CFG, + DEFAULTS, + data: { filterDefaults: new Map() }, + bs: { + traverseBubbleSets: function* () { + for (const group of Object.keys(DEFAULTS.BUBBLE_GROUP_QUADRANT_POSITIONS)) { + yield group; + } + }, + }, + ui: { warning: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + nodeRef: new Map(), + edgeRef: new Map(), + }; +} + +/** Build an Excel buffer; pass null to omit a sheet entirely. */ +async function buildWorkbook(nodeRows, edgeRows) { + const workbook = new ExcelJS.Workbook(); + const addSheet = (name, rows) => { + if (rows === null) return; + const sheet = workbook.addWorksheet(name); + if (rows.length > 0) { + const cols = Object.keys(rows[0]); + sheet.addRow(cols); + for (const row of rows) sheet.addRow(cols.map((c) => row[c])); + } + }; + addSheet("nodes", nodeRows); + addSheet("edges", edgeRows); + return workbook.xlsx.writeBuffer(); +} + +const NODE_HEADER = CFG.EXCEL_NODE_HEADER; +const EDGE_HEADER = CFG.EXCEL_EDGE_HEADER; + +function currentGraphFixture() { + return { + nodes: [ + { + id: "A", + label: "Node A", + style: { size: 20 }, + D4Data: { [NODE_HEADER]: { "group A": { "Feature X": 1 } } }, + }, + { + id: "B", + style: {}, + D4Data: { [NODE_HEADER]: { "group A": { "Feature X": 2 } } }, + }, + ], + edges: [ + { + id: "A::B", + source: "A", + target: "B", + style: {}, + D4Data: { [EDGE_HEADER]: { "group X": { Weight: 0.5 } } }, + }, + ], + nodeDataHeaders: [{ subGroup: "group A", key: "Feature X" }], + edgeDataHeaders: [{ subGroup: "group X", key: "Weight" }], + }; +} + +// -------------------------------------------------------------------------- +// computeMergePlan +// -------------------------------------------------------------------------- + +describe("computeMergePlan", () => { + it("extends an existing node with a new property column", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [ + { + id: "A", + style: {}, + D4Data: { [NODE_HEADER]: { "group A": { "Feature Y": 5 } } }, + }, + ], + edges: [], + nodeDataHeaders: [ + { subGroup: "group A", key: "Feature X" }, + { subGroup: "group A", key: "Feature Y" }, + ], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + + expect(plan.stats.nodes.added).toEqual([]); + expect(plan.stats.nodes.modified).toEqual(["A"]); + expect(plan.stats.newNodeColumns).toEqual(["Feature Y [group A]"]); + expect(plan.stats.hasChanges).toBe(true); + + const mergedA = plan.fileData.nodes.find((n) => n.id === "A"); + expect(mergedA.D4Data[NODE_HEADER]["group A"]).toEqual({ + "Feature X": 1, + "Feature Y": 5, + }); + // Existing fields survive the overlay + expect(mergedA.label).toBe("Node A"); + expect(mergedA.style.size).toBe(20); + }); + + it("treats a re-import of identical data as unchanged", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [ + { + id: "A", + style: {}, + D4Data: { [NODE_HEADER]: { "group A": { "Feature X": 1 } } }, + }, + ], + edges: [ + { + id: "A::B", + source: "A", + target: "B", + style: {}, + D4Data: { [EDGE_HEADER]: { "group X": { Weight: 0.5 } } }, + }, + ], + nodeDataHeaders: [{ subGroup: "group A", key: "Feature X" }], + edgeDataHeaders: [{ subGroup: "group X", key: "Weight" }], + }; + + const plan = computeMergePlan(current, incoming); + + expect(plan.stats.nodes.modified).toEqual([]); + expect(plan.stats.nodes.unchanged).toBe(1); + expect(plan.stats.edges.unchanged).toBe(1); + expect(plan.stats.hasChanges).toBe(false); + }); + + it("ignores the empty D4Data scaffolding the parser always emits", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [{ id: "A", style: {}, D4Data: { [NODE_HEADER]: {} } }], + edges: [], + nodeDataHeaders: [], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + expect(plan.stats.nodes.modified).toEqual([]); + expect(plan.stats.hasChanges).toBe(false); + }); + + it("adds new nodes and edges, including edges onto existing nodes", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [ + { + id: "C", + style: {}, + D4Data: { [NODE_HEADER]: { "group B": { "Feature Z": 3 } } }, + }, + ], + edges: [ + { id: "A::C", source: "A", target: "C", style: {}, D4Data: {} }, + ], + nodeDataHeaders: [{ subGroup: "group B", key: "Feature Z" }], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + + expect(plan.stats.nodes.added).toEqual(["C"]); + expect(plan.stats.edges.added).toEqual(["A::C"]); + expect(plan.fileData.nodes).toHaveLength(3); + expect(plan.fileData.edges).toHaveLength(2); + expect(plan.stats.newNodeColumns).toEqual(["Feature Z [group B]"]); + }); + + it("updates label, description and style on existing elements", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [ + { + id: "B", + label: "Node B", + description: "desc", + style: { fill: "#FF0000" }, + D4Data: {}, + }, + ], + edges: [], + nodeDataHeaders: [], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + const mergedB = plan.fileData.nodes.find((n) => n.id === "B"); + + expect(plan.stats.nodes.modified).toEqual(["B"]); + expect(mergedB.label).toBe("Node B"); + expect(mergedB.description).toBe("desc"); + expect(mergedB.style.fill).toBe("#FF0000"); + }); + + it("does not mutate its inputs", () => { + const current = currentGraphFixture(); + const incoming = { + nodes: [ + { + id: "A", + style: { size: 99 }, + D4Data: { [NODE_HEADER]: { "group A": { "Feature Y": 5 } } }, + }, + ], + edges: [], + nodeDataHeaders: [{ subGroup: "group A", key: "Feature Y" }], + edgeDataHeaders: [], + }; + const currentBefore = JSON.stringify(current); + const incomingBefore = JSON.stringify(incoming); + + computeMergePlan(current, incoming); + + expect(JSON.stringify(current)).toBe(currentBefore); + expect(JSON.stringify(incoming)).toBe(incomingBefore); + }); + + it("matches ids across string/number representations", () => { + const current = { + nodes: [{ id: "1", style: {}, D4Data: {} }], + edges: [], + nodeDataHeaders: [], + edgeDataHeaders: [], + }; + const incoming = { + nodes: [ + { id: 1, style: {}, D4Data: { [NODE_HEADER]: { g: { p: 7 } } } }, + ], + edges: [], + nodeDataHeaders: [{ subGroup: "g", key: "p" }], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + expect(plan.stats.nodes.added).toEqual([]); + expect(plan.stats.nodes.modified).toEqual(["1"]); + expect(plan.fileData.nodes).toHaveLength(1); + }); + + it("keeps new columns contiguous with their group", () => { + const current = { + nodes: [], + edges: [], + nodeDataHeaders: [ + { subGroup: "g1", key: "a" }, + { subGroup: "g2", key: "b" }, + ], + edgeDataHeaders: [], + }; + const incoming = { + nodes: [], + edges: [], + nodeDataHeaders: [{ subGroup: "g1", key: "c" }], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + expect(plan.fileData.nodeDataHeaders).toEqual([ + { subGroup: "g1", key: "a" }, + { subGroup: "g1", key: "c" }, + { subGroup: "g2", key: "b" }, + ]); + }); + + it("labels uncategorized columns without a group suffix", () => { + const current = { nodes: [], edges: [], nodeDataHeaders: [], edgeDataHeaders: [] }; + const incoming = { + nodes: [], + edges: [], + nodeDataHeaders: [ + { subGroup: CFG.EXCEL_UNCATEGORIZED_SUBHEADER, key: "Plain" }, + ], + edgeDataHeaders: [], + }; + + const plan = computeMergePlan(current, incoming); + expect(plan.stats.newNodeColumns).toEqual(["Plain"]); + }); +}); + +// -------------------------------------------------------------------------- +// parseExcelToJson merge mode +// -------------------------------------------------------------------------- + +describe("parseExcelToJson merge mode", () => { + let cache; + let io; + + beforeEach(() => { + cache = createMockCache(); + io = new IOManager(cache); + }); + + it("accepts a nodes-only workbook in merge mode", async () => { + const buffer = await buildWorkbook( + [{ ID: "A", "Feature X [group A]": 1 }], + null + ); + + const result = await io.parseExcelToJson(buffer, { merge: true }); + + expect(cache.ui.error).not.toHaveBeenCalled(); + expect(result.nodes).toHaveLength(1); + expect(result.edges).toEqual([]); + expect(result.nodeDataHeaders).toEqual([{ subGroup: "group A", key: "Feature X" }]); + }); + + it("still requires both sheets outside merge mode", async () => { + const buffer = await buildWorkbook([{ ID: "A" }], null); + + const result = await io.parseExcelToJson(buffer); + + expect(result).toBeUndefined(); + expect(cache.ui.error).toHaveBeenCalledWith( + 'The Excel file must contain a "nodes" and "edges" sheet.' + ); + }); + + it("validates edge endpoints against known graph nodes", async () => { + const buffer = await buildWorkbook(null, [ + { "Source ID": "A", "Target ID": "B", "Weight [group X]": 1 }, + { "Source ID": "A", "Target ID": "MISSING", "Weight [group X]": 2 }, + ]); + + const result = await io.parseExcelToJson(buffer, { + merge: true, + knownNodeIDs: new Set(["A", "B"]), + }); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].id).toBe("A::B"); + expect(result.skippedEdgeRows).toBe(1); + expect(cache.ui.warning).toHaveBeenCalledWith( + expect.stringContaining("MISSING") + ); + }); + + it("rejects a merge workbook with no rows at all", async () => { + const buffer = await buildWorkbook([], []); + + const result = await io.parseExcelToJson(buffer, { merge: true }); + + expect(result).toBeUndefined(); + expect(cache.ui.error).toHaveBeenCalledWith( + "The Excel file contains no node or edge rows." + ); + }); +}); + +// -------------------------------------------------------------------------- +// Preview modal +// -------------------------------------------------------------------------- + +describe("merge preview modal", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + function planFixture(hasChanges = true) { + return { + fileData: { nodes: [], edges: [], nodeDataHeaders: [], edgeDataHeaders: [] }, + stats: { + nodes: { + added: hasChanges ? ["C", "D"] : [], + modified: hasChanges ? ["A"] : [], + unchanged: 1, + }, + edges: { added: [], modified: [], unchanged: 2 }, + newNodeColumns: hasChanges ? ["Feature Y [group A]"] : [], + newEdgeColumns: [], + skippedNodeRows: 0, + skippedEdgeRows: 1, + hasChanges, + }, + }; + } + + it("renders counts, columns, notes and id lists", () => { + const content = buildMergePreviewContent(planFixture().stats, "extra.xlsx"); + + expect(content.querySelector(".merge-preview-file").textContent).toContain("extra.xlsx"); + const nums = [...content.querySelectorAll(".merge-stat-num")].map((n) => n.textContent); + expect(nums).toEqual(["+2", "~1", "0", "0"]); + expect(content.querySelector(".merge-preview-col-badge").textContent).toBe( + "Feature Y [group A] (nodes)" + ); + const notes = [...content.querySelectorAll(".merge-preview-note")].map((n) => n.textContent); + expect(notes.some((t) => t.includes("stay unchanged"))).toBe(true); + expect(notes.some((t) => t.includes("skipped"))).toBe(true); + const details = [...content.querySelectorAll(".merge-preview-details summary")].map( + (s) => s.textContent + ); + expect(details).toEqual(["New nodes (2)", "Updated nodes (1)"]); + expect(content.querySelector(".alert-warning")).not.toBeNull(); + }); + + it("resolves true on Import and false on Cancel", async () => { + let promise = showMergePreview(planFixture(), "a.xlsx"); + document.querySelector(".p-footer .p-button-primary").click(); + await expect(promise).resolves.toBe(true); + + promise = showMergePreview(planFixture(), "a.xlsx"); + document.querySelector(".p-footer .p-button-secondary").click(); + await expect(promise).resolves.toBe(false); + }); + + it("disables Import and shows a notice when nothing changes", () => { + showMergePreview(planFixture(false), "same.xlsx"); + + const importBtn = document.querySelector(".p-footer .p-button-primary"); + expect(importBtn.disabled).toBe(true); + expect(document.querySelector(".alert-info").textContent).toContain("No changes detected"); + expect(document.querySelector(".alert-warning")).toBeNull(); + document.querySelector(".p-footer .p-button-secondary").click(); + }); +}); + +// -------------------------------------------------------------------------- +// DataTable import entry points +// -------------------------------------------------------------------------- + +describe("DataTable import guards and position seeding", () => { + let cache; + let table; + + beforeEach(() => { + cache = createMockCache(); + table = new DataTable(cache); + }); + + it("refuses to import before a graph is loaded", () => { + table.fileData = null; + table.importExcel(); + expect(cache.ui.error).toHaveBeenCalledWith("Load a graph before importing."); + }); + + it("refuses to import while data editor changes are pending", () => { + table.fileData = { nodes: [], edges: [] }; + table.pendingChanges.set("A", { type: "Node" }); + table.importExcel(); + expect(cache.ui.error).toHaveBeenCalledWith( + "Apply or reset the pending data editor changes before importing." + ); + }); + + it("seeds current-workspace positions only for NEW nodes with coordinates", () => { + const positions = new Map([["A", { style: { x: 1, y: 2 } }]]); + cache.data.layouts = { Default: { positions } }; + cache.data.selectedLayout = "Default"; + + const plan = { + stats: { nodes: { added: ["C", "D"] } }, + fileData: { + nodes: [ + { id: "A", style: { x: 99, y: 99 } }, // existing — never touched + { id: "C", style: { x: 10, y: 20 } }, // new with coordinates — seeded + { id: "D", style: {} }, // new without coordinates — placeholder ring + ], + }, + }; + + table.seedImportedPositions(plan); + + expect(positions.get("A")).toEqual({ style: { x: 1, y: 2 } }); + expect(positions.get("C")).toEqual({ style: { x: 10, y: 20 } }); + expect(positions.has("D")).toBe(false); + }); +}); From a9153cf855f2d271689f7867170878df9c54228c Mon Sep 17 00:00:00 2001 From: Mnikley Date: Tue, 21 Jul 2026 16:47:14 +0200 Subject: [PATCH 10/18] chore(scripts): fix ruff findings in LaTeX submission packager Remove an unused variable and four extraneous f-string prefixes. --- scripts/package_latex_submission.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/package_latex_submission.py b/scripts/package_latex_submission.py index c3ee20a..f8d9bdc 100755 --- a/scripts/package_latex_submission.py +++ b/scripts/package_latex_submission.py @@ -135,13 +135,12 @@ def clean_build_artifacts(work_dir: Path) -> None: def main() -> None: venue = pick_venue() - venue_dir = MANUSCRIPT / venue print(f"Packaging '{venue}' submission …\n") files = collect_files(venue) missing = [src for src, _ in files if not src.exists()] if missing: - die(f"Missing files:\n " + "\n ".join(str(p) for p in missing)) + die("Missing files:\n " + "\n ".join(str(p) for p in missing)) # 1. Create temp directory and copy files tmp = Path(tempfile.mkdtemp(prefix="gll-submission-")) @@ -152,10 +151,10 @@ def main() -> None: # 2. Flatten paths in main.tex flatten_main_tex(tmp / "main.tex") - print(f"\n Flattened paths in main.tex") + print("\n Flattened paths in main.tex") # 3. Compile test PDF - print(f"\n Compiling test PDF …") + print("\n Compiling test PDF …") pdf = compile_pdf(tmp) print(f" OK — {pdf.stat().st_size / 1024:.0f} KB\n") @@ -181,7 +180,7 @@ def main() -> None: shutil.rmtree(tmp) print(f"\n Created {final_zip.relative_to(REPO_ROOT)} ({size_mb:.1f} MB)") - print(f" Temp directory removed.") + print(" Temp directory removed.") if __name__ == "__main__": From 0e8012d50c7e37c4e97c6d8ad4f8a5d946fada64 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:09:22 +0200 Subject: [PATCH 11/18] feat(graph): organic arced corridors and hug-tight proportional padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The straight lines below the field's resolution limit were the geometric capsules: the marching grid physically cannot trace corridors thinner than ~two grid cells, so the thin-corridor range is always capsule-built. capsuleRing now runs along a gentle quadratic arc (sagitta 8% of length, capped by tube width, bulge side stable across refits) with semicircular caps — thin corridors read organic instead of ruler-drawn. The leftover space around members at padding 0.01 was the absolute 2 px disc-pad minimum, which is ~a quarter node radius on dense graphs (small ratio-1 radii) and magnifies under zoom. Reconstruction minimums are now PROPORTIONAL: disc pad ≥ 5% of the member's own radius, capsule half-width ≥ 15% of the group's mean radius — knob minimum means visually hugging at every node scale and zoom. --- src/graph/bubble_geometry.js | 95 +++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/src/graph/bubble_geometry.js b/src/graph/bubble_geometry.js index 6d0c081..fc65ff3 100644 --- a/src/graph/bubble_geometry.js +++ b/src/graph/bubble_geometry.js @@ -108,13 +108,21 @@ const HOLE_GAP_RADIUS_RATIO = 0.25; // small nodes. const FIELD_PIXEL_GROUP_MIN = 1; const FIELD_PIXEL_GROUP_MAX = 4; -// Semicircular cap resolution for the stadium-shaped straggler links. +// Capsule links are gently ARCED tubes (quadratic, deterministic bulge), so +// the geometric corridors below field resolution read organic rather than +// ruler-straight. Sagitta is a fraction of the link length, capped relative +// to the tube width so hairlines stay near-straight. const CAPSULE_CAP_SEGMENTS = 8; -// Visual minimums for the geometric reconstruction (repair discs + capsule -// corridors): the tightest hull still clears the node body by roughly the -// outline stroke, and a corridor stays a visible hairline. -const DISC_PAD_MIN_PX = 2; -const LINK_HALF_WIDTH_MIN_PX = 1.25; +const CAPSULE_ARC_SEGMENTS = 12; +const CAPSULE_ARC_SAGITTA_RATIO = 0.08; +const CAPSULE_ARC_SAGITTA_MAX_R = 6; +// Visual minimums for the geometric reconstruction, PROPORTIONAL to node +// size — the fit runs in ratio-1 reference space, so an absolute px minimum +// magnifies under zoom and reads as leftover padding on dense graphs. +// Disc pad: fraction of the member's OWN radius; link half-width: fraction +// of the group's mean member radius. +const DISC_PAD_MIN_RATIO = 0.05; +const LINK_HALF_WIDTH_MIN_RATIO = 0.15; /** * Axis-aligned square around a node's viewport position. @@ -210,10 +218,9 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { const nodeR1px = FIELD_NODE_R1_RATIO * unit * pad; const edgeR0px = FIELD_EDGE_R0_RATIO * unit * cor; const edgeR1px = FIELD_EDGE_R1_RATIO * unit * cor; - // Repair-disc padding and capsule half-width keep a small visual minimum - // so the tightest hull still clears the node body by the stroke width. - const discPadPx = Math.max(nodeR0px, DISC_PAD_MIN_PX); - const linkRPx = Math.max(edgeR0px, LINK_HALF_WIDTH_MIN_PX); + // Capsule half-width keeps a proportional visual minimum (disc padding + // gets its per-member minimum inside ensureMembersEnclosed). + const linkRPx = Math.max(edgeR0px, LINK_HALF_WIDTH_MIN_RATIO * unit); // Avoidance 0 means "ignore non-members" — drop the rects entirely so // virtual-edge routing stops detouring around them too (and the // O(members × avoid) routing cost disappears with them). @@ -268,10 +275,10 @@ function computeOutlineGeometry(memberRects, avoidRects = [], opts = {}) { if (repaired.length >= 3) outline = repaired; } } - outline = ensureMembersEnclosed(outline, memberRects, discPadPx, linkRPx); - // Hole breathing room = the disc padding: carved non-members get the same - // visual gap members do (symmetric, does not scale with avoidance). - return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, discPadPx, discPadPx); + outline = ensureMembersEnclosed(outline, memberRects, nodeR0px, linkRPx); + // Hole breathing room = the padding radius (per-node proportional floor + // applied inside): carved non-members get the same visual gap members do. + return carveAvoidHoles(outline, memberRects, effectiveAvoidRects, nodeR0px, nodeR0px); } /** @@ -405,7 +412,10 @@ function ensureMembersEnclosed(points, memberRects, padPx, linkR) { const clearance = signedDist * distanceToPolygonEdge(cx, cy, painted) - radius; if (clearance >= minClearance) continue; } - discs.push([discRing(cx, cy, radius + padPx * round)]); + // Per-member proportional pad floor: at knob minimum the disc hugs the + // node at a fixed fraction of ITS radius, at any node scale. + const basePad = Math.max(padPx, DISC_PAD_MIN_RATIO * radius); + discs.push([discRing(cx, cy, radius + basePad * round)]); } if (discs.length === 0) return ring; let merged = ring.length >= 3 @@ -517,11 +527,14 @@ function nearestVertexPair(ringA, ringB) { } /** - * Closed stadium ring of half-width r along segment a→b: straight sides with - * semicircular caps centered on the endpoints, so straggler links join the - * hull without corners (the curve painter rounds them further). The caps - * extend r beyond both endpoints, so the shape always overlaps what it links. - * Null for degenerate (coincident) endpoints. + * Closed tube ring of half-width r along a GENTLY ARCED path from a to b + * (quadratic Bézier, sagitta CAPSULE_ARC_SAGITTA_RATIO × length, capped at + * CAPSULE_ARC_SAGITTA_MAX_R × r), with semicircular caps centered on the + * endpoints — straggler links read as organic corridors, not ruler lines. + * Endpoints are sorted before picking the bulge side, so the curve is stable + * across refits regardless of argument order. The caps extend r beyond both + * endpoints, so the shape always overlaps what it links. Null for degenerate + * (coincident) endpoints. * * @param {[number, number]} a * @param {[number, number]} b @@ -529,20 +542,46 @@ function nearestVertexPair(ringA, ringB) { * @returns {Array<[number, number]>|null} */ function capsuleRing(a, b, r) { + if (b[0] < a[0] || (b[0] === a[0] && b[1] < a[1])) [a, b] = [b, a]; const dx = b[0] - a[0]; const dy = b[1] - a[1]; const len = Math.hypot(dx, dy); if (len < 1e-9) return null; - const angle = Math.atan2(dy, dx); - const ring = []; - const arc = (cx, cy, from) => { - for (let i = 0; i <= CAPSULE_CAP_SEGMENTS; i++) { - const t = from + (Math.PI * i) / CAPSULE_CAP_SEGMENTS; - ring.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); + const sag = Math.min(len * CAPSULE_ARC_SAGITTA_RATIO, r * CAPSULE_ARC_SAGITTA_MAX_R); + const cx = (a[0] + b[0]) / 2 - (dy / len) * sag; + const cy = (a[1] + b[1]) / 2 + (dx / len) * sag; + // Point + unit tangent on the quadratic a → (cx, cy) → b. + const at = (t) => { + const u = 1 - t; + const px = u * u * a[0] + 2 * u * t * cx + t * t * b[0]; + const py = u * u * a[1] + 2 * u * t * cy + t * t * b[1]; + let tx = u * (cx - a[0]) + t * (b[0] - cx); + let ty = u * (cy - a[1]) + t * (b[1] - cy); + const tl = Math.hypot(tx, ty) || 1; + return [px, py, tx / tl, ty / tl]; + }; + const left = []; + const right = []; + for (let i = 0; i <= CAPSULE_ARC_SEGMENTS; i++) { + const [px, py, tx, ty] = at(i / CAPSULE_ARC_SEGMENTS); + left.push([px - ty * r, py + tx * r]); + right.push([px + ty * r, py - tx * r]); + } + const ring = [...left]; + // Semicircular cap at b: from the left offset, through the forward + // tangent, to the right offset (interior points only — ends are in the + // side arrays already). + const capArc = (px, py, fromAngle) => { + for (let i = 1; i < CAPSULE_CAP_SEGMENTS; i++) { + const t = fromAngle - (Math.PI * i) / CAPSULE_CAP_SEGMENTS; + ring.push([px + r * Math.cos(t), py + r * Math.sin(t)]); } }; - arc(b[0], b[1], angle - Math.PI / 2); // cap around b - arc(a[0], a[1], angle + Math.PI / 2); // cap around a + const [bx, by, btx, bty] = at(1); + capArc(bx, by, Math.atan2(btx, -bty)); + for (let i = right.length - 1; i >= 0; i--) ring.push(right[i]); + const [ax, ay, atx, aty] = at(0); + capArc(ax, ay, Math.atan2(-atx, aty)); ring.push([ring[0][0], ring[0][1]]); return ring; } From 6d0fd73ddf626ac3d292c4e299b73644a15c12cb Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:50:12 +0200 Subject: [PATCH 12/18] feat(styling): default bubble knobs to padding 0.1, corridor 0.25, avoid on User-picked defaults after the live tuning rounds: snug-but-not-touching padding, corridor at the field-organic routing threshold, avoidance enabled (per-disc hole fallback makes it safe on dense graphs). --- src/config.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/config.js b/src/config.js index 2edea99..3253302 100644 --- a/src/config.js +++ b/src/config.js @@ -202,9 +202,9 @@ const DEFAULTS = { stroke: '#C33D35', strokeOpacity: 1, virtualEdges: true, - padding: 0.05, - corridor: 0.1, - avoidance: 0, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group one', labelFill: '#fff', @@ -225,9 +225,9 @@ const DEFAULTS = { stroke: '#403c53', strokeOpacity: 1, virtualEdges: true, - padding: 0.05, - corridor: 0.1, - avoidance: 0, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group two', labelFill: '#fff', @@ -248,9 +248,9 @@ const DEFAULTS = { stroke: '#8CA6D9', strokeOpacity: 1, virtualEdges: true, - padding: 0.05, - corridor: 0.1, - avoidance: 0, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group three', labelFill: '#fff', @@ -271,9 +271,9 @@ const DEFAULTS = { stroke: '#EFB0AA', strokeOpacity: 1, virtualEdges: true, - padding: 0.05, - corridor: 0.1, - avoidance: 0, + padding: 0.1, + corridor: 0.25, + avoidance: 1, label: true, labelText: 'group four', labelFill: '#fff', From 61f46182d73a7665f19952ef83d77d10dd9a4903 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:54:32 +0200 Subject: [PATCH 13/18] =?UTF-8?q?chore(release):=201.16.0=20=E2=80=94=20sm?= =?UTF-8?q?ooth,=20node-size-aware=20bubble=20groups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- src/config.js | 2 +- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f3658..424b13a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.16.0 — 2026-07-22 + +Saved graph files load unchanged. Stored bubble-set knob values keep applying, with two semantic shifts: padding and corridor width now scale with your configured node size (a value of 1 ≈ one node radius of margin) instead of absolute pixels, and any saved avoidance value above 0 now simply means "on". + +### Features + +* **Bubble-set geometry controls.** The Bubble Sets styling card gains **Padding** (how far the body extends past its members) and **Corridor Width** (thickness of the arms reaching outlying members) sliders, range 0.01–3, plus an **Avoid Other Nodes** switch that steers the hull around non-members and carves holes for fully enclosed ones. Defaults: padding 0.1, corridor 0.25, avoidance on. +* **Smooth, organic bubble outlines at every zoom.** Hulls render as continuous curves instead of polylines — no more faceted or jagged outlines when zooming in — and the PNG and SVG exports paint the exact same curves as the live canvas. +* **Node-size-aware hulls.** The influence field scales with the group's mean member radius, so bubbles look proportional whatever node size you configure. At minimum padding the hull hugs each node at a fixed fraction of *its own* radius, and minimum-width corridors render as thin, gently arced tubes. +* **A member is never lost.** Every member circle is guaranteed inside its group's outline (measured against the curve actually painted); groups whose members drift beyond the field's reach stay connected as one shape via corridor links. + +### Fixes + +* **Bubble hulls no longer clip their own members.** Avoid-node pressure could squeeze the outline through a member's body while its center stayed inside; grazed members now get repaired with proper clearance. +* **Enclosed non-members are no longer silently swallowed.** With avoidance on, a non-member inside the hull gets a visible carve whenever one is geometrically possible — and one impossible hole no longer discards all the others. +* **Bubble outlines no longer wobble around dense non-member fields**, and corridors no longer reroute into phantom lobes when widening them. + ## 1.15.5 — 2026-07-10 Saved graph files load unchanged; older files (and files saved by earlier versions) default the new opacity to fully opaque, so their appearance is identical. diff --git a/package.json b/package.json index c36c78a..aa7e02e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.15.5", + "version": "1.16.0", "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 3253302..89d03ea 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.15.5"; +const VERSION = "1.16.0"; const DEFAULTS = { NODE: { From 68d8591008269923915e939e0f9a386caba8fd42 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 12:57:31 +0200 Subject: [PATCH 14/18] docs(changelog): add merge-import to the 1.16.0 entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 424b13a..547da41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Saved graph files load unchanged. Stored bubble-set knob values keep applying, w ### Features +* **Merge-import Excel files into the loaded graph.** A new ⤒ Import button in the data editor header loads a workbook *into* the current graph instead of replacing it. A preview modal shows what the merge will do before anything is applied — new/updated node and edge counts, new property columns, unchanged and skipped rows. Existing workspaces, positions, filters and styles are preserved; a single nodes or edges sheet suffices, and new edges can attach to nodes already in the graph. X/Y coordinates in the file seed positions for new nodes only. * **Bubble-set geometry controls.** The Bubble Sets styling card gains **Padding** (how far the body extends past its members) and **Corridor Width** (thickness of the arms reaching outlying members) sliders, range 0.01–3, plus an **Avoid Other Nodes** switch that steers the hull around non-members and carves holes for fully enclosed ones. Defaults: padding 0.1, corridor 0.25, avoidance on. * **Smooth, organic bubble outlines at every zoom.** Hulls render as continuous curves instead of polylines — no more faceted or jagged outlines when zooming in — and the PNG and SVG exports paint the exact same curves as the live canvas. * **Node-size-aware hulls.** The influence field scales with the group's mean member radius, so bubbles look proportional whatever node size you configure. At minimum padding the hull hugs each node at a fixed fraction of *its own* radius, and minimum-width corridors render as thin, gently arced tubes. From 1e29535bea34d865b503ae87cd45c277db9a03bf Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 13:11:48 +0200 Subject: [PATCH 15/18] fix(io): normalize rich Excel cell values to primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workbooks re-saved by Excel/LibreOffice can carry rich cell values (rich-text runs, hyperlinks, formulas). The parser passed these through as raw objects, which (a) made every matched row look modified in the import preview — snapshot compare of object vs stored string — and (b) crashed the category filter dropdown after the merge rebuild (TypeError: val.toLowerCase is not a function). Normalize cell values at the parse boundary (richText joined, hyperlink text, formula cached result; error/unevaluated cells become empty) for headers and data alike — this also hardens the full-load path, which had the same latent bug. DropdownChecklist.sortCategories additionally coerces to String so hand-crafted JSON files cannot crash the panel. Adds regression tests incl. merge idempotency (a second import of the same file must report no changes). --- src/managers/io.js | 16 +++++- src/managers/ui_components.js | 7 ++- tests/excel-merge.test.js | 93 +++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/managers/io.js b/src/managers/io.js index fd5a4c7..6dd0153 100644 --- a/src/managers/io.js +++ b/src/managers/io.js @@ -848,6 +848,18 @@ class IOManager { }); }; + // ExcelJS surfaces rich cells as objects (rich-text runs, hyperlinks, + // formulas, error cells). Normalize to the primitive the user sees in the + // sheet — raw objects leaking into D4Data corrupt change detection and + // crash the category filters downstream. + const cellValueToPrimitive = (value) => { + if (value === null || typeof value !== 'object' || value instanceof Date) return value; + if (Array.isArray(value.richText)) return value.richText.map((run) => run.text).join(''); + if (value.text !== undefined) return cellValueToPrimitive(value.text); // hyperlink cell + if (value.result !== undefined) return cellValueToPrimitive(value.result); // formula cell + return null; // formula without cached result, error cell, unknown shape → empty + }; + const worksheetToJson = (worksheet) => { if (!worksheet) return { headers: [], jsonData: [] }; @@ -856,7 +868,7 @@ class IOManager { const firstRow = worksheet.getRow(1); firstRow.eachCell((cell, colNumber) => { - headers[colNumber] = cell.value; + headers[colNumber] = cellValueToPrimitive(cell.value); }); worksheet.eachRow((row, rowNumber) => { @@ -867,7 +879,7 @@ class IOManager { row.eachCell((cell, colNumber) => { const header = headers[colNumber]; if (header) { - rowData[header] = cell.value; + rowData[header] = cellValueToPrimitive(cell.value); } }); diff --git a/src/managers/ui_components.js b/src/managers/ui_components.js index c661a69..4a88205 100644 --- a/src/managers/ui_components.js +++ b/src/managers/ui_components.js @@ -18,8 +18,11 @@ class DropdownChecklist { : Array.from(this.categories); catArray.sort((a, b) => { + // Category values normally arrive as strings, but hand-crafted JSON (or + // historic files saved before rich-cell normalization) may carry other + // types — coerce instead of crashing the whole filter panel. const getPriority = (val) => { - const lower = val.toLowerCase(); + const lower = String(val).toLowerCase(); if (lower === "low") return 1; if (lower === "medium") return 2; if (lower === "high") return 3; @@ -30,7 +33,7 @@ class DropdownChecklist { if (priorityA === 0 && priorityB === 0) { // Both “other” values → alphabetical - return a.localeCompare(b); + return String(a).localeCompare(String(b)); } // Sort by priority ascending: 0 → “other”, 1 → “low”, 2 → “medium”, 3 → “high” return priorityA - priorityB; diff --git a/tests/excel-merge.test.js b/tests/excel-merge.test.js index acd2c12..dfba302 100644 --- a/tests/excel-merge.test.js +++ b/tests/excel-merge.test.js @@ -9,6 +9,7 @@ import { showMergePreview, } from "../src/utilities/excel_merge.js"; import { CFG, DEFAULTS } from "../src/config.js"; +import { DropdownChecklist } from "../src/managers/ui_components.js"; // ========================================================================== // Excel import & merge — the "⤒ Import" data-editor feature. Covers the pure @@ -370,6 +371,76 @@ describe("parseExcelToJson merge mode", () => { ); }); + it("normalizes rich cell values (richText, hyperlink, formula) to primitives", async () => { + // A workbook re-saved by Excel/LibreOffice can turn plain cells into rich + // values. Raw objects in D4Data make every matched row look "modified" + // and later crash the category filters (val.toLowerCase). + const wb = new ExcelJS.Workbook(); + const ns = wb.addWorksheet("nodes"); + ns.addRow(["ID", "Feat [g]"]); + ns.addRow(["A", 1]); + const es = wb.addWorksheet("edges"); + es.addRow(["Source ID", "Target ID", "Cat [g]", "Link [g]", "Calc [g]"]); + es.getCell("A2").value = "A"; + es.getCell("B2").value = "A"; + es.getCell("C2").value = { richText: [{ text: "Dummy " }, { text: "Category 1" }] }; + es.getCell("D2").value = { text: "example", hyperlink: "https://example.org" }; + es.getCell("E2").value = { formula: "1+1", result: 2 }; + const buffer = await wb.xlsx.writeBuffer(); + + const result = await io.parseExcelToJson(buffer); + + const data = result.edges[0].D4Data[EDGE_HEADER]["g"]; + expect(data["Cat"]).toBe("Dummy Category 1"); + expect(data["Link"]).toBe("example"); + expect(data["Calc"]).toBe(2); + }); + + it("re-importing the source file of a graph reports no changes", async () => { + const buffer = await buildWorkbook( + [ + { ID: "A", Label: "Node A", "Feat [g]": 1 }, + { ID: "B", Label: "Node B", "Feat [g]": 2 }, + ], + [{ "Source ID": "A", "Target ID": "B", Label: "rel", "Cat [g]": "x" }] + ); + + const current = await io.parseExcelToJson(buffer); + const incoming = await io.parseExcelToJson(buffer, { merge: true }); + const plan = computeMergePlan(current, incoming); + + expect(plan.stats.hasChanges).toBe(false); + expect(plan.stats.nodes.unchanged).toBe(2); + expect(plan.stats.edges.unchanged).toBe(1); + }); + + it("is idempotent: a second merge of the same file reports no changes", async () => { + const buffer = await buildWorkbook( + [{ ID: "A", Label: "Node A", "Feat [g]": 1 }], + [{ "Source ID": "A", "Target ID": "A", Label: "self", "Cat [g]": "x" }] + ); + // Existing graph state as a JSON load would have it: full default styles + // that differ from what the parser emits (label flag, label styling keys). + const current = { + nodes: [{ id: "A", label: "old", style: { label: true, size: 20 }, D4Data: {} }], + edges: [ + { id: "A::A", source: "A", target: "A", style: { label: true }, D4Data: {} }, + ], + nodeDataHeaders: [], + edgeDataHeaders: [], + }; + + const incoming1 = await io.parseExcelToJson(buffer, { merge: true }); + const plan1 = computeMergePlan(current, incoming1); + expect(plan1.stats.hasChanges).toBe(true); // first merge legitimately updates + + const incoming2 = await io.parseExcelToJson(buffer, { merge: true }); + const plan2 = computeMergePlan(plan1.fileData, incoming2); + expect(plan2.stats.hasChanges).toBe(false); + expect(plan2.stats.nodes.modified).toEqual([]); + expect(plan2.stats.edges.modified).toEqual([]); + }); + it("rejects a merge workbook with no rows at all", async () => { const buffer = await buildWorkbook([], []); @@ -450,6 +521,28 @@ describe("merge preview modal", () => { }); }); +// -------------------------------------------------------------------------- +// Category dropdown resilience (crash path of the merge bug report) +// -------------------------------------------------------------------------- + +describe("DropdownChecklist.sortCategories", () => { + it("tolerates non-string category values instead of crashing", () => { + const propID = "p"; + const cache = { + data: { + filterDefaults: new Map([[propID, { categories: new Set(["high", 5, "alpha"]) }]]), + layouts: { L: { filters: new Map([[propID, { categories: new Set() }]]) } }, + selectedLayout: "L", + }, + propIDToDropdownChecklists: new Map(), + }; + + const checklist = new DropdownChecklist(propID, cache); + // "high" is a priority keyword and sorts last; others alphabetical + expect([...checklist.categories]).toEqual([5, "alpha", "high"]); + }); +}); + // -------------------------------------------------------------------------- // DataTable import entry points // -------------------------------------------------------------------------- From 14ccfd3e44da226d36cafa56de07e83b7c905aa6 Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 13:23:26 +0200 Subject: [PATCH 16/18] feat(data-editor): join-mode choice in the Excel import preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview modal now offers two merge modes: 'Extend & add (full outer join)' — the previous upsert behavior, still the default — and 'Extend existing only (left join)', which enriches matched rows but ignores file rows without a match in the graph (including edges onto skipped new nodes, so no dangling edges). New property columns apply in both modes. Stats, notes and the Import button state re-render live on toggle, and showMergePreview now resolves the chosen plan instead of a boolean. --- src/graph_lens_lite.html | 2 +- src/style.css | 23 ++++ src/utilities/data_editor.js | 17 ++- src/utilities/excel_merge.js | 121 ++++++++++++++++++--- tests/excel-merge.test.js | 199 +++++++++++++++++++++++++++++++++-- 5 files changed, 334 insertions(+), 28 deletions(-) diff --git a/src/graph_lens_lite.html b/src/graph_lens_lite.html index 2f72463..8cd29c6 100644 --- a/src/graph_lens_lite.html +++ b/src/graph_lens_lite.html @@ -275,7 +275,7 @@
    Shown: + title="Merge an Excel file into the current graph — preview changes and choose to add new nodes/edges or only extend existing ones">⤒ Import diff --git a/src/style.css b/src/style.css index ef04c25..6aa57e3 100644 --- a/src/style.css +++ b/src/style.css @@ -5913,3 +5913,26 @@ input:checked + .slider:before { max-height: 120px; overflow-y: auto; } + +.merge-join-modes { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.merge-join-mode { + display: flex; + align-items: baseline; + gap: 8px; + cursor: pointer; + font-size: 13px; +} + +.merge-join-mode-text { + color: var(--text-muted); +} + +.merge-join-mode-text strong { + color: var(--text); +} diff --git a/src/utilities/data_editor.js b/src/utilities/data_editor.js index 57d12e5..d38d244 100644 --- a/src/utilities/data_editor.js +++ b/src/utilities/data_editor.js @@ -1056,9 +1056,14 @@ class DataTable { } if (!incoming) return; - const plan = computeMergePlan(this.fileData, incoming); - const confirmed = await showMergePreview(plan, file.name); - if (!confirmed) { + // Both plans up front: computeMergePlan is pure and cheap, so the modal + // toggles between ready results without async machinery. + const plans = { + outer: computeMergePlan(this.fileData, incoming), + left: computeMergePlan(this.fileData, incoming, { joinMode: 'left' }), + }; + const plan = await showMergePreview(plans, file.name); + if (!plan) { this.cache.ui.info('Import canceled'); return; } @@ -1072,9 +1077,11 @@ class DataTable { selectedLayout: this.cache.data.selectedLayout, }); const s = plan.stats; + const ignored = s.ignoredNodes.length + s.ignoredEdges.length; this.cache.ui.success( `Imported "${file.name}": ${s.nodes.added.length} new / ${s.nodes.modified.length} updated nodes, ` + - `${s.edges.added.length} new / ${s.edges.modified.length} updated edges.` + `${s.edges.added.length} new / ${s.edges.modified.length} updated edges.` + + (ignored > 0 ? ` ${ignored} unmatched file row(s) ignored.` : '') ); } catch (err) { this.cache.ui.error(`Error merging Excel file: ${err}`); @@ -1477,7 +1484,7 @@ class DataTable {
  • + Node — Create a new node in the graph
  • + Edge — Create a new edge between existing nodes
  • + Column — Add a new property column to the table
  • -
  • ⤒ Import — Merge an Excel file into the loaded graph: extend existing nodes/edges with new columns or values, or add entirely new ones — a preview shows what changes before anything is applied
  • +
  • ⤒ Import — Merge an Excel file into the loaded graph: extend existing nodes/edges with new columns or values, and either add new ones too ("Extend & add") or ignore unmatched file rows ("Extend existing only") — a preview shows what changes before anything is applied
  • ⤓ Export — Save current view as an Excel file
  • diff --git a/src/utilities/excel_merge.js b/src/utilities/excel_merge.js index 17ea094..8f9f403 100644 --- a/src/utilities/excel_merge.js +++ b/src/utilities/excel_merge.js @@ -4,6 +4,21 @@ import { Popup } from './popup.js'; // Cap the id lists rendered in the preview modal; full counts are always shown. const MAX_PREVIEW_IDS = 50; +// Join modes offered in the import preview. 'outer' upserts (default); +// 'left' only enriches matched rows and ignores unmatched file rows. +const JOIN_MODES = [ + { + value: 'outer', + label: 'Extend & add (full outer join)', + description: 'Update matching nodes/edges and add new ones from the file.', + }, + { + value: 'left', + label: 'Extend existing only (left join)', + description: 'Update matching nodes/edges; rows in the file without a match in the graph are ignored.', + }, +]; + function headerLabel(header) { return header.subGroup === CFG.EXCEL_UNCATEGORIZED_SUBHEADER ? header.key @@ -103,11 +118,29 @@ function mergeElements(currentList, incomingList) { * * @param {Object} current - { nodes, edges, nodeDataHeaders, edgeDataHeaders } * @param {Object} incoming - parseExcelToJson result (merge mode) + * @param {Object} [options] + * @param {'outer'|'left'} [options.joinMode='outer'] - 'outer' upserts; + * 'left' only enriches matched rows and ignores unmatched file rows (which + * also drops any incoming edge onto a dropped new node — no dangling edges) * @returns {{fileData: Object, stats: Object}} merged fileData plus preview stats */ -function computeMergePlan(current, incoming) { - const nodes = mergeElements(current.nodes || [], incoming.nodes); - const edges = mergeElements(current.edges || [], incoming.edges); +function computeMergePlan(current, incoming, { joinMode = 'outer' } = {}) { + let incomingNodes = incoming.nodes || []; + let incomingEdges = incoming.edges || []; + const ignoredNodes = []; + const ignoredEdges = []; + if (joinMode === 'left') { + const idsOf = (list) => new Set((list || []).map((el) => String(el.id))); + const nodeIds = idsOf(current.nodes); + const edgeIds = idsOf(current.edges); + ignoredNodes.push(...incomingNodes.filter((n) => !nodeIds.has(String(n.id))).map((n) => String(n.id))); + ignoredEdges.push(...incomingEdges.filter((e) => !edgeIds.has(String(e.id))).map((e) => String(e.id))); + incomingNodes = incomingNodes.filter((n) => nodeIds.has(String(n.id))); + incomingEdges = incomingEdges.filter((e) => edgeIds.has(String(e.id))); + } + + const nodes = mergeElements(current.nodes || [], incomingNodes); + const edges = mergeElements(current.edges || [], incomingEdges); const nodeHeaders = mergeHeaders(current.nodeDataHeaders || [], incoming.nodeDataHeaders); const edgeHeaders = mergeHeaders(current.edgeDataHeaders || [], incoming.edgeDataHeaders); @@ -127,10 +160,13 @@ function computeMergePlan(current, incoming) { edgeDataHeaders: edgeHeaders.headers, }, stats: { + joinMode, nodes: { added: nodes.added, modified: nodes.modified, unchanged: nodes.matchedUnchanged }, edges: { added: edges.added, modified: edges.modified, unchanged: edges.matchedUnchanged }, newNodeColumns: nodeHeaders.added, newEdgeColumns: edgeHeaders.added, + ignoredNodes, + ignoredEdges, skippedNodeRows: incoming.skippedNodeRows || 0, skippedEdgeRows: incoming.skippedEdgeRows || 0, hasChanges, @@ -184,6 +220,12 @@ function appendColumnsAndNotes(content, stats) { `match the current graph and stay unchanged.` ); } + if (stats.ignoredNodes?.length > 0 || stats.ignoredEdges?.length > 0) { + notes.push( + `${stats.ignoredNodes.length} node row(s) and ${stats.ignoredEdges.length} edge row(s) ` + + `have no match in the graph and will be ignored.` + ); + } if (stats.skippedNodeRows > 0 || stats.skippedEdgeRows > 0) { notes.push( `${stats.skippedNodeRows} node row(s) and ${stats.skippedEdgeRows} edge row(s) ` + @@ -193,9 +235,10 @@ function appendColumnsAndNotes(content, stats) { notes.forEach((note) => content.appendChild(el('div', 'merge-preview-note', note))); } -function buildMergePreviewContent(stats, fileName) { - const content = el('div', 'merge-preview'); - content.appendChild(el('div', 'merge-preview-file', `📄 ${fileName}`)); +// Everything below the file name / mode picker: stat grid, columns, notes, +// id lists and the warning. Re-rendered by the modal when the mode toggles. +function buildStatsBody(stats) { + const content = el('div', 'merge-preview-body'); const grid = el('div', 'merge-preview-grid'); grid.appendChild(statTile(stats.nodes.added.length, 'New nodes', 'add')); @@ -233,21 +276,67 @@ function buildMergePreviewContent(stats, fileName) { return content; } +function buildMergePreviewContent(stats, fileName) { + const content = el('div', 'merge-preview'); + content.appendChild(el('div', 'merge-preview-file', `📄 ${fileName}`)); + content.appendChild(buildStatsBody(stats)); + return content; +} + +function buildJoinModeRow(mode, checked, onSelect) { + const label = el('label', 'merge-join-mode'); + const radio = el('input'); + radio.type = 'radio'; + radio.name = 'merge-join-mode'; + radio.value = mode.value; + radio.checked = checked; + radio.addEventListener('change', () => onSelect(mode.value)); + const text = el('span', 'merge-join-mode-text'); + text.appendChild(el('strong', null, mode.label)); + text.appendChild(document.createTextNode(` — ${mode.description}`)); + label.appendChild(radio); + label.appendChild(text); + return label; +} + /** - * Show the import preview modal for a computed merge plan. + * Show the import preview modal offering both join modes. * - * @param {Object} plan - computeMergePlan result + * @param {{outer: Object, left: Object}} plans - computeMergePlan results per mode * @param {string} fileName - name of the imported file - * @returns {Promise} true when the user confirms the import + * @returns {Promise} the chosen plan on Import, null on cancel */ -function showMergePreview(plan, fileName) { +function showMergePreview(plans, fileName) { return new Promise((resolve) => { - const content = buildMergePreviewContent(plan.stats, fileName); + const content = el('div', 'merge-preview'); + content.appendChild(el('div', 'merge-preview-file', `📄 ${fileName}`)); + let selected = 'outer'; + const body = el('div'); const footer = el('div', 'p-footer'); const cancelBtn = el('button', 'p-button p-button-secondary', 'Cancel'); const importBtn = el('button', 'p-button p-button-primary', '✔ Import'); - importBtn.disabled = !plan.stats.hasChanges; + + const render = () => { + body.replaceChildren(buildStatsBody(plans[selected].stats)); + importBtn.disabled = !plans[selected].stats.hasChanges; + }; + + const modes = el('div', 'merge-join-modes'); + modes.setAttribute('role', 'radiogroup'); + modes.setAttribute('aria-label', 'Import mode'); + for (const mode of JOIN_MODES) { + modes.appendChild( + buildJoinModeRow(mode, mode.value === selected, (value) => { + selected = value; + render(); + }) + ); + } + content.appendChild(modes); + content.appendChild(body); + render(); + footer.appendChild(cancelBtn); footer.appendChild(importBtn); content.appendChild(footer); @@ -259,22 +348,22 @@ function showMergePreview(plan, fileName) { showFullscreenButton: false, closeOnClickOutside: false, onClose: () => { - if (!isResolved) resolve(false); + if (!isResolved) resolve(null); }, }); importBtn.addEventListener('click', () => { isResolved = true; popup.close(); - resolve(true); + resolve(plans[selected]); }); cancelBtn.addEventListener('click', () => { isResolved = true; popup.close(); - resolve(false); + resolve(null); }); - setTimeout(() => (plan.stats.hasChanges ? importBtn : cancelBtn).focus(), 0); + setTimeout(() => (plans.outer.stats.hasChanges ? importBtn : cancelBtn).focus(), 0); }); } diff --git a/tests/excel-merge.test.js b/tests/excel-merge.test.js index dfba302..eb92c88 100644 --- a/tests/excel-merge.test.js +++ b/tests/excel-merge.test.js @@ -91,6 +91,42 @@ function currentGraphFixture() { }; } +// Incoming data mixing matched updates (node A, edge A::B) with unmatched +// rows (node C, edge A::C onto the dropped node, edge C::D between two +// dropped nodes) — exercises both join modes. +function incomingMixedFixture() { + return { + nodes: [ + { + id: "A", + style: {}, + D4Data: { [NODE_HEADER]: { "group A": { "Feature Y": 5 } } }, + }, + { + id: "C", + style: {}, + D4Data: { [NODE_HEADER]: { "group A": { "Feature Y": 3 } } }, + }, + ], + edges: [ + { + id: "A::B", + source: "A", + target: "B", + style: {}, + D4Data: { [EDGE_HEADER]: { "group X": { Weight: 0.9 } } }, + }, + { id: "A::C", source: "A", target: "C", style: {}, D4Data: {} }, + { id: "C::D", source: "C", target: "D", style: {}, D4Data: {} }, + ], + nodeDataHeaders: [ + { subGroup: "group A", key: "Feature X" }, + { subGroup: "group A", key: "Feature Y" }, + ], + edgeDataHeaders: [{ subGroup: "group X", key: "Weight" }], + }; +} + // -------------------------------------------------------------------------- // computeMergePlan // -------------------------------------------------------------------------- @@ -312,6 +348,88 @@ describe("computeMergePlan", () => { const plan = computeMergePlan(current, incoming); expect(plan.stats.newNodeColumns).toEqual(["Plain"]); }); + + it("defaults to outer join with empty ignored lists", () => { + const plan = computeMergePlan(currentGraphFixture(), incomingMixedFixture()); + + expect(plan.stats.joinMode).toBe("outer"); + expect(plan.stats.ignoredNodes).toEqual([]); + expect(plan.stats.ignoredEdges).toEqual([]); + expect(plan.stats.nodes.added).toEqual(["C"]); + expect(plan.stats.edges.added).toEqual(["A::C", "C::D"]); + }); +}); + +// -------------------------------------------------------------------------- +// computeMergePlan — left join ("Extend existing only") +// -------------------------------------------------------------------------- + +describe("computeMergePlan left join", () => { + it("drops unmatched nodes and their dependent edges, keeps matched updates", () => { + const plan = computeMergePlan(currentGraphFixture(), incomingMixedFixture(), { + joinMode: "left", + }); + + expect(plan.stats.joinMode).toBe("left"); + expect(plan.stats.nodes.added).toEqual([]); + expect(plan.stats.edges.added).toEqual([]); + expect(plan.stats.nodes.modified).toEqual(["A"]); + expect(plan.stats.edges.modified).toEqual(["A::B"]); + expect(plan.stats.ignoredNodes).toEqual(["C"]); + expect(plan.stats.ignoredEdges).toEqual(["A::C", "C::D"]); + expect(plan.stats.hasChanges).toBe(true); + + expect(plan.fileData.nodes.map((n) => n.id)).toEqual(["A", "B"]); + expect(plan.fileData.edges.map((e) => e.id)).toEqual(["A::B"]); + // No dangling edges: every edge endpoint exists among the merged nodes + const nodeIds = new Set(plan.fileData.nodes.map((n) => String(n.id))); + for (const edge of plan.fileData.edges) { + expect(nodeIds.has(String(edge.source))).toBe(true); + expect(nodeIds.has(String(edge.target))).toBe(true); + } + }); + + it("still applies new property columns to matched rows", () => { + const plan = computeMergePlan(currentGraphFixture(), incomingMixedFixture(), { + joinMode: "left", + }); + + expect(plan.stats.newNodeColumns).toEqual(["Feature Y [group A]"]); + const mergedA = plan.fileData.nodes.find((n) => n.id === "A"); + expect(mergedA.D4Data[NODE_HEADER]["group A"]).toEqual({ + "Feature X": 1, + "Feature Y": 5, + }); + }); + + it("reports no changes when the file only contains unmatched rows", () => { + const incoming = { + nodes: [{ id: "Z", style: {}, D4Data: {} }], + edges: [{ id: "Z::A", source: "Z", target: "A", style: {}, D4Data: {} }], + nodeDataHeaders: [], + edgeDataHeaders: [], + }; + + const outer = computeMergePlan(currentGraphFixture(), incoming); + const left = computeMergePlan(currentGraphFixture(), incoming, { joinMode: "left" }); + + expect(outer.stats.hasChanges).toBe(true); + expect(left.stats.hasChanges).toBe(false); + expect(left.stats.ignoredNodes).toEqual(["Z"]); + expect(left.stats.ignoredEdges).toEqual(["Z::A"]); + }); + + it("does not mutate its inputs in left mode", () => { + const current = currentGraphFixture(); + const incoming = incomingMixedFixture(); + const currentSnapshot = JSON.stringify(current); + const incomingSnapshot = JSON.stringify(incoming); + + computeMergePlan(current, incoming, { joinMode: "left" }); + + expect(JSON.stringify(current)).toBe(currentSnapshot); + expect(JSON.stringify(incoming)).toBe(incomingSnapshot); + }); }); // -------------------------------------------------------------------------- @@ -466,6 +584,7 @@ describe("merge preview modal", () => { return { fileData: { nodes: [], edges: [], nodeDataHeaders: [], edgeDataHeaders: [] }, stats: { + joinMode: "outer", nodes: { added: hasChanges ? ["C", "D"] : [], modified: hasChanges ? ["A"] : [], @@ -474,6 +593,8 @@ describe("merge preview modal", () => { edges: { added: [], modified: [], unchanged: 2 }, newNodeColumns: hasChanges ? ["Feature Y [group A]"] : [], newEdgeColumns: [], + ignoredNodes: [], + ignoredEdges: [], skippedNodeRows: 0, skippedEdgeRows: 1, hasChanges, @@ -481,6 +602,38 @@ describe("merge preview modal", () => { }; } + function leftPlanFixture(hasChanges = true) { + return { + fileData: { nodes: [], edges: [], nodeDataHeaders: [], edgeDataHeaders: [] }, + stats: { + joinMode: "left", + nodes: { added: [], modified: hasChanges ? ["A"] : [], unchanged: 1 }, + edges: { added: [], modified: [], unchanged: 2 }, + newNodeColumns: [], + newEdgeColumns: [], + ignoredNodes: ["C", "D"], + ignoredEdges: ["A::C"], + skippedNodeRows: 0, + skippedEdgeRows: 0, + hasChanges, + }, + }; + } + + const plansFixture = (outerChanges = true, leftChanges = true) => ({ + outer: planFixture(outerChanges), + left: leftPlanFixture(leftChanges), + }); + + function selectMode(value) { + const radio = document.querySelector(`.merge-join-modes input[value="${value}"]`); + radio.checked = true; + radio.dispatchEvent(new Event("change")); + } + + const statNums = () => + [...document.querySelectorAll(".merge-stat-num")].map((n) => n.textContent); + it("renders counts, columns, notes and id lists", () => { const content = buildMergePreviewContent(planFixture().stats, "extra.xlsx"); @@ -500,18 +653,52 @@ describe("merge preview modal", () => { expect(content.querySelector(".alert-warning")).not.toBeNull(); }); - it("resolves true on Import and false on Cancel", async () => { - let promise = showMergePreview(planFixture(), "a.xlsx"); + it("resolves the outer plan on Import and null on Cancel", async () => { + let plans = plansFixture(); + let promise = showMergePreview(plans, "a.xlsx"); document.querySelector(".p-footer .p-button-primary").click(); - await expect(promise).resolves.toBe(true); + await expect(promise).resolves.toBe(plans.outer); + + plans = plansFixture(); + promise = showMergePreview(plans, "a.xlsx"); + document.querySelector(".p-footer .p-button-secondary").click(); + await expect(promise).resolves.toBeNull(); + }); - promise = showMergePreview(planFixture(), "a.xlsx"); + it("toggles tiles and notes with the join mode and resolves the chosen plan", async () => { + const plans = plansFixture(); + const promise = showMergePreview(plans, "a.xlsx"); + + expect(statNums()).toEqual(["+2", "~1", "0", "0"]); + + selectMode("left"); + expect(statNums()).toEqual(["0", "~1", "0", "0"]); + const notes = [...document.querySelectorAll(".merge-preview-note")].map( + (n) => n.textContent + ); + expect( + notes.some((t) => t.includes("2 node row(s) and 1 edge row(s) have no match")) + ).toBe(true); + + document.querySelector(".p-footer .p-button-primary").click(); + await expect(promise).resolves.toBe(plans.left); + }); + + it("disables Import when the selected mode yields no changes", () => { + showMergePreview(plansFixture(true, false), "a.xlsx"); + const importBtn = document.querySelector(".p-footer .p-button-primary"); + + expect(importBtn.disabled).toBe(false); + selectMode("left"); + expect(importBtn.disabled).toBe(true); + expect(document.querySelector(".alert-info").textContent).toContain("No changes detected"); + selectMode("outer"); + expect(importBtn.disabled).toBe(false); document.querySelector(".p-footer .p-button-secondary").click(); - await expect(promise).resolves.toBe(false); }); it("disables Import and shows a notice when nothing changes", () => { - showMergePreview(planFixture(false), "same.xlsx"); + showMergePreview(plansFixture(false, false), "same.xlsx"); const importBtn = document.querySelector(".p-footer .p-button-primary"); expect(importBtn.disabled).toBe(true); From f93a11d62d518d4fd257b9aabdce543e0b290d7e Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 13:23:31 +0200 Subject: [PATCH 17/18] docs(changelog): add join modes and rich-cell fix to the 1.16.0 entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 547da41..f041deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Saved graph files load unchanged. Stored bubble-set knob values keep applying, w ### Features * **Merge-import Excel files into the loaded graph.** A new ⤒ Import button in the data editor header loads a workbook *into* the current graph instead of replacing it. A preview modal shows what the merge will do before anything is applied — new/updated node and edge counts, new property columns, unchanged and skipped rows. Existing workspaces, positions, filters and styles are preserved; a single nodes or edges sheet suffices, and new edges can attach to nodes already in the graph. X/Y coordinates in the file seed positions for new nodes only. +* **Choose how an import joins the graph.** The import preview offers two modes: **Extend & add** (default) updates matching nodes/edges and adds new ones from the file, while **Extend existing only** updates matches and ignores file rows without a match — including edges onto skipped new nodes, so no dangling edges. The preview counts update live as you switch. * **Bubble-set geometry controls.** The Bubble Sets styling card gains **Padding** (how far the body extends past its members) and **Corridor Width** (thickness of the arms reaching outlying members) sliders, range 0.01–3, plus an **Avoid Other Nodes** switch that steers the hull around non-members and carves holes for fully enclosed ones. Defaults: padding 0.1, corridor 0.25, avoidance on. * **Smooth, organic bubble outlines at every zoom.** Hulls render as continuous curves instead of polylines — no more faceted or jagged outlines when zooming in — and the PNG and SVG exports paint the exact same curves as the live canvas. * **Node-size-aware hulls.** The influence field scales with the group's mean member radius, so bubbles look proportional whatever node size you configure. At minimum padding the hull hugs each node at a fixed fraction of *its own* radius, and minimum-width corridors render as thin, gently arced tubes. @@ -17,6 +18,7 @@ Saved graph files load unchanged. Stored bubble-set knob values keep applying, w * **Bubble hulls no longer clip their own members.** Avoid-node pressure could squeeze the outline through a member's body while its center stayed inside; grazed members now get repaired with proper clearance. * **Enclosed non-members are no longer silently swallowed.** With avoidance on, a non-member inside the hull gets a visible carve whenever one is geometrically possible — and one impossible hole no longer discards all the others. * **Bubble outlines no longer wobble around dense non-member fields**, and corridors no longer reroute into phantom lobes when widening them. +* **Re-saved Excel workbooks import cleanly.** Rich cell values (formatted text, hyperlinks, formulas) are normalized to plain values on import, so matched rows no longer all report as "updated" and the filter panel no longer crashes after such an import. ## 1.15.5 — 2026-07-10 From 42bc32ad9fbc605c563c5856878e7f53bc4ea7fb Mon Sep 17 00:00:00 2001 From: Mnikley Date: Wed, 22 Jul 2026 13:25:58 +0200 Subject: [PATCH 18/18] test(data-editor): cover the import flow end to end Two handleImportFile tests drive the real preview modal against a stubbed parser: choosing left join applies that plan (and reports the ignored rows), canceling leaves the graph untouched. Also extracts the join-mode picker into its own builder. --- src/utilities/excel_merge.js | 28 +++++++++++--------- tests/excel-merge.test.js | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/utilities/excel_merge.js b/src/utilities/excel_merge.js index 8f9f403..5790e58 100644 --- a/src/utilities/excel_merge.js +++ b/src/utilities/excel_merge.js @@ -299,6 +299,16 @@ function buildJoinModeRow(mode, checked, onSelect) { return label; } +function buildJoinModePicker(selected, onSelect) { + const modes = el('div', 'merge-join-modes'); + modes.setAttribute('role', 'radiogroup'); + modes.setAttribute('aria-label', 'Import mode'); + for (const mode of JOIN_MODES) { + modes.appendChild(buildJoinModeRow(mode, mode.value === selected, onSelect)); + } + return modes; +} + /** * Show the import preview modal offering both join modes. * @@ -322,18 +332,12 @@ function showMergePreview(plans, fileName) { importBtn.disabled = !plans[selected].stats.hasChanges; }; - const modes = el('div', 'merge-join-modes'); - modes.setAttribute('role', 'radiogroup'); - modes.setAttribute('aria-label', 'Import mode'); - for (const mode of JOIN_MODES) { - modes.appendChild( - buildJoinModeRow(mode, mode.value === selected, (value) => { - selected = value; - render(); - }) - ); - } - content.appendChild(modes); + content.appendChild( + buildJoinModePicker(selected, (value) => { + selected = value; + render(); + }) + ); content.appendChild(body); render(); diff --git a/tests/excel-merge.test.js b/tests/excel-merge.test.js index eb92c88..53c545c 100644 --- a/tests/excel-merge.test.js +++ b/tests/excel-merge.test.js @@ -780,4 +780,54 @@ describe("DataTable import guards and position seeding", () => { expect(positions.get("C")).toEqual({ style: { x: 10, y: 20 } }); expect(positions.has("D")).toBe(false); }); + + // End-to-end handleImportFile: real preview modal, stubbed parser/rebuild. + function setupImportFlow() { + document.body.innerHTML = ""; + cache.ui.showLoading = vi.fn(async () => {}); + cache.ui.hideLoading = vi.fn(async () => {}); + cache.ui.success = vi.fn(); + cache.io = { parseExcelToJson: vi.fn(async () => incomingMixedFixture()) }; + cache.data.layouts = { Default: { positions: new Map() } }; + cache.data.selectedLayout = "Default"; + table.fileData = currentGraphFixture(); + table.rebuildGraph = vi.fn(async () => {}); + return { name: "extra.xlsx", arrayBuffer: async () => new ArrayBuffer(0) }; + } + + const waitForModal = () => + vi.waitFor(() => { + if (!document.querySelector(".p-footer .p-button-primary")) { + throw new Error("preview modal not open yet"); + } + }); + + it("applies the join mode chosen in the preview and reports ignored rows", async () => { + const file = setupImportFlow(); + const done = table.handleImportFile(file); + await waitForModal(); + + const radio = document.querySelector('.merge-join-modes input[value="left"]'); + radio.checked = true; + radio.dispatchEvent(new Event("change")); + document.querySelector(".p-footer .p-button-primary").click(); + await done; + + const rebuilt = table.rebuildGraph.mock.calls[0][0]; + expect(rebuilt.nodes.map((n) => n.id)).toEqual(["A", "B"]); + expect(rebuilt.edges.map((e) => e.id)).toEqual(["A::B"]); + expect(cache.ui.success.mock.calls[0][0]).toContain("3 unmatched file row(s) ignored"); + }); + + it("does not touch the graph when the preview is canceled", async () => { + const file = setupImportFlow(); + const done = table.handleImportFile(file); + await waitForModal(); + + document.querySelector(".p-footer .p-button-secondary").click(); + await done; + + expect(table.rebuildGraph).not.toHaveBeenCalled(); + expect(cache.ui.info).toHaveBeenCalledWith("Import canceled"); + }); });