Skip to content

Commit 89910a2

Browse files
Add defaultStyling.json support for persistent per-type vertex and edge styling
Adds an optional defaultStyling.json file that can be mounted into the Docker container to provide per-type vertex and edge styling defaults. This addresses the need for persistent styling in environments where browser storage is cleared between sessions (e.g., AWS WorkSpaces Web). On startup, file values are merged into the user's per-type styling preferences (IndexedDB). Default values fill in properties the user hasn't explicitly set; existing user overrides are preserved. The file also serves as a reference for per-type "Reset to Default" in the Node/Edge Style dialogs. The Settings page adds Export/Import/Reset All styling controls: - Export: saves current styling as defaultStyling.json for sharing or Docker-mounting as team defaults - Import: merges a defaultStyling.json into user styling and updates the reset reference so reset-after-import restores imported values - Reset All: reverts all styling to defaults (from file or hardcoded) Supports lucide icon names (resolved to SVG data URIs at runtime via dynamic imports), custom icon URLs, colors, shapes, border styles, edge line styles, and arrow styles. Includes Zod validation with strict type checking for shapes and arrow styles. Includes 58 new tests with 100% line coverage on new modules, example file, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e0785d2 commit 89910a2

22 files changed

Lines changed: 1844 additions & 12 deletions

Changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Graph Explorer Change Log
22

3+
## Unreleased
4+
5+
- Add defaultStyling.json support for persistent per-type vertex and edge
6+
styling (#1265, #112, #173, #573, #689)
7+
38
## Release 3.0.0
49

510
Graph Explorer 3.0 is here! This release brings one of the most requested features — the ability to visualize your graph database schema — along with a fresh navigation experience and a handful of quality-of-life improvements.

docs/references/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ Technical reference documentation for Graph Explorer covering configuration, sec
77
- [Health Check](./health-check.md) - Proxy server health and readiness endpoint
88
- [Logging](./logging.md) - Log levels, output, and proxy server logging modules
99
- [Default Connection](./default-connection.md) - Configure a default connection via environment variables or JSON
10+
- [Default Styling](./default-styling.md) - Configure default vertex and edge styling via a JSON file

docs/references/default-styling.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Default Styling Configuration
2+
3+
Graph Explorer supports an optional `defaultStyling.json` file that provides
4+
default vertex and edge styling for all users. This is useful for:
5+
6+
- Non-persistent browser environments (e.g., AWS WorkSpaces Web) where IndexedDB
7+
is cleared between sessions
8+
- Pre-configuring a shared visual style for teams
9+
- Providing a consistent starting point for new users
10+
11+
## How It Works
12+
13+
On startup, Graph Explorer fetches `defaultStyling.json` and merges its values
14+
into the user's per-type styling preferences (stored in IndexedDB). Default
15+
values fill in any properties the user hasn't explicitly set — existing user
16+
overrides are preserved.
17+
18+
When no `defaultStyling.json` is mounted, behavior is identical to the default
19+
Graph Explorer experience. When mounted, it populates the per-type styling that
20+
users can then customize. Resetting a vertex or edge style in the UI will revert
21+
to the `defaultStyling.json` values (or the hardcoded application defaults if no
22+
entry exists for that type).
23+
24+
## Setup
25+
26+
### Docker
27+
28+
Mount the file into the container's configuration folder:
29+
30+
```bash
31+
docker run \
32+
-v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \
33+
public.ecr.aws/neptune/graph-explorer
34+
```
35+
36+
### Custom Icons
37+
38+
To serve custom icon files (referenced by URL in the config), mount an icons
39+
directory:
40+
41+
```bash
42+
docker run \
43+
-v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \
44+
-v /path/to/icons:/graph-explorer/packages/graph-explorer/custom-icons \
45+
public.ecr.aws/neptune/graph-explorer
46+
```
47+
48+
Icons in the `custom-icons` directory are served at `/custom-icons/<filename>`.
49+
50+
## JSON Schema
51+
52+
```json
53+
{
54+
"vertices": {
55+
"<VertexTypeLabel>": {
56+
"color": "#hex",
57+
"icon": "lucide-icon-name",
58+
"iconUrl": "url-or-base64",
59+
"iconImageType": "image/svg+xml",
60+
"shape": "ellipse",
61+
"displayLabel": "Custom Label",
62+
"displayNameAttribute": "name",
63+
"longDisplayNameAttribute": "description",
64+
"backgroundOpacity": 0.4,
65+
"borderWidth": 0,
66+
"borderColor": "#hex",
67+
"borderStyle": "solid"
68+
}
69+
},
70+
"edges": {
71+
"<EdgeTypeLabel>": {
72+
"displayLabel": "Custom Label",
73+
"displayNameAttribute": "name",
74+
"labelColor": "#hex",
75+
"labelBackgroundOpacity": 0.7,
76+
"labelBorderColor": "#hex",
77+
"labelBorderStyle": "solid",
78+
"labelBorderWidth": 0,
79+
"lineColor": "#hex",
80+
"lineThickness": 2,
81+
"lineStyle": "solid",
82+
"sourceArrowStyle": "none",
83+
"targetArrowStyle": "triangle"
84+
}
85+
}
86+
}
87+
```
88+
89+
All properties are optional — only specify what you want to override. Type
90+
labels must exactly match the vertex/edge type names in your graph database.
91+
92+
### Icons
93+
94+
There are two ways to specify vertex icons:
95+
96+
- **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case
97+
(e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No
98+
additional files needed.
99+
- **`iconUrl`** — A URL or base64 data URI for a custom icon. Use this for
100+
non-Lucide icons. If both `icon` and `iconUrl` are specified, `iconUrl` takes
101+
precedence.
102+
103+
### Vertex Shapes
104+
105+
Available shapes: `ellipse`, `rectangle`, `diamond`, `triangle`, `pentagon`,
106+
`hexagon`, `heptagon`, `octagon`, `star`, `barrel`, `vee`, `rhomboid`, `tag`,
107+
`round-rectangle`, `round-triangle`, `round-diamond`, `round-pentagon`,
108+
`round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`,
109+
`cut-rectangle`, `concave-hexagon`.
110+
111+
### Line Styles
112+
113+
Available for edges and borders: `solid`, `dashed`, `dotted`.
114+
115+
### Arrow Styles
116+
117+
Available for `sourceArrowStyle` and `targetArrowStyle`: `triangle`,
118+
`triangle-tee`, `circle-triangle`, `triangle-cross`, `triangle-backcurve`,
119+
`tee`, `vee`, `square`, `circle`, `diamond`, `none`.
120+
121+
## Common Lucide Icons for Graph Use Cases
122+
123+
| Use Case | Icon Name |
124+
| -------------------- | --------------------------------- |
125+
| Person / User | `user` |
126+
| Account / Bank | `landmark` |
127+
| Email | `mail` |
128+
| Phone | `phone` |
129+
| Login / Auth | `log-in` |
130+
| Device | `monitor`, `laptop`, `smartphone` |
131+
| IP Address / Network | `globe`, `network` |
132+
| Transaction | `arrow-left-right` |
133+
| Location | `map-pin` |
134+
| Organization | `building` |
135+
| Alert | `shield-alert`, `triangle-alert` |
136+
| Document | `file-text` |
137+
| Calendar / Date | `calendar` |
138+
| Lock / Security | `lock`, `shield` |
139+
| Database | `database` |
140+
| Server | `server` |
141+
| Link / Relationship | `link` |
142+
143+
See the full list at [lucide.dev/icons](https://lucide.dev/icons).
144+
145+
## Import / Export / Reset
146+
147+
The Settings page provides styling management:
148+
149+
- **Export Styling** — exports the current per-type styling as a
150+
`defaultStyling.json` file, for sharing or Docker-mounting as team defaults.
151+
- **Import Styling** — imports a `defaultStyling.json` file. This is an
152+
alternative to mounting the file in Docker.
153+
- **Reset All Styling** — resets all styling to defaults. If a
154+
`defaultStyling.json` is mounted, those values are restored; otherwise,
155+
styling reverts to the application defaults.
156+
157+
Per-type reset is also available in the Node/Edge Style dialogs via the "Reset
158+
to Default" button.
159+
160+
## Example
161+
162+
See [`example/defaultStyling.json`](../example/defaultStyling.json) for a
163+
complete example with banking-oriented vertex and edge types.

example/defaultStyling.json

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{
2+
"vertices": {
3+
"User": {
4+
"color": "#1565C0",
5+
"icon": "user",
6+
"shape": "ellipse"
7+
},
8+
"Account": {
9+
"color": "#2E7D32",
10+
"icon": "landmark",
11+
"shape": "ellipse"
12+
},
13+
"Login": {
14+
"color": "#EF6C00",
15+
"icon": "log-in",
16+
"shape": "ellipse"
17+
},
18+
"EmailAddress": {
19+
"color": "#C62828",
20+
"icon": "mail",
21+
"shape": "ellipse"
22+
},
23+
"PhoneNumber": {
24+
"color": "#6A1B9A",
25+
"icon": "phone",
26+
"shape": "ellipse"
27+
},
28+
"Device": {
29+
"color": "#00838F",
30+
"icon": "monitor",
31+
"shape": "rectangle"
32+
},
33+
"IPAddress": {
34+
"color": "#4E342E",
35+
"icon": "globe",
36+
"shape": "diamond"
37+
},
38+
"Transaction": {
39+
"color": "#1B5E20",
40+
"icon": "arrow-left-right",
41+
"shape": "ellipse"
42+
},
43+
"Location": {
44+
"color": "#E65100",
45+
"icon": "map-pin",
46+
"shape": "ellipse"
47+
},
48+
"Organization": {
49+
"color": "#283593",
50+
"icon": "building",
51+
"shape": "rectangle"
52+
}
53+
},
54+
"edges": {
55+
"OWNS": {
56+
"lineColor": "#2E7D32",
57+
"lineThickness": 2,
58+
"lineStyle": "solid"
59+
},
60+
"TRANSFERRED_TO": {
61+
"lineColor": "#1B5E20",
62+
"lineThickness": 3,
63+
"lineStyle": "solid"
64+
},
65+
"HAS_EMAIL": {
66+
"lineColor": "#9E9E9E",
67+
"lineThickness": 1,
68+
"lineStyle": "dashed"
69+
},
70+
"HAS_PHONE": {
71+
"lineColor": "#9E9E9E",
72+
"lineThickness": 1,
73+
"lineStyle": "dashed"
74+
},
75+
"LOGGED_IN_FROM": {
76+
"lineColor": "#EF6C00",
77+
"lineThickness": 2,
78+
"lineStyle": "solid"
79+
},
80+
"LOCATED_AT": {
81+
"lineColor": "#E65100",
82+
"lineThickness": 1,
83+
"lineStyle": "dotted"
84+
}
85+
}
86+
}

packages/graph-explorer-proxy-server/src/node-server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ app.use(
180180
path.join(defaultConnectionFolderPath, "defaultConnection.json"),
181181
),
182182
);
183+
app.use(
184+
"/defaultStyling",
185+
express.static(path.join(defaultConnectionFolderPath, "defaultStyling.json")),
186+
);
187+
app.use(
188+
"/custom-icons",
189+
express.static(path.join(defaultConnectionFolderPath, "custom-icons")),
190+
);
183191

184192
// Host the Graph Explorer UI static files
185193
const staticFilesVirtualPath = "/explorer";

packages/graph-explorer/src/core/AppStatusLoader.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useQuery } from "@tanstack/react-query";
2-
import { useAtom } from "jotai";
2+
import { useAtom, useSetAtom } from "jotai";
33
import {
44
type PropsWithChildren,
55
startTransition,
@@ -11,7 +11,14 @@ import { PanelEmptyState, Spinner } from "@/components";
1111
import { logger } from "@/utils";
1212

1313
import { fetchDefaultConnection } from "./defaultConnection";
14-
import { activeConfigurationAtom, configurationAtom } from "./StateProvider";
14+
import { fetchDefaultStyling, resolveDefaultStyling } from "./defaultStyling";
15+
import {
16+
activeConfigurationAtom,
17+
configurationAtom,
18+
defaultStylingAtom,
19+
mergeDefaultsIntoUserStyling,
20+
userStylingAtom,
21+
} from "./StateProvider";
1522

1623
function AppStatusLoader({ children }: PropsWithChildren) {
1724
return (
@@ -24,6 +31,8 @@ function AppStatusLoader({ children }: PropsWithChildren) {
2431
function LoadDefaultConfig({ children }: PropsWithChildren) {
2532
const [activeConfig, setActiveConfig] = useAtom(activeConfigurationAtom);
2633
const [configuration, setConfiguration] = useAtom(configurationAtom);
34+
const setDefaultStyling = useSetAtom(defaultStylingAtom);
35+
const setUserStyling = useSetAtom(userStylingAtom);
2736

2837
const defaultConfigQuery = useQuery({
2938
queryKey: ["default-connection"],
@@ -33,6 +42,39 @@ function LoadDefaultConfig({ children }: PropsWithChildren) {
3342
enabled: configuration.size === 0,
3443
});
3544

45+
// Fetch default styling on every session start
46+
const defaultStylingQuery = useQuery({
47+
queryKey: ["default-styling"],
48+
queryFn: fetchDefaultStyling,
49+
staleTime: Infinity,
50+
});
51+
52+
useEffect(() => {
53+
const data = defaultStylingQuery.data;
54+
if (!data) {
55+
return;
56+
}
57+
let cancelled = false;
58+
logger.debug("Applying default styling", data);
59+
resolveDefaultStyling(data)
60+
.then(resolved => {
61+
if (!cancelled) {
62+
// Store reference copy for per-type "Reset to Default"
63+
setDefaultStyling(resolved);
64+
65+
// Merge file values into user styling. Default values fill in
66+
// properties the user hasn't explicitly set; user overrides win.
67+
setUserStyling(prev => mergeDefaultsIntoUserStyling(prev, resolved));
68+
}
69+
})
70+
.catch(err => {
71+
logger.warn("Failed to resolve default styling", err);
72+
});
73+
return () => {
74+
cancelled = true;
75+
};
76+
}, [defaultStylingQuery.data, setDefaultStyling, setUserStyling]);
77+
3678
const defaultConnectionConfigs = defaultConfigQuery.data;
3779

3880
useEffect(() => {

packages/graph-explorer/src/core/StateProvider/configuration.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,37 @@ describe("mergedConfiguration", () => {
257257
expect(actualEtConfig?.displayLabel).toEqual(customDisplayLabel);
258258
});
259259

260+
it("should apply styling from userStyling (which includes merged defaults)", () => {
261+
const config = createRandomRawConfiguration();
262+
const schema = createRandomSchema();
263+
// In the new architecture, defaults from defaultStyling.json are
264+
// merged into userStyling at load time
265+
const styling: UserStyling = {
266+
vertices: schema.vertices.map(v => ({
267+
type: v.type,
268+
color: "#FF0000",
269+
})),
270+
edges: schema.edges.map(e => ({
271+
type: e.type,
272+
lineColor: "#00FF00",
273+
})),
274+
};
275+
const result = mergeConfiguration(schema, config, styling);
276+
277+
for (const v of result.schema?.vertices ?? []) {
278+
const style = styling.vertices?.find(s => s.type === v.type);
279+
if (style) {
280+
expect(v.color).toBe("#FF0000");
281+
}
282+
}
283+
for (const e of result.schema?.edges ?? []) {
284+
const style = styling.edges?.find(s => s.type === e.type);
285+
if (style) {
286+
expect(e.lineColor).toBe("#00FF00");
287+
}
288+
}
289+
});
290+
260291
it("should patch displayNameAttribute to be 'types' when it was 'type'", () => {
261292
const etConfig = createRandomEdgeTypeConfig();
262293

0 commit comments

Comments
 (0)