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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

## Bug fixes

* Fixed clicks being misaligned while the built-in "Node details" side panel is open ([#417](https://github.com/neo4j/python-graph-visualization/issues/417)).
* Fixed a stored cross-site scripting (XSS) vulnerability in `VG.render()`. Graph data was injected into an executable `<script>` block, so a node caption or property value containing `</script>` could break out and run arbitrary code in the browser of anyone opening a saved visualization. Data is now delivered as an inert `<script type="application/json">` block and read back with `JSON.parse`, with `<` escaped so no `</script>` can appear literally. The `render_widget` was unaffected.
* Fixed `widget.remove_data` leaving dangling relationships when only nodes were removed.
* Fixed `widget.remove_data` silently doing nothing when the id type differed (e.g. `Node(id=1)` vs `remove_data(nodes="1")`).
Expand Down
79 changes: 79 additions & 0 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createRender, useModelState } from "@anywidget/react";
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";
import { Gesture, GraphSelection, GraphVisualization } from "@neo4j-ndl/react-graph";
import type NVL from "@neo4j-nvl/base";
import type { Layout, NvlOptions } from "@neo4j-nvl/base";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Expand Down Expand Up @@ -187,13 +188,90 @@ function GraphWidget() {
};

const wrapperRef = useRef<HTMLDivElement>(null);
const nvlRef = useRef<NVL | null>(null);
const resolvedTheme = useResolvedTheme(theme);

useEffect(() => {
if (!wrapperRef.current) return;
injectNdlCss(wrapperRef.current);
}, []);

// NVL sizes its <canvas> once at mount via an internal `element-resize-event` scroll-sensor
// polyfill that doesn't fire when the side panel (NDL Drawer, type "push") flex-shrinks its
// container — so the canvas keeps its initial width and clicks land offset by the panel width
// (#417). NVL has no public resize API, so we bridge a real ResizeObserver to that polyfill: on
// any size change we dispatch a synthetic `scroll` on the container, which the polyfill listens
// for (capture) and uses to recompute the canvas size. TEMPORARY SHIM — remove once the upstream
// NVL resize fix (see changelog/PR) is bumped into this package.
//
// NVL may replace its container element after mount (observed in the Streamlit/Components-v2
// mount flow), so a one-shot observer would stick to a detached element. We watch the stable
// wrapper subtree with a MutationObserver and re-attach the ResizeObserver whenever the
// current container (`getContainer()`) changes.
Comment thread
FlorentinD marked this conversation as resolved.
useEffect(() => {
if (!wrapperRef.current) return;
let ro: ResizeObserver | undefined;
let observed: HTMLElement | null = null;
let raf = 0;
let attempts = 0;
let disposed = false;

// The polyfill is only safe to poke while it's live: on NVL destroy it sets
// `__resizeTriggers__` to `false` and (due to a capture-flag bug) leaves its `scroll` listener
// attached, so an unguarded `scroll` dispatch would throw inside that leaked listener — and
// `dispatchEvent` doesn't propagate listener exceptions, so it can't be caught. Guard on a
// real `__resizeTriggers__.firstElementChild` instead.
const ready = (el: HTMLElement | null): el is HTMLElement =>
!!el &&
!!(el as unknown as { __resizeTriggers__?: HTMLElement }).__resizeTriggers__
?.firstElementChild;

const dispatch = () => {
// Re-resolve each time: NVL may have been recreated after mount.
const cur = nvlRef.current?.getContainer?.() ?? null;
if (ready(cur)) {
try {
cur.dispatchEvent(new Event("scroll"));
} catch {
/* best-effort */
}
}
};

const attach = (el: HTMLElement) => {
if (observed === el) return;
ro?.disconnect();
observed = el;
ro = new ResizeObserver(dispatch);
ro.observe(el);
};

// Re-attach whenever NVL swaps in a new container (it replaces the element, leaving the
// previous one detached, so the prior observer would go silent).
const mo = new MutationObserver(() => {
if (disposed) return;
const el = nvlRef.current?.getContainer?.() ?? null;
if (ready(el) && el !== observed) attach(el);
});
mo.observe(wrapperRef.current, { childList: true, subtree: true });

// NVL is created in a child effect; retry briefly until its polyfill is attached.
const tick = () => {
if (disposed) return;
const el = nvlRef.current?.getContainer?.() ?? null;
if (ready(el)) attach(el);
else if (++attempts < 120) raf = requestAnimationFrame(tick);
};
tick();

return () => {
disposed = true;
cancelAnimationFrame(raf);
ro?.disconnect();
mo.disconnect();
};
}, []);

const [neoNodes, neoRelationships] = useMemo(
() => [transformNodes(nodes ?? []), transformRelationships(relationships ?? [])],
[nodes, relationships],
Expand Down Expand Up @@ -243,6 +321,7 @@ function GraphWidget() {
layout={layout}
setLayout={setLayout}
nvlOptions={nvlOptionsWithoutWorkers}
nvlRef={nvlRef}
zoom={zoom}
pan={pan}
layoutOptions={layoutOptions}
Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ js-rebuild:
js-build:
./scripts/build_js_applet.sh

js-style:
cd js-applet && yarn && yarn lint:fix && yarn format
cd js-applet && yarn && yarn lint && yarn format:check

streamlit:
./scripts/run_streamlit_example.sh

Expand Down

Large diffs are not rendered by default.

63 changes: 45 additions & 18 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -94570,22 +94570,48 @@ function Sre() {
...n,
layout: e
});
}, b = (0, x.useRef)(null), S = _re(o);
}, b = (0, x.useRef)(null), S = (0, x.useRef)(null), C = _re(o);
(0, x.useEffect)(() => {
b.current && xre(b.current);
}, []), (0, x.useEffect)(() => {
if (!b.current) return;
let e, t = null, n = 0, r = 0, i = !1, a = (e) => !!e && !!e.__resizeTriggers__?.firstElementChild, o = () => {
let e = S.current?.getContainer?.() ?? null;
if (a(e)) try {
e.dispatchEvent(new Event("scroll"));
} catch {}
}, s = (n) => {
t !== n && (e?.disconnect(), t = n, e = new ResizeObserver(o), e.observe(n));
}, c = new MutationObserver(() => {
if (i) return;
let e = S.current?.getContainer?.() ?? null;
a(e) && e !== t && s(e);
});
c.observe(b.current, {
childList: !0,
subtree: !0
});
let l = () => {
if (i) return;
let e = S.current?.getContainer?.() ?? null;
a(e) ? s(e) : ++r < 120 && (n = requestAnimationFrame(l));
};
return l(), () => {
i = !0, cancelAnimationFrame(n), e?.disconnect(), c.disconnect();
};
}, []);
let [C, w] = (0, x.useMemo)(() => [lre(e ?? []), ure(t ?? [])], [e, t]), T = (0, x.useMemo)(() => ({
let [w, T] = (0, x.useMemo)(() => [lre(e ?? []), ure(t ?? [])], [e, t]), D = (0, x.useMemo)(() => ({
...d,
minZoom: 0,
maxZoom: 1e3,
disableWebWorkers: !0
}), [d]), [D, O] = (0, x.useState)(!1), [k, A] = (0, x.useState)(300), [j, M] = (0, x.useState)(!1);
}), [d]), [O, k] = (0, x.useState)(!1), [A, j] = (0, x.useState)(300), [M, N] = (0, x.useState)(!1);
(0, x.useEffect)(() => {
Y9(l ?? X9) && M(!0);
Y9(l ?? X9) && N(!0);
}, [l]);
let N = Y9(l ?? X9);
let P = Y9(l ?? X9);
return /* @__PURE__ */ (0, z.jsx)(Ja, {
theme: S,
theme: C,
wrapperProps: { isWrappingChildren: !1 },
children: /* @__PURE__ */ (0, z.jsxs)("div", {
ref: b,
Expand All @@ -94595,35 +94621,36 @@ function Sre() {
width: a ?? "100%"
},
children: [/* @__PURE__ */ (0, z.jsx)(K9, {
nodes: C,
rels: w,
nodes: w,
rels: T,
gesture: _,
setGesture: v,
selected: s ?? hre,
setSelected: c,
layout: u,
setLayout: y,
nvlOptions: T,
nvlOptions: D,
nvlRef: S,
zoom: f,
pan: p,
layoutOptions: m,
sidepanel: {
isSidePanelOpen: D,
setIsSidePanelOpen: O,
onSidePanelResize: A,
sidePanelWidth: k,
isSidePanelOpen: O,
setIsSidePanelOpen: k,
onSidePanelResize: j,
sidePanelWidth: A,
children: /* @__PURE__ */ (0, z.jsx)(K9.SingleSelectionSidePanelContents, {})
},
topLeftIsland: /* @__PURE__ */ (0, z.jsx)(K9.DownloadButton, { tooltipPlacement: "right" }),
topRightIsland: /* @__PURE__ */ (0, z.jsxs)(zE, {
size: "small",
orientation: "horizontal",
children: [N && /* @__PURE__ */ (0, z.jsx)(ME, {
children: [P && /* @__PURE__ */ (0, z.jsx)(ME, {
size: "small",
isFloating: !0,
isActive: j,
description: j ? "Hide legend" : "Show legend",
onClick: () => M((e) => !e),
isActive: M,
description: M ? "Hide legend" : "Show legend",
onClick: () => N((e) => !e),
htmlAttributes: { "aria-label": "Toggle legend" },
tooltipProps: { root: {
placement: "bottom",
Expand All @@ -94650,7 +94677,7 @@ function Sre() {
})] })
]
})
}), j && /* @__PURE__ */ (0, z.jsx)(pre, { legend: l ?? X9 })]
}), M && /* @__PURE__ */ (0, z.jsx)(pre, { legend: l ?? X9 })]
})
});
}
Expand Down
63 changes: 45 additions & 18 deletions python-wrapper/src/neo4j_viz/resources/streamlit_v2/graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -94570,22 +94570,48 @@ function wre() {
...n,
layout: e
});
}, b = (0, x.useRef)(null), S = yre(o);
}, b = (0, x.useRef)(null), S = (0, x.useRef)(null), C = yre(o);
(0, x.useEffect)(() => {
b.current && Cre(b.current);
}, []), (0, x.useEffect)(() => {
if (!b.current) return;
let e, t = null, n = 0, r = 0, i = !1, a = (e) => !!e && !!e.__resizeTriggers__?.firstElementChild, o = () => {
let e = S.current?.getContainer?.() ?? null;
if (a(e)) try {
e.dispatchEvent(new Event("scroll"));
} catch {}
}, s = (n) => {
t !== n && (e?.disconnect(), t = n, e = new ResizeObserver(o), e.observe(n));
}, c = new MutationObserver(() => {
if (i) return;
let e = S.current?.getContainer?.() ?? null;
a(e) && e !== t && s(e);
});
c.observe(b.current, {
childList: !0,
subtree: !0
});
let l = () => {
if (i) return;
let e = S.current?.getContainer?.() ?? null;
a(e) ? s(e) : ++r < 120 && (n = requestAnimationFrame(l));
};
return l(), () => {
i = !0, cancelAnimationFrame(n), e?.disconnect(), c.disconnect();
};
}, []);
let [C, w] = (0, x.useMemo)(() => [dre(e ?? []), fre(t ?? [])], [e, t]), T = (0, x.useMemo)(() => ({
let [w, T] = (0, x.useMemo)(() => [dre(e ?? []), fre(t ?? [])], [e, t]), D = (0, x.useMemo)(() => ({
...d,
minZoom: 0,
maxZoom: 1e3,
disableWebWorkers: !0
}), [d]), [D, O] = (0, x.useState)(!1), [k, A] = (0, x.useState)(300), [j, M] = (0, x.useState)(!1);
}), [d]), [O, k] = (0, x.useState)(!1), [A, j] = (0, x.useState)(300), [M, N] = (0, x.useState)(!1);
(0, x.useEffect)(() => {
q9(l ?? J9) && M(!0);
q9(l ?? J9) && N(!0);
}, [l]);
let N = q9(l ?? J9);
let P = q9(l ?? J9);
return /* @__PURE__ */ (0, z.jsx)(Ja, {
theme: S,
theme: C,
wrapperProps: { isWrappingChildren: !1 },
children: /* @__PURE__ */ (0, z.jsxs)("div", {
ref: b,
Expand All @@ -94595,35 +94621,36 @@ function wre() {
width: a ?? "100%"
},
children: [/* @__PURE__ */ (0, z.jsx)(W9, {
nodes: C,
rels: w,
nodes: w,
rels: T,
gesture: _,
setGesture: v,
selected: s ?? _re,
setSelected: c,
layout: u,
setLayout: y,
nvlOptions: T,
nvlOptions: D,
nvlRef: S,
zoom: f,
pan: p,
layoutOptions: m,
sidepanel: {
isSidePanelOpen: D,
setIsSidePanelOpen: O,
onSidePanelResize: A,
sidePanelWidth: k,
isSidePanelOpen: O,
setIsSidePanelOpen: k,
onSidePanelResize: j,
sidePanelWidth: A,
children: /* @__PURE__ */ (0, z.jsx)(W9.SingleSelectionSidePanelContents, {})
},
topLeftIsland: /* @__PURE__ */ (0, z.jsx)(W9.DownloadButton, { tooltipPlacement: "right" }),
topRightIsland: /* @__PURE__ */ (0, z.jsxs)(ME, {
size: "small",
orientation: "horizontal",
children: [N && /* @__PURE__ */ (0, z.jsx)(TE, {
children: [P && /* @__PURE__ */ (0, z.jsx)(TE, {
size: "small",
isFloating: !0,
isActive: j,
description: j ? "Hide legend" : "Show legend",
onClick: () => M((e) => !e),
isActive: M,
description: M ? "Hide legend" : "Show legend",
onClick: () => N((e) => !e),
htmlAttributes: { "aria-label": "Toggle legend" },
tooltipProps: { root: {
placement: "bottom",
Expand All @@ -94650,7 +94677,7 @@ function wre() {
})] })
]
})
}), j && /* @__PURE__ */ (0, z.jsx)(hre, { legend: l ?? J9 })]
}), M && /* @__PURE__ */ (0, z.jsx)(hre, { legend: l ?? J9 })]
})
});
}
Expand Down