Skip to content

Commit bfe964c

Browse files
Jacob Säll NilssonJacob Säll Nilsson
authored andcommitted
improve ndl graph-vis in shadow dom
1 parent caded8e commit bfe964c

File tree

7 files changed

+55926
-55797
lines changed

7 files changed

+55926
-55797
lines changed

js-applet/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
},
2828
"dependencies": {
2929
"@anywidget/react": "^0.2.0",
30-
"@neo4j-ndl/base": "^4.7.1",
31-
"@neo4j-ndl/react": "^4.7.3",
32-
"@neo4j-ndl/react-graph": "^1.2.8",
30+
"@neo4j-ndl/base": "4.9.7",
31+
"@neo4j-ndl/react": "4.9.17",
32+
"@neo4j-ndl/react-graph": "1.2.35",
3333
"@neo4j-nvl/base": "^1.1.0",
3434
"@neo4j-nvl/interaction-handlers": "^1.1.0",
3535
"@neo4j-nvl/react": "^1.1.0",

js-applet/src/graph-widget.tsx

Lines changed: 199 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,162 +1,226 @@
1-
import {createRender, useModelState} from "@anywidget/react";
2-
import "@neo4j-ndl/base/lib/neo4j-ds-styles.css";
3-
import {Gesture, GraphVisualization} from "@neo4j-ndl/react-graph";
4-
import type {Layout, NvlOptions} from "@neo4j-nvl/base";
5-
import {useEffect, useMemo, useState} from "react";
1+
import { createRender, useModelState } from "@anywidget/react";
2+
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";
3+
import { Gesture, GraphVisualization } from "@neo4j-ndl/react-graph";
4+
import type { Layout, NvlOptions } from "@neo4j-nvl/base";
5+
import { useEffect, useMemo, useRef, useState } from "react";
66
import {
7-
SerializedNode,
8-
SerializedRelationship,
9-
transformNodes,
10-
transformRelationships,
7+
SerializedNode,
8+
SerializedRelationship,
9+
transformNodes,
10+
transformRelationships,
1111
} from "./data-transforms";
12-
import {GraphErrorBoundary} from "./graph-error-boundary";
13-
import {Divider, IconButtonArray} from "@neo4j-ndl/react";
12+
import { GraphErrorBoundary } from "./graph-error-boundary";
13+
import {
14+
Divider,
15+
IconButtonArray,
16+
NeedleThemeProvider,
17+
} from "@neo4j-ndl/react";
1418

1519
export type Theme = "dark" | "light" | "auto";
1620

1721
export type GraphOptions = {
18-
layout: Layout;
19-
nvlOptions?: Partial<NvlOptions>;
20-
zoom?: number;
21-
pan?: { x: number; y: number };
22-
layoutOptions?: Record<string, unknown>;
23-
showLayoutButton: boolean;
22+
layout: Layout;
23+
nvlOptions?: Partial<NvlOptions>;
24+
zoom?: number;
25+
pan?: { x: number; y: number };
26+
layoutOptions?: Record<string, unknown>;
27+
showLayoutButton: boolean;
2428
};
2529

2630
export type WidgetData = {
27-
nodes: SerializedNode[];
28-
relationships: SerializedRelationship[];
29-
options: GraphOptions;
30-
height: string;
31-
width: string;
32-
theme: Theme;
31+
nodes: SerializedNode[];
32+
relationships: SerializedRelationship[];
33+
options: GraphOptions;
34+
height: string;
35+
width: string;
36+
theme: Theme;
3337
};
3438

3539
function detectTheme(): "light" | "dark" {
36-
if (document.body.classList.contains("vscode-light")) {
37-
return "light";
38-
}
39-
if (document.body.classList.contains("vscode-dark")) {
40-
return "dark";
41-
}
40+
if (document.body.classList.contains("vscode-light")) {
41+
return "light";
42+
}
43+
if (document.body.classList.contains("vscode-dark")) {
44+
return "dark";
45+
}
46+
47+
const backgroundColorString = window
48+
.getComputedStyle(document.body, null)
49+
.getPropertyValue("background-color");
50+
const colorsArray = backgroundColorString.match(/\d+/g);
51+
if (!colorsArray || colorsArray.length < 3) {
52+
return "light";
53+
}
54+
const brightness =
55+
Number(colorsArray[0]) * 0.2126 +
56+
Number(colorsArray[1]) * 0.7152 +
57+
Number(colorsArray[2]) * 0.0722;
58+
59+
// VSCode reports: rgba(0, 0, 0, 0) as the background color independent of the theme, default to light here
60+
if (brightness === 0 && colorsArray.length > 3 && colorsArray[3] === "0") {
61+
return "light";
62+
}
63+
64+
return brightness < 128 ? "dark" : "light";
65+
}
4266

43-
const backgroundColorString = window
44-
.getComputedStyle(document.body, null)
45-
.getPropertyValue("background-color");
46-
const colorsArray = backgroundColorString.match(/\d+/g);
47-
if (!colorsArray || colorsArray.length < 3) {
48-
return "light";
49-
}
50-
const brightness =
51-
Number(colorsArray[0]) * 0.2126 +
52-
Number(colorsArray[1]) * 0.7152 +
53-
Number(colorsArray[2]) * 0.0722;
54-
55-
// VSCode reports: rgba(0, 0, 0, 0) as the background color independent of the theme, default to light here
56-
if (brightness === 0 && colorsArray.length > 3 && colorsArray[3] === "0") {
57-
return "light";
58-
}
67+
function resolveTheme(theme: Theme): "light" | "dark" {
68+
return theme === "auto" ? detectTheme() : theme;
69+
}
5970

60-
return brightness < 128 ? "dark" : "light";
71+
// @font-face rules in shadow DOM adopted stylesheets don't register fonts at the
72+
// document level, so the browser can't find them for rendering. We extract and hoist
73+
// them into document.head eagerly at module load so fonts begin loading immediately.
74+
const fontFaceRules = (ndlCssText.match(/@font-face\s*\{[^}]*\}/g) || []).join(
75+
"\n"
76+
);
77+
if (fontFaceRules) {
78+
const fontStyle = document.createElement("style");
79+
fontStyle.textContent = fontFaceRules;
80+
document.head.appendChild(fontStyle);
6181
}
6282

63-
function useTheme(theme: Theme) {
64-
useEffect(() => {
65-
const resolved = theme === "auto" ? detectTheme() : theme;
66-
document.documentElement.className = `ndl-theme-${resolved}`;
67-
}, [theme]);
83+
let cssInjected = false;
84+
85+
/**
86+
* Injects the full NDL stylesheet into the appropriate scope. In a shadow DOM
87+
* context (e.g. Marimo notebooks), the CSS is adopted onto the shadow root so
88+
* tokens, resets and component styles are properly scoped. Outside shadow DOM,
89+
* a regular <style> element is appended to document.head.
90+
*/
91+
function injectNdlCss(el: HTMLElement) {
92+
if (cssInjected) return;
93+
cssInjected = true;
94+
95+
const rootNode = el.getRootNode();
96+
if (rootNode instanceof ShadowRoot) {
97+
const sheet = new CSSStyleSheet();
98+
sheet.replaceSync(ndlCssText);
99+
rootNode.adoptedStyleSheets = [...rootNode.adoptedStyleSheets, sheet];
100+
} else {
101+
const style = document.createElement("style");
102+
style.textContent = ndlCssText;
103+
document.head.appendChild(style);
104+
}
68105
}
69106

70107
function GraphWidget() {
71-
const [nodes] = useModelState<WidgetData["nodes"]>("nodes");
72-
const [relationships] =
73-
useModelState<WidgetData["relationships"]>("relationships");
74-
const [options, setOptions] = useModelState<WidgetData["options"]>("options");
75-
const [height] = useModelState<WidgetData["height"]>("height");
76-
const [width] = useModelState<WidgetData["width"]>("width");
77-
const [theme] = useModelState<WidgetData["theme"]>("theme");
78-
const [gesture, setGesture] = useState<Gesture>('box');
79-
const {layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton} = options ?? {};
80-
const setLayout = (layout: Layout) => {
81-
setOptions({...options, layout});
108+
const [nodes] = useModelState<WidgetData["nodes"]>("nodes");
109+
const [relationships] =
110+
useModelState<WidgetData["relationships"]>("relationships");
111+
const [options, setOptions] = useModelState<WidgetData["options"]>("options");
112+
const [height] = useModelState<WidgetData["height"]>("height");
113+
const [width] = useModelState<WidgetData["width"]>("width");
114+
const [theme] = useModelState<WidgetData["theme"]>("theme");
115+
const [gesture, setGesture] = useState<Gesture>("box");
116+
const { layout, nvlOptions, zoom, pan, layoutOptions, showLayoutButton } =
117+
options ?? {};
118+
const setLayout = (layout: Layout) => {
119+
setOptions({ ...options, layout });
120+
};
121+
122+
const wrapperRef = useRef<HTMLDivElement>(null);
123+
const resolvedTheme = resolveTheme(theme ?? "auto");
124+
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
125+
126+
useEffect(() => {
127+
if (!wrapperRef.current) return;
128+
injectNdlCss(wrapperRef.current);
129+
130+
if (wrapperRef.current.getRootNode() instanceof ShadowRoot) {
131+
setPortalTarget(wrapperRef.current);
82132
}
83-
84-
useTheme(theme ?? "auto");
85-
86-
const [neoNodes, neoRelationships] = useMemo(
87-
() => [
88-
transformNodes(nodes ?? []),
89-
transformRelationships(relationships ?? []),
90-
],
91-
[nodes, relationships],
92-
);
93-
94-
const nvlOptionsWithoutWorkers = useMemo(
95-
() => ({
96-
...nvlOptions,
97-
minZoom: 0,
98-
maxZoom: 1000,
99-
disableWebWorkers: true,
100-
}),
101-
[nvlOptions],
102-
);
103-
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
104-
const [sidePanelWidth, setSidePanelWidth] = useState(300);
105-
106-
return (
107-
<div style={{height: height ?? "600px", width: width ?? "100%"}}>
108-
<GraphVisualization
109-
nodes={neoNodes}
110-
rels={neoRelationships}
111-
gesture={gesture}
112-
setGesture={setGesture}
113-
layout={layout}
114-
setLayout={setLayout}
115-
nvlOptions={nvlOptionsWithoutWorkers}
116-
zoom={zoom}
117-
pan={pan}
118-
layoutOptions={layoutOptions}
119-
sidepanel={{
120-
isSidePanelOpen,
121-
setIsSidePanelOpen,
122-
onSidePanelResize: setSidePanelWidth,
123-
sidePanelWidth,
124-
children: <GraphVisualization.SingleSelectionSidePanelContents/>,
125-
}}
126-
topRightIsland={
127-
<IconButtonArray size="medium">
128-
<GraphVisualization.DownloadButton/>
129-
<GraphVisualization.ToggleSidePanelButton/>
130-
</IconButtonArray>
131-
}
132-
bottomRightIsland={
133-
<IconButtonArray size="medium" orientation="vertical">
134-
<GraphVisualization.GestureSelectButton menuPlacement="top-end-bottom-end"/>
135-
<Divider orientation="vertical"/>
136-
<GraphVisualization.ZoomInButton/>
137-
<GraphVisualization.ZoomOutButton/>
138-
<GraphVisualization.ZoomToFitButton/>
139-
{showLayoutButton && (
140-
<>
141-
<Divider orientation="vertical"/>
142-
<GraphVisualization.LayoutSelectButton menuPlacement="top-end-bottom-end"/>
143-
</>
144-
)}
145-
</IconButtonArray>
146-
}
147-
/>
148-
</div>
149-
);
133+
}, []);
134+
135+
const [neoNodes, neoRelationships] = useMemo(
136+
() => [
137+
transformNodes(nodes ?? []),
138+
transformRelationships(relationships ?? []),
139+
],
140+
[nodes, relationships]
141+
);
142+
143+
const nvlOptionsWithoutWorkers = useMemo(
144+
() => ({
145+
...nvlOptions,
146+
minZoom: 0,
147+
maxZoom: 1000,
148+
disableWebWorkers: true,
149+
}),
150+
[nvlOptions]
151+
);
152+
const [isSidePanelOpen, setIsSidePanelOpen] = useState(false);
153+
const [sidePanelWidth, setSidePanelWidth] = useState(300);
154+
155+
return (
156+
<NeedleThemeProvider
157+
theme={resolvedTheme}
158+
wrapperProps={{ isWrappingChildren: false }}
159+
>
160+
<div
161+
ref={wrapperRef}
162+
style={{ height: height ?? "600px", width: width ?? "100%" }}
163+
>
164+
<GraphVisualization
165+
nodes={neoNodes}
166+
rels={neoRelationships}
167+
gesture={gesture}
168+
setGesture={setGesture}
169+
layout={layout}
170+
setLayout={setLayout}
171+
nvlOptions={nvlOptionsWithoutWorkers}
172+
zoom={zoom}
173+
pan={pan}
174+
layoutOptions={layoutOptions}
175+
portalTarget={portalTarget}
176+
sidepanel={{
177+
isSidePanelOpen,
178+
setIsSidePanelOpen,
179+
onSidePanelResize: setSidePanelWidth,
180+
sidePanelWidth,
181+
children: <GraphVisualization.SingleSelectionSidePanelContents />,
182+
}}
183+
topLeftIsland={
184+
<GraphVisualization.DownloadButton tooltipPlacement="right" />
185+
}
186+
topRightIsland={
187+
<GraphVisualization.ToggleSidePanelButton tooltipPlacement="left" />
188+
}
189+
bottomRightIsland={
190+
<IconButtonArray size="medium" orientation="horizontal">
191+
<GraphVisualization.GestureSelectButton
192+
menuPlacement="top-end-bottom-end"
193+
tooltipPlacement="top"
194+
/>
195+
<Divider orientation="vertical" />
196+
<GraphVisualization.ZoomInButton tooltipPlacement="top" />
197+
<GraphVisualization.ZoomOutButton tooltipPlacement="top" />
198+
<GraphVisualization.ZoomToFitButton tooltipPlacement="top" />
199+
{showLayoutButton && (
200+
<>
201+
<Divider orientation="vertical" />
202+
<GraphVisualization.LayoutSelectButton
203+
menuPlacement="top-end-bottom-end"
204+
tooltipPlacement="top"
205+
/>
206+
</>
207+
)}
208+
</IconButtonArray>
209+
}
210+
/>
211+
</div>
212+
</NeedleThemeProvider>
213+
);
150214
}
151215

152216
function GraphWidgetWithErrorBoundary() {
153-
return (
154-
<GraphErrorBoundary>
155-
<GraphWidget/>
156-
</GraphErrorBoundary>
157-
);
217+
return (
218+
<GraphErrorBoundary>
219+
<GraphWidget />
220+
</GraphErrorBoundary>
221+
);
158222
}
159223

160224
const render = createRender(GraphWidgetWithErrorBoundary);
161225

162-
export default {render};
226+
export default { render };

js-applet/src/vite-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/// <reference types="vite/client" />

0 commit comments

Comments
 (0)