From 89910a219f8b26c6d01357bf6bcad5de0d2e2237 Mon Sep 17 00:00:00 2001
From: jkemmererupgrade
Date: Mon, 9 Mar 2026 21:33:22 -0600
Subject: [PATCH 1/2] 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
---
Changelog.md | 5 +
docs/references/README.md | 1 +
docs/references/default-styling.md | 163 +++++++
example/defaultStyling.json | 86 ++++
.../src/node-server.ts | 8 +
.../src/core/AppStatusLoader.tsx | 46 +-
.../core/StateProvider/configuration.test.ts | 31 ++
.../src/core/StateProvider/configuration.ts | 4 +-
.../core/StateProvider/defaultStylingAtom.ts | 15 +
.../src/core/StateProvider/index.ts | 1 +
.../StateProvider/userPreferences.test.ts | 167 +++++++
.../src/core/StateProvider/userPreferences.ts | 63 ++-
.../src/core/defaultStyling.test.ts | 419 ++++++++++++++++++
.../graph-explorer/src/core/defaultStyling.ts | 284 ++++++++++++
.../src/routes/Settings/SettingsGeneral.tsx | 78 +++-
.../Settings/useExportStylingFile.test.tsx | 88 ++++
.../routes/Settings/useExportStylingFile.ts | 25 ++
.../Settings/useImportStylingFile.test.tsx | 125 ++++++
.../routes/Settings/useImportStylingFile.ts | 52 +++
.../src/utils/lucideIconUrl.test.ts | 80 ++++
.../graph-explorer/src/utils/lucideIconUrl.ts | 91 ++++
.../src/utils/testing/DbState.ts | 24 +-
22 files changed, 1844 insertions(+), 12 deletions(-)
create mode 100644 docs/references/default-styling.md
create mode 100644 example/defaultStyling.json
create mode 100644 packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts
create mode 100644 packages/graph-explorer/src/core/defaultStyling.test.ts
create mode 100644 packages/graph-explorer/src/core/defaultStyling.ts
create mode 100644 packages/graph-explorer/src/routes/Settings/useExportStylingFile.test.tsx
create mode 100644 packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts
create mode 100644 packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx
create mode 100644 packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts
create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.test.ts
create mode 100644 packages/graph-explorer/src/utils/lucideIconUrl.ts
diff --git a/Changelog.md b/Changelog.md
index f0652c10b..dbb4b4dfa 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -1,5 +1,10 @@
# Graph Explorer Change Log
+## Unreleased
+
+- Add defaultStyling.json support for persistent per-type vertex and edge
+ styling (#1265, #112, #173, #573, #689)
+
## Release 3.0.0
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.
diff --git a/docs/references/README.md b/docs/references/README.md
index a4a773451..eb940c51e 100644
--- a/docs/references/README.md
+++ b/docs/references/README.md
@@ -7,3 +7,4 @@ Technical reference documentation for Graph Explorer covering configuration, sec
- [Health Check](./health-check.md) - Proxy server health and readiness endpoint
- [Logging](./logging.md) - Log levels, output, and proxy server logging modules
- [Default Connection](./default-connection.md) - Configure a default connection via environment variables or JSON
+- [Default Styling](./default-styling.md) - Configure default vertex and edge styling via a JSON file
diff --git a/docs/references/default-styling.md b/docs/references/default-styling.md
new file mode 100644
index 000000000..0775e1c36
--- /dev/null
+++ b/docs/references/default-styling.md
@@ -0,0 +1,163 @@
+# Default Styling Configuration
+
+Graph Explorer supports an optional `defaultStyling.json` file that provides
+default vertex and edge styling for all users. This is useful for:
+
+- Non-persistent browser environments (e.g., AWS WorkSpaces Web) where IndexedDB
+ is cleared between sessions
+- Pre-configuring a shared visual style for teams
+- Providing a consistent starting point for new users
+
+## How It Works
+
+On startup, Graph Explorer fetches `defaultStyling.json` and merges its values
+into the user's per-type styling preferences (stored in IndexedDB). Default
+values fill in any properties the user hasn't explicitly set — existing user
+overrides are preserved.
+
+When no `defaultStyling.json` is mounted, behavior is identical to the default
+Graph Explorer experience. When mounted, it populates the per-type styling that
+users can then customize. Resetting a vertex or edge style in the UI will revert
+to the `defaultStyling.json` values (or the hardcoded application defaults if no
+entry exists for that type).
+
+## Setup
+
+### Docker
+
+Mount the file into the container's configuration folder:
+
+```bash
+docker run \
+ -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \
+ public.ecr.aws/neptune/graph-explorer
+```
+
+### Custom Icons
+
+To serve custom icon files (referenced by URL in the config), mount an icons
+directory:
+
+```bash
+docker run \
+ -v /path/to/defaultStyling.json:/graph-explorer/packages/graph-explorer/defaultStyling.json \
+ -v /path/to/icons:/graph-explorer/packages/graph-explorer/custom-icons \
+ public.ecr.aws/neptune/graph-explorer
+```
+
+Icons in the `custom-icons` directory are served at `/custom-icons/`.
+
+## JSON Schema
+
+```json
+{
+ "vertices": {
+ "": {
+ "color": "#hex",
+ "icon": "lucide-icon-name",
+ "iconUrl": "url-or-base64",
+ "iconImageType": "image/svg+xml",
+ "shape": "ellipse",
+ "displayLabel": "Custom Label",
+ "displayNameAttribute": "name",
+ "longDisplayNameAttribute": "description",
+ "backgroundOpacity": 0.4,
+ "borderWidth": 0,
+ "borderColor": "#hex",
+ "borderStyle": "solid"
+ }
+ },
+ "edges": {
+ "": {
+ "displayLabel": "Custom Label",
+ "displayNameAttribute": "name",
+ "labelColor": "#hex",
+ "labelBackgroundOpacity": 0.7,
+ "labelBorderColor": "#hex",
+ "labelBorderStyle": "solid",
+ "labelBorderWidth": 0,
+ "lineColor": "#hex",
+ "lineThickness": 2,
+ "lineStyle": "solid",
+ "sourceArrowStyle": "none",
+ "targetArrowStyle": "triangle"
+ }
+ }
+}
+```
+
+All properties are optional — only specify what you want to override. Type
+labels must exactly match the vertex/edge type names in your graph database.
+
+### Icons
+
+There are two ways to specify vertex icons:
+
+- **`icon`** — A [Lucide](https://lucide.dev/icons) icon name in kebab-case
+ (e.g., `"user"`, `"log-in"`, `"landmark"`). Resolved to an SVG at runtime. No
+ additional files needed.
+- **`iconUrl`** — A URL or base64 data URI for a custom icon. Use this for
+ non-Lucide icons. If both `icon` and `iconUrl` are specified, `iconUrl` takes
+ precedence.
+
+### Vertex Shapes
+
+Available shapes: `ellipse`, `rectangle`, `diamond`, `triangle`, `pentagon`,
+`hexagon`, `heptagon`, `octagon`, `star`, `barrel`, `vee`, `rhomboid`, `tag`,
+`round-rectangle`, `round-triangle`, `round-diamond`, `round-pentagon`,
+`round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`,
+`cut-rectangle`, `concave-hexagon`.
+
+### Line Styles
+
+Available for edges and borders: `solid`, `dashed`, `dotted`.
+
+### Arrow Styles
+
+Available for `sourceArrowStyle` and `targetArrowStyle`: `triangle`,
+`triangle-tee`, `circle-triangle`, `triangle-cross`, `triangle-backcurve`,
+`tee`, `vee`, `square`, `circle`, `diamond`, `none`.
+
+## Common Lucide Icons for Graph Use Cases
+
+| Use Case | Icon Name |
+| -------------------- | --------------------------------- |
+| Person / User | `user` |
+| Account / Bank | `landmark` |
+| Email | `mail` |
+| Phone | `phone` |
+| Login / Auth | `log-in` |
+| Device | `monitor`, `laptop`, `smartphone` |
+| IP Address / Network | `globe`, `network` |
+| Transaction | `arrow-left-right` |
+| Location | `map-pin` |
+| Organization | `building` |
+| Alert | `shield-alert`, `triangle-alert` |
+| Document | `file-text` |
+| Calendar / Date | `calendar` |
+| Lock / Security | `lock`, `shield` |
+| Database | `database` |
+| Server | `server` |
+| Link / Relationship | `link` |
+
+See the full list at [lucide.dev/icons](https://lucide.dev/icons).
+
+## Import / Export / Reset
+
+The Settings page provides styling management:
+
+- **Export Styling** — exports the current per-type styling as a
+ `defaultStyling.json` file, for sharing or Docker-mounting as team defaults.
+- **Import Styling** — imports a `defaultStyling.json` file. This is an
+ alternative to mounting the file in Docker.
+- **Reset All Styling** — resets all styling to defaults. If a
+ `defaultStyling.json` is mounted, those values are restored; otherwise,
+ styling reverts to the application defaults.
+
+Per-type reset is also available in the Node/Edge Style dialogs via the "Reset
+to Default" button.
+
+## Example
+
+See [`example/defaultStyling.json`](../example/defaultStyling.json) for a
+complete example with banking-oriented vertex and edge types.
diff --git a/example/defaultStyling.json b/example/defaultStyling.json
new file mode 100644
index 000000000..122a8856b
--- /dev/null
+++ b/example/defaultStyling.json
@@ -0,0 +1,86 @@
+{
+ "vertices": {
+ "User": {
+ "color": "#1565C0",
+ "icon": "user",
+ "shape": "ellipse"
+ },
+ "Account": {
+ "color": "#2E7D32",
+ "icon": "landmark",
+ "shape": "ellipse"
+ },
+ "Login": {
+ "color": "#EF6C00",
+ "icon": "log-in",
+ "shape": "ellipse"
+ },
+ "EmailAddress": {
+ "color": "#C62828",
+ "icon": "mail",
+ "shape": "ellipse"
+ },
+ "PhoneNumber": {
+ "color": "#6A1B9A",
+ "icon": "phone",
+ "shape": "ellipse"
+ },
+ "Device": {
+ "color": "#00838F",
+ "icon": "monitor",
+ "shape": "rectangle"
+ },
+ "IPAddress": {
+ "color": "#4E342E",
+ "icon": "globe",
+ "shape": "diamond"
+ },
+ "Transaction": {
+ "color": "#1B5E20",
+ "icon": "arrow-left-right",
+ "shape": "ellipse"
+ },
+ "Location": {
+ "color": "#E65100",
+ "icon": "map-pin",
+ "shape": "ellipse"
+ },
+ "Organization": {
+ "color": "#283593",
+ "icon": "building",
+ "shape": "rectangle"
+ }
+ },
+ "edges": {
+ "OWNS": {
+ "lineColor": "#2E7D32",
+ "lineThickness": 2,
+ "lineStyle": "solid"
+ },
+ "TRANSFERRED_TO": {
+ "lineColor": "#1B5E20",
+ "lineThickness": 3,
+ "lineStyle": "solid"
+ },
+ "HAS_EMAIL": {
+ "lineColor": "#9E9E9E",
+ "lineThickness": 1,
+ "lineStyle": "dashed"
+ },
+ "HAS_PHONE": {
+ "lineColor": "#9E9E9E",
+ "lineThickness": 1,
+ "lineStyle": "dashed"
+ },
+ "LOGGED_IN_FROM": {
+ "lineColor": "#EF6C00",
+ "lineThickness": 2,
+ "lineStyle": "solid"
+ },
+ "LOCATED_AT": {
+ "lineColor": "#E65100",
+ "lineThickness": 1,
+ "lineStyle": "dotted"
+ }
+ }
+}
diff --git a/packages/graph-explorer-proxy-server/src/node-server.ts b/packages/graph-explorer-proxy-server/src/node-server.ts
index 8d96bcd78..68cfc8016 100644
--- a/packages/graph-explorer-proxy-server/src/node-server.ts
+++ b/packages/graph-explorer-proxy-server/src/node-server.ts
@@ -180,6 +180,14 @@ app.use(
path.join(defaultConnectionFolderPath, "defaultConnection.json"),
),
);
+app.use(
+ "/defaultStyling",
+ express.static(path.join(defaultConnectionFolderPath, "defaultStyling.json")),
+);
+app.use(
+ "/custom-icons",
+ express.static(path.join(defaultConnectionFolderPath, "custom-icons")),
+);
// Host the Graph Explorer UI static files
const staticFilesVirtualPath = "/explorer";
diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx
index 36535788f..a100d356c 100644
--- a/packages/graph-explorer/src/core/AppStatusLoader.tsx
+++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { useAtom } from "jotai";
+import { useAtom, useSetAtom } from "jotai";
import {
type PropsWithChildren,
startTransition,
@@ -11,7 +11,14 @@ import { PanelEmptyState, Spinner } from "@/components";
import { logger } from "@/utils";
import { fetchDefaultConnection } from "./defaultConnection";
-import { activeConfigurationAtom, configurationAtom } from "./StateProvider";
+import { fetchDefaultStyling, resolveDefaultStyling } from "./defaultStyling";
+import {
+ activeConfigurationAtom,
+ configurationAtom,
+ defaultStylingAtom,
+ mergeDefaultsIntoUserStyling,
+ userStylingAtom,
+} from "./StateProvider";
function AppStatusLoader({ children }: PropsWithChildren) {
return (
@@ -24,6 +31,8 @@ function AppStatusLoader({ children }: PropsWithChildren) {
function LoadDefaultConfig({ children }: PropsWithChildren) {
const [activeConfig, setActiveConfig] = useAtom(activeConfigurationAtom);
const [configuration, setConfiguration] = useAtom(configurationAtom);
+ const setDefaultStyling = useSetAtom(defaultStylingAtom);
+ const setUserStyling = useSetAtom(userStylingAtom);
const defaultConfigQuery = useQuery({
queryKey: ["default-connection"],
@@ -33,6 +42,39 @@ function LoadDefaultConfig({ children }: PropsWithChildren) {
enabled: configuration.size === 0,
});
+ // Fetch default styling on every session start
+ const defaultStylingQuery = useQuery({
+ queryKey: ["default-styling"],
+ queryFn: fetchDefaultStyling,
+ staleTime: Infinity,
+ });
+
+ useEffect(() => {
+ const data = defaultStylingQuery.data;
+ if (!data) {
+ return;
+ }
+ let cancelled = false;
+ logger.debug("Applying default styling", data);
+ resolveDefaultStyling(data)
+ .then(resolved => {
+ if (!cancelled) {
+ // Store reference copy for per-type "Reset to Default"
+ setDefaultStyling(resolved);
+
+ // Merge file values into user styling. Default values fill in
+ // properties the user hasn't explicitly set; user overrides win.
+ setUserStyling(prev => mergeDefaultsIntoUserStyling(prev, resolved));
+ }
+ })
+ .catch(err => {
+ logger.warn("Failed to resolve default styling", err);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [defaultStylingQuery.data, setDefaultStyling, setUserStyling]);
+
const defaultConnectionConfigs = defaultConfigQuery.data;
useEffect(() => {
diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts
index a972d7cdb..f948c723f 100644
--- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts
+++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts
@@ -257,6 +257,37 @@ describe("mergedConfiguration", () => {
expect(actualEtConfig?.displayLabel).toEqual(customDisplayLabel);
});
+ it("should apply styling from userStyling (which includes merged defaults)", () => {
+ const config = createRandomRawConfiguration();
+ const schema = createRandomSchema();
+ // In the new architecture, defaults from defaultStyling.json are
+ // merged into userStyling at load time
+ const styling: UserStyling = {
+ vertices: schema.vertices.map(v => ({
+ type: v.type,
+ color: "#FF0000",
+ })),
+ edges: schema.edges.map(e => ({
+ type: e.type,
+ lineColor: "#00FF00",
+ })),
+ };
+ const result = mergeConfiguration(schema, config, styling);
+
+ for (const v of result.schema?.vertices ?? []) {
+ const style = styling.vertices?.find(s => s.type === v.type);
+ if (style) {
+ expect(v.color).toBe("#FF0000");
+ }
+ }
+ for (const e of result.schema?.edges ?? []) {
+ const style = styling.edges?.find(s => s.type === e.type);
+ if (style) {
+ expect(e.lineColor).toBe("#00FF00");
+ }
+ }
+ });
+
it("should patch displayNameAttribute to be 'types' when it was 'type'", () => {
const etConfig = createRandomEdgeTypeConfig();
diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts
index f3119f406..87e01ef43 100644
--- a/packages/graph-explorer/src/core/StateProvider/configuration.ts
+++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts
@@ -170,7 +170,7 @@ const mergeVertex = (
...(patchedSchema || {}),
// File-based override
...(patchedConfig || {}),
- // User preferences override
+ // User preferences override (includes defaults from defaultStyling.json)
...(preferences || {}),
attributes,
};
@@ -204,7 +204,7 @@ const mergeEdge = (
...(patchedSchema || {}),
// File-based override
...(patchedConfig || {}),
- // User preferences override
+ // User preferences override (includes defaults from defaultStyling.json)
...(preferences || {}),
attributes,
};
diff --git a/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts
new file mode 100644
index 000000000..a92e8651b
--- /dev/null
+++ b/packages/graph-explorer/src/core/StateProvider/defaultStylingAtom.ts
@@ -0,0 +1,15 @@
+import { atom } from "jotai";
+
+import type { UserStyling } from "./userPreferences";
+
+/**
+ * Read-only reference copy of the resolved styling from defaultStyling.json.
+ *
+ * On load, the file values are written into userStylingAtom (for types without
+ * existing overrides). This atom is kept only as a reference so that per-type
+ * "Reset to Default" can restore the file's original values.
+ *
+ * NOT persisted to LocalForage. Re-fetched each session. Remains null when
+ * no defaultStyling.json is mounted.
+ */
+export const defaultStylingAtom = atom(null);
diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts
index 786eda58d..b4d5ce3b4 100644
--- a/packages/graph-explorer/src/core/StateProvider/index.ts
+++ b/packages/graph-explorer/src/core/StateProvider/index.ts
@@ -1,5 +1,6 @@
export * from "./appStore";
export * from "./configuration";
+export * from "./defaultStylingAtom";
export * from "./displayAttribute";
export * from "./displayEdge";
export * from "./displayTypeConfigs";
diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts
index 00ba721d6..9b3190479 100644
--- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts
+++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts
@@ -7,6 +7,7 @@ import {
defaultEdgePreferences,
defaultVertexPreferences,
type EdgePreferencesStorageModel,
+ mergeDefaultsIntoUserStyling,
useEdgeStyling,
useVertexStyling,
type VertexPreferencesStorageModel,
@@ -339,6 +340,172 @@ describe("useEdgeStyling", () => {
});
});
+describe("default styling", () => {
+ it("should apply default styling when no user pref exists", () => {
+ const dbState = new DbState();
+ dbState.setDefaultStyling({
+ vertices: [
+ { type: createVertexType("test"), color: "red", shape: "diamond" },
+ ],
+ });
+
+ const { result } = renderHookWithState(
+ () => useVertexStyling(createVertexType("test")),
+ dbState,
+ );
+
+ expect(result.current.vertexStyle.color).toBe("red");
+ expect(result.current.vertexStyle.shape).toBe("diamond");
+ });
+
+ it("should let user prefs override default styling", () => {
+ const dbState = new DbState();
+ dbState.setDefaultStyling({
+ vertices: [
+ { type: createVertexType("test"), color: "red", shape: "diamond" },
+ ],
+ });
+ dbState.addVertexStyle(createVertexType("test"), { color: "blue" });
+
+ const { result } = renderHookWithState(
+ () => useVertexStyling(createVertexType("test")),
+ dbState,
+ );
+
+ // User pref overrides default styling color
+ expect(result.current.vertexStyle.color).toBe("blue");
+ // Default styling shape still applies since user didn't override it
+ expect(result.current.vertexStyle.shape).toBe("diamond");
+ });
+
+ it("should reveal default styling after reset", () => {
+ const dbState = new DbState();
+ dbState.setDefaultStyling({
+ vertices: [{ type: createVertexType("test"), color: "red" }],
+ });
+ dbState.addVertexStyle(createVertexType("test"), { color: "blue" });
+
+ const { result } = renderHookWithState(
+ () => useVertexStyling(createVertexType("test")),
+ dbState,
+ );
+
+ expect(result.current.vertexStyle.color).toBe("blue");
+
+ act(() => result.current.resetVertexStyle());
+
+ // After reset, default styling color should be visible
+ expect(result.current.vertexStyle.color).toBe("red");
+ });
+
+ it("should fall through to hardcoded defaults when no default styling", () => {
+ const dbState = new DbState();
+ // No default styling set
+
+ const { result } = renderHookWithState(
+ () => useVertexStyling(createVertexType("test")),
+ dbState,
+ );
+
+ expect(result.current.vertexStyle).toStrictEqual(
+ createExpectedVertex({ type: createVertexType("test") }),
+ );
+ });
+
+ it("should apply default edge styling", () => {
+ const dbState = new DbState();
+ dbState.setDefaultStyling({
+ edges: [
+ { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 },
+ ],
+ });
+
+ const { result } = renderHookWithState(
+ () => useEdgeStyling(createEdgeType("test")),
+ dbState,
+ );
+
+ expect(result.current.edgeStyle.lineColor).toBe("green");
+ expect(result.current.edgeStyle.lineThickness).toBe(5);
+ });
+
+ it("should let user edge prefs override default edge styling", () => {
+ const dbState = new DbState();
+ dbState.setDefaultStyling({
+ edges: [
+ { type: createEdgeType("test"), lineColor: "green", lineThickness: 5 },
+ ],
+ });
+ dbState.addEdgeStyle(createEdgeType("test"), { lineColor: "red" });
+
+ const { result } = renderHookWithState(
+ () => useEdgeStyling(createEdgeType("test")),
+ dbState,
+ );
+
+ expect(result.current.edgeStyle.lineColor).toBe("red");
+ expect(result.current.edgeStyle.lineThickness).toBe(5);
+ });
+});
+
+describe("mergeDefaultsIntoUserStyling", () => {
+ it("should add default types when user has none", () => {
+ const result = mergeDefaultsIntoUserStyling(
+ {},
+ {
+ vertices: [{ type: createVertexType("A"), color: "red" }],
+ edges: [{ type: createEdgeType("B"), lineColor: "green" }],
+ },
+ );
+ expect(result.vertices).toHaveLength(1);
+ expect(result.vertices![0].color).toBe("red");
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges![0].lineColor).toBe("green");
+ });
+
+ it("should merge properties when user has partial override", () => {
+ const result = mergeDefaultsIntoUserStyling(
+ {
+ vertices: [{ type: createVertexType("A"), color: "blue" }],
+ },
+ {
+ vertices: [
+ { type: createVertexType("A"), color: "red", shape: "diamond" },
+ ],
+ },
+ );
+ expect(result.vertices).toHaveLength(1);
+ expect(result.vertices![0].color).toBe("blue"); // user wins
+ expect(result.vertices![0].shape).toBe("diamond"); // default fills in
+ });
+
+ it("should not modify types not in defaults", () => {
+ const result = mergeDefaultsIntoUserStyling(
+ {
+ vertices: [{ type: createVertexType("A"), color: "blue" }],
+ edges: [{ type: createEdgeType("X"), lineColor: "red" }],
+ },
+ {
+ vertices: [{ type: createVertexType("B"), color: "green" }],
+ },
+ );
+ expect(result.vertices).toHaveLength(2);
+ expect(result.vertices![0].color).toBe("blue");
+ expect(result.vertices![1].color).toBe("green");
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges![0].lineColor).toBe("red");
+ });
+
+ it("should handle empty defaults", () => {
+ const input = {
+ vertices: [{ type: createVertexType("A"), color: "blue" }],
+ };
+ const result = mergeDefaultsIntoUserStyling(input, {});
+ expect(result.vertices).toHaveLength(1);
+ expect(result.edges).toHaveLength(0);
+ });
+});
+
describe("useDeferredAtom integration", () => {
it("should handle multiple rapid updates correctly", () => {
const dbState = new DbState();
diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts
index 54e386bdf..7104b036d 100644
--- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts
+++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts
@@ -9,6 +9,7 @@ import DEFAULT_ICON_URL from "@/utils/defaultIconUrl";
import type { EdgeType, VertexType } from "../entities";
+import { defaultStylingAtom } from "./defaultStylingAtom";
import { useActiveSchema } from "./schema";
import { userStylingAtom } from "./storageAtoms";
@@ -158,6 +159,38 @@ export type UserStyling = {
edges?: Array;
};
+/**
+ * Merges default styling from defaultStyling.json into user styling.
+ * Default values fill in properties the user hasn't explicitly set;
+ * existing user overrides win via spread order.
+ */
+export function mergeDefaultsIntoUserStyling(
+ userStyling: UserStyling,
+ defaults: UserStyling,
+): UserStyling {
+ const vertices = [...(userStyling.vertices ?? [])];
+ for (const v of defaults.vertices ?? []) {
+ const existingIndex = vertices.findIndex(e => e.type === v.type);
+ if (existingIndex >= 0) {
+ vertices[existingIndex] = { ...v, ...vertices[existingIndex] };
+ } else {
+ vertices.push(v);
+ }
+ }
+
+ const edges = [...(userStyling.edges ?? [])];
+ for (const e of defaults.edges ?? []) {
+ const existingIndex = edges.findIndex(x => x.type === e.type);
+ if (existingIndex >= 0) {
+ edges[existingIndex] = { ...e, ...edges[existingIndex] };
+ } else {
+ edges.push(e);
+ }
+ }
+
+ return { vertices, edges };
+}
+
/** Get the stored user preferences for vertices and edges in a fast lookup Map. */
function useStoredGraphPreferences() {
const graphPreferences = useAtomValue(userStylingAtom);
@@ -170,7 +203,10 @@ function useStoredGraphPreferences() {
return deferredResult;
}
-/** Combines the stored user preferences with the defined default values. */
+/**
+ * Combines hardcoded defaults with user preferences.
+ * User preferences include values populated from defaultStyling.json on load.
+ */
export function createVertexPreference(
type: VertexType,
stored?: VertexPreferencesStorageModel,
@@ -182,7 +218,10 @@ export function createVertexPreference(
} as const;
}
-/** Combines the stored user preferences with the defined default values. */
+/**
+ * Combines hardcoded defaults with user preferences.
+ * User preferences include values populated from defaultStyling.json on load.
+ */
export function createEdgePreference(
type: EdgeType,
stored?: EdgePreferencesStorageModel,
@@ -256,6 +295,7 @@ type UpdatedVertexStyle = Partial>;
*/
export function useVertexStyling(type: VertexType) {
const setAllStyling = useSetAtom(userStylingAtom);
+ const defaultStyling = useAtomValue(defaultStylingAtom);
const vertexStyle = useVertexPreferences(type);
const setVertexStyle = (updatedStyle: UpdatedVertexStyle) =>
@@ -279,9 +319,17 @@ export function useVertexStyling(type: VertexType) {
const resetVertexStyle = () =>
setAllStyling(prev => {
+ // Restore from defaultStyling.json if available, otherwise remove entirely
+ // (which falls back to hardcoded defaults)
+ const defaultForType = defaultStyling?.vertices?.find(
+ v => v.type === type,
+ );
+ const withoutCurrent = prev.vertices?.filter(v => v.type !== type) ?? [];
return {
...prev,
- vertices: prev.vertices?.filter(v => v.type !== type),
+ vertices: defaultForType
+ ? [...withoutCurrent, defaultForType]
+ : withoutCurrent,
};
});
@@ -302,6 +350,7 @@ type UpdatedEdgeStyle = Omit;
*/
export function useEdgeStyling(type: EdgeType) {
const setAllStyling = useSetAtom(userStylingAtom);
+ const defaultStyling = useAtomValue(defaultStylingAtom);
const edgeStyle = useEdgePreferences(type);
const setEdgeStyle = (updatedStyle: UpdatedEdgeStyle) =>
@@ -325,9 +374,15 @@ export function useEdgeStyling(type: EdgeType) {
const resetEdgeStyle = () =>
setAllStyling(prev => {
+ // Restore from defaultStyling.json if available, otherwise remove entirely
+ // (which falls back to hardcoded defaults)
+ const defaultForType = defaultStyling?.edges?.find(e => e.type === type);
+ const withoutCurrent = prev.edges?.filter(e => e.type !== type) ?? [];
return {
...prev,
- edges: prev.edges?.filter(v => v.type !== type),
+ edges: defaultForType
+ ? [...withoutCurrent, defaultForType]
+ : withoutCurrent,
};
});
diff --git a/packages/graph-explorer/src/core/defaultStyling.test.ts b/packages/graph-explorer/src/core/defaultStyling.test.ts
new file mode 100644
index 000000000..ef4a58845
--- /dev/null
+++ b/packages/graph-explorer/src/core/defaultStyling.test.ts
@@ -0,0 +1,419 @@
+import {
+ DefaultStylingSchema,
+ fetchDefaultStyling,
+ parseDefaultStyling,
+ resolveDefaultStyling,
+ userStylingToExportFormat,
+} from "./defaultStyling";
+import { createEdgeType, createVertexType } from "./entities";
+
+describe("DefaultStylingSchema", () => {
+ it("should accept a valid complete config", () => {
+ const data = {
+ vertices: {
+ User: {
+ color: "#1565C0",
+ icon: "user",
+ shape: "ellipse",
+ backgroundOpacity: 0.4,
+ borderWidth: 2,
+ borderColor: "#000000",
+ borderStyle: "solid",
+ },
+ },
+ edges: {
+ OWNS: {
+ lineColor: "#2E7D32",
+ lineThickness: 3,
+ lineStyle: "dashed",
+ sourceArrowStyle: "none",
+ targetArrowStyle: "triangle",
+ },
+ },
+ };
+
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept an empty object", () => {
+ const result = DefaultStylingSchema.safeParse({});
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept partial vertex entries", () => {
+ const data = {
+ vertices: {
+ User: { color: "#1565C0" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept partial edge entries", () => {
+ const data = {
+ edges: {
+ OWNS: { lineColor: "#2E7D32" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(true);
+ });
+
+ it("should accept vertex entries with zero values", () => {
+ const data = {
+ vertices: {
+ User: { backgroundOpacity: 0, borderWidth: 0 },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.vertices?.User.backgroundOpacity).toBe(0);
+ expect(result.data.vertices?.User.borderWidth).toBe(0);
+ }
+ });
+
+ it("should reject unknown top-level properties", () => {
+ const data = { unknownProp: "value" };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject unknown vertex properties", () => {
+ const data = {
+ vertices: {
+ User: { unknownProp: "value" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject invalid backgroundOpacity", () => {
+ const data = {
+ vertices: {
+ User: { backgroundOpacity: 2 },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject invalid borderStyle", () => {
+ const data = {
+ vertices: {
+ User: { borderStyle: "wavy" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+
+ it("should reject invalid shape", () => {
+ const data = {
+ vertices: {
+ User: { shape: "banana" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+
+ it("should accept valid shape values", () => {
+ const shapes = [
+ "ellipse",
+ "rectangle",
+ "round-rectangle",
+ "diamond",
+ "star",
+ ];
+ for (const shape of shapes) {
+ const data = { vertices: { User: { shape } } };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(true);
+ }
+ });
+
+ it("should reject invalid arrow style", () => {
+ const data = {
+ edges: {
+ OWNS: { targetArrowStyle: "star" },
+ },
+ };
+ const result = DefaultStylingSchema.safeParse(data);
+ expect(result.success).toBe(false);
+ });
+});
+
+describe("fetchDefaultStyling", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("should return parsed data on successful fetch", async () => {
+ const mockData = { vertices: { User: { color: "#1565C0" } } };
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(mockData), { status: 200 }),
+ );
+ const result = await fetchDefaultStyling();
+ expect(result).not.toBeNull();
+ expect(result?.vertices?.User.color).toBe("#1565C0");
+ });
+
+ it("should return null on 404", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("Not Found", { status: 404 }),
+ );
+ const result = await fetchDefaultStyling();
+ expect(result).toBeNull();
+ });
+
+ it("should return null on non-404 error status", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("Server Error", { status: 500 }),
+ );
+ const result = await fetchDefaultStyling();
+ expect(result).toBeNull();
+ });
+
+ it("should return null on fetch exception", async () => {
+ vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error"));
+ const result = await fetchDefaultStyling();
+ expect(result).toBeNull();
+ });
+
+ it("should return null for invalid JSON response", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response('{"vertices": "invalid"}', { status: 200 }),
+ );
+ const result = await fetchDefaultStyling();
+ expect(result).toBeNull();
+ });
+});
+
+describe("parseDefaultStyling", () => {
+ it("should return parsed data for valid input", () => {
+ const data = {
+ vertices: {
+ User: { color: "#1565C0", icon: "user" },
+ },
+ };
+ const result = parseDefaultStyling(data);
+ expect(result).not.toBeNull();
+ expect(result?.vertices?.User.color).toBe("#1565C0");
+ });
+
+ it("should return null for invalid input", () => {
+ const result = parseDefaultStyling({ vertices: "invalid" });
+ expect(result).toBeNull();
+ });
+
+ it("should return null for non-object input", () => {
+ const result = parseDefaultStyling("not an object");
+ expect(result).toBeNull();
+ });
+
+ it("should return null for null input", () => {
+ const result = parseDefaultStyling(null);
+ expect(result).toBeNull();
+ });
+});
+
+describe("resolveDefaultStyling", () => {
+ it("should resolve lucide icon names to data URIs", async () => {
+ const data = {
+ vertices: {
+ User: { icon: "user", color: "#1565C0" },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices).toHaveLength(1);
+ expect(result.vertices?.[0].type).toBe(createVertexType("User"));
+ expect(result.vertices?.[0].iconUrl).toMatch(
+ /^data:image\/svg\+xml;base64,/,
+ );
+ expect(result.vertices?.[0].iconImageType).toBe("image/svg+xml");
+ expect(result.vertices?.[0].color).toBe("#1565C0");
+ });
+
+ it("should prefer explicit iconUrl over lucide icon name", async () => {
+ const data = {
+ vertices: {
+ User: {
+ icon: "user",
+ iconUrl: "https://example.com/icon.svg",
+ iconImageType: "image/svg+xml",
+ },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.svg");
+ });
+
+ it("should handle unknown lucide icon names gracefully", async () => {
+ const data = {
+ vertices: {
+ User: { icon: "not-a-real-icon", color: "#1565C0" },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices?.[0].iconUrl).toBeUndefined();
+ expect(result.vertices?.[0].color).toBe("#1565C0");
+ });
+
+ it("should resolve edge styles", async () => {
+ const data = {
+ edges: {
+ OWNS: { lineColor: "#2E7D32", lineThickness: 3 },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges?.[0].type).toBe(createEdgeType("OWNS"));
+ expect(result.edges?.[0].lineColor).toBe("#2E7D32");
+ expect(result.edges?.[0].lineThickness).toBe(3);
+ });
+
+ it("should handle empty data", async () => {
+ const result = await resolveDefaultStyling({});
+ expect(result.vertices).toHaveLength(0);
+ expect(result.edges).toHaveLength(0);
+ });
+
+ it("should handle multiple vertex types", async () => {
+ const data = {
+ vertices: {
+ User: { color: "#1565C0" },
+ Account: { color: "#2E7D32" },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices).toHaveLength(2);
+ const types = result.vertices?.map(v => v.type);
+ expect(types).toContain(createVertexType("User"));
+ expect(types).toContain(createVertexType("Account"));
+ });
+
+ it("should resolve all vertex style properties", async () => {
+ const data = {
+ vertices: {
+ User: {
+ color: "#1565C0",
+ displayLabel: "Person",
+ displayNameAttribute: "name",
+ longDisplayNameAttribute: "fullName",
+ shape: "round-rectangle" as const,
+ backgroundOpacity: 0.5,
+ borderWidth: 2,
+ borderColor: "#000000",
+ borderStyle: "dashed" as const,
+ },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+ const v = result.vertices?.[0];
+
+ expect(v?.color).toBe("#1565C0");
+ expect(v?.displayLabel).toBe("Person");
+ expect(v?.displayNameAttribute).toBe("name");
+ expect(v?.longDisplayNameAttribute).toBe("fullName");
+ expect(v?.shape).toBe("round-rectangle");
+ expect(v?.backgroundOpacity).toBe(0.5);
+ expect(v?.borderWidth).toBe(2);
+ expect(v?.borderColor).toBe("#000000");
+ expect(v?.borderStyle).toBe("dashed");
+ });
+
+ it("should resolve iconUrl and iconImageType when explicitly provided", async () => {
+ const data = {
+ vertices: {
+ User: {
+ iconUrl: "https://example.com/icon.png",
+ iconImageType: "image/png",
+ },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices?.[0].iconUrl).toBe("https://example.com/icon.png");
+ expect(result.vertices?.[0].iconImageType).toBe("image/png");
+ });
+
+ it("should preserve zero values for backgroundOpacity and borderWidth", async () => {
+ const data = {
+ vertices: {
+ User: { backgroundOpacity: 0, borderWidth: 0 },
+ },
+ };
+ const result = await resolveDefaultStyling(data);
+
+ expect(result.vertices?.[0].backgroundOpacity).toBe(0);
+ expect(result.vertices?.[0].borderWidth).toBe(0);
+ });
+});
+
+describe("userStylingToExportFormat", () => {
+ it("should convert vertex styling to export format", () => {
+ const styling = {
+ vertices: [
+ {
+ type: createVertexType("User"),
+ color: "#1565C0",
+ iconUrl: "data:image/svg+xml;base64,abc",
+ },
+ ],
+ };
+ const result = userStylingToExportFormat(styling);
+
+ expect(result.vertices).toBeDefined();
+ expect(result.vertices?.User).toEqual({
+ color: "#1565C0",
+ iconUrl: "data:image/svg+xml;base64,abc",
+ });
+ });
+
+ it("should convert edge styling to export format", () => {
+ const styling = {
+ edges: [
+ {
+ type: createEdgeType("OWNS"),
+ lineColor: "#2E7D32",
+ lineThickness: 3,
+ },
+ ],
+ };
+ const result = userStylingToExportFormat(styling);
+
+ expect(result.edges).toBeDefined();
+ expect(result.edges?.OWNS).toEqual({
+ lineColor: "#2E7D32",
+ lineThickness: 3,
+ });
+ });
+
+ it("should return empty object for empty styling", () => {
+ const result = userStylingToExportFormat({});
+ expect(result).toEqual({});
+ });
+
+ it("should not include type in the exported entry", () => {
+ const styling = {
+ vertices: [
+ {
+ type: createVertexType("User"),
+ color: "#1565C0",
+ },
+ ],
+ };
+ const result = userStylingToExportFormat(styling);
+
+ expect(result.vertices?.User).not.toHaveProperty("type");
+ });
+});
diff --git a/packages/graph-explorer/src/core/defaultStyling.ts b/packages/graph-explorer/src/core/defaultStyling.ts
new file mode 100644
index 000000000..22489623a
--- /dev/null
+++ b/packages/graph-explorer/src/core/defaultStyling.ts
@@ -0,0 +1,284 @@
+import { z } from "zod";
+
+import { logger } from "@/utils";
+import { lucideIconToDataUri } from "@/utils/lucideIconUrl";
+
+import type {
+ EdgePreferencesStorageModel,
+ UserStyling,
+ VertexPreferencesStorageModel,
+} from "./StateProvider/userPreferences";
+
+import { createEdgeType, createVertexType } from "./entities";
+
+/** Zod schema for a single vertex style entry in defaultStyling.json. */
+const VertexStyleSchema = z
+ .object({
+ /** Lucide icon name (kebab-case). Resolved to an SVG data URI at runtime. */
+ icon: z.string().optional(),
+ /** Custom icon URL or base64 data URI. Takes precedence over `icon`. */
+ iconUrl: z.string().optional(),
+ /** MIME type for custom icon (e.g., "image/svg+xml", "image/png"). */
+ iconImageType: z.string().optional(),
+ /** Hex color for the vertex (e.g., "#1565C0"). */
+ color: z.string().optional(),
+ /** Display label override. */
+ displayLabel: z.string().optional(),
+ /** Which vertex attribute to use as the display name. */
+ displayNameAttribute: z.string().optional(),
+ /** Which vertex attribute to use as the description. */
+ longDisplayNameAttribute: z.string().optional(),
+ /** Node shape. */
+ shape: z
+ .enum([
+ "rectangle",
+ "roundrectangle",
+ "ellipse",
+ "triangle",
+ "pentagon",
+ "hexagon",
+ "heptagon",
+ "octagon",
+ "star",
+ "barrel",
+ "diamond",
+ "vee",
+ "rhomboid",
+ "tag",
+ "round-rectangle",
+ "round-triangle",
+ "round-diamond",
+ "round-pentagon",
+ "round-hexagon",
+ "round-heptagon",
+ "round-octagon",
+ "round-tag",
+ "cut-rectangle",
+ "concave-hexagon",
+ ])
+ .optional(),
+ /** Background opacity (0-1). */
+ backgroundOpacity: z.number().min(0).max(1).optional(),
+ /** Border width in pixels. */
+ borderWidth: z.number().min(0).optional(),
+ /** Hex color for the border. */
+ borderColor: z.string().optional(),
+ /** Border line style. */
+ borderStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
+ })
+ .strict();
+
+/** Zod schema for a single edge style entry in defaultStyling.json. */
+const EdgeStyleSchema = z
+ .object({
+ /** Display label override. */
+ displayLabel: z.string().optional(),
+ /** Which edge attribute to use as the display name. */
+ displayNameAttribute: z.string().optional(),
+ /** Hex color for edge label background. */
+ labelColor: z.string().optional(),
+ /** Label background opacity (0-1). */
+ labelBackgroundOpacity: z.number().min(0).max(1).optional(),
+ /** Hex color for label border. */
+ labelBorderColor: z.string().optional(),
+ /** Label border style. */
+ labelBorderStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
+ /** Label border width in pixels. */
+ labelBorderWidth: z.number().min(0).optional(),
+ /** Hex color for the edge line. */
+ lineColor: z.string().optional(),
+ /** Edge line thickness in pixels. */
+ lineThickness: z.number().min(0).optional(),
+ /** Edge line style. */
+ lineStyle: z.enum(["solid", "dashed", "dotted"]).optional(),
+ /** Arrow style at the source end. */
+ sourceArrowStyle: z
+ .enum([
+ "triangle",
+ "triangle-tee",
+ "circle-triangle",
+ "triangle-cross",
+ "triangle-backcurve",
+ "tee",
+ "vee",
+ "square",
+ "circle",
+ "diamond",
+ "none",
+ ])
+ .optional(),
+ /** Arrow style at the target end. */
+ targetArrowStyle: z
+ .enum([
+ "triangle",
+ "triangle-tee",
+ "circle-triangle",
+ "triangle-cross",
+ "triangle-backcurve",
+ "tee",
+ "vee",
+ "square",
+ "circle",
+ "diamond",
+ "none",
+ ])
+ .optional(),
+ })
+ .strict();
+
+/** Zod schema for the complete defaultStyling.json file. */
+export const DefaultStylingSchema = z
+ .object({
+ vertices: z.record(z.string(), VertexStyleSchema).optional(),
+ edges: z.record(z.string(), EdgeStyleSchema).optional(),
+ })
+ .strict();
+
+export type DefaultStylingData = z.infer;
+
+/**
+ * Fetches the default styling configuration from the server.
+ * Returns null if no file is mounted (404) or if the data is invalid.
+ */
+export async function fetchDefaultStyling(): Promise {
+ const defaultStylingPath = `${location.origin}/defaultStyling`;
+
+ try {
+ logger.debug("Fetching default styling from", defaultStylingPath);
+ const response = await fetch(defaultStylingPath);
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ logger.debug("No default styling file found");
+ } else {
+ logger.warn(
+ `Response status ${response.status} for default styling`,
+ await response.text(),
+ );
+ }
+ return null;
+ }
+
+ const data = await response.json();
+ return parseDefaultStyling(data);
+ } catch (error) {
+ logger.warn("Failed to fetch default styling", error);
+ return null;
+ }
+}
+
+/**
+ * Parses and validates default styling data from any source (server fetch or file import).
+ * Returns null if the data is invalid.
+ */
+export function parseDefaultStyling(data: unknown): DefaultStylingData | null {
+ const result = DefaultStylingSchema.safeParse(data);
+
+ if (!result.success) {
+ logger.warn("Failed to parse default styling data", result.error.flatten());
+ return null;
+ }
+
+ return result.data;
+}
+
+/**
+ * Resolves a DefaultStylingData object into a UserStyling object.
+ *
+ * This converts lucide icon names to data URIs and maps the record-based
+ * format (keyed by type name) into the array-based format used by UserStyling.
+ */
+export async function resolveDefaultStyling(
+ data: DefaultStylingData,
+): Promise {
+ const vertices: VertexPreferencesStorageModel[] = [];
+ const edges: EdgePreferencesStorageModel[] = [];
+
+ if (data.vertices) {
+ for (const [typeName, style] of Object.entries(data.vertices)) {
+ const resolved: VertexPreferencesStorageModel = {
+ type: createVertexType(typeName),
+ };
+
+ // Resolve lucide icon name to data URI if no explicit iconUrl is provided
+ if (style.icon && !style.iconUrl) {
+ const dataUri = await lucideIconToDataUri(style.icon);
+ if (dataUri) {
+ resolved.iconUrl = dataUri;
+ resolved.iconImageType = "image/svg+xml";
+ } else {
+ logger.warn(
+ `Unknown lucide icon name "${style.icon}" for vertex type "${typeName}"`,
+ );
+ }
+ }
+
+ // Copy over all other defined properties (using !== undefined to
+ // preserve valid falsy values like 0 for borderWidth)
+ if (style.iconUrl !== undefined) resolved.iconUrl = style.iconUrl;
+ if (style.iconImageType !== undefined)
+ resolved.iconImageType = style.iconImageType;
+ if (style.color !== undefined) resolved.color = style.color;
+ if (style.displayLabel !== undefined)
+ resolved.displayLabel = style.displayLabel;
+ if (style.displayNameAttribute !== undefined)
+ resolved.displayNameAttribute = style.displayNameAttribute;
+ if (style.longDisplayNameAttribute !== undefined)
+ resolved.longDisplayNameAttribute = style.longDisplayNameAttribute;
+ if (style.shape !== undefined) resolved.shape = style.shape;
+ if (style.backgroundOpacity !== undefined)
+ resolved.backgroundOpacity = style.backgroundOpacity;
+ if (style.borderWidth !== undefined)
+ resolved.borderWidth = style.borderWidth;
+ if (style.borderColor !== undefined)
+ resolved.borderColor = style.borderColor;
+ if (style.borderStyle !== undefined)
+ resolved.borderStyle = style.borderStyle;
+
+ vertices.push(resolved);
+ }
+ }
+
+ if (data.edges) {
+ for (const [typeName, style] of Object.entries(data.edges)) {
+ const resolved: EdgePreferencesStorageModel = {
+ type: createEdgeType(typeName),
+ ...style,
+ };
+ edges.push(resolved);
+ }
+ }
+
+ return { vertices, edges };
+}
+
+/**
+ * Converts a UserStyling object back to the DefaultStylingData format
+ * suitable for export to a JSON file.
+ *
+ * Note: Lucide icon names cannot be reverse-resolved from data URIs,
+ * so exported files will contain iconUrl data URIs instead of icon names.
+ */
+export function userStylingToExportFormat(
+ styling: UserStyling,
+): DefaultStylingData {
+ const result: DefaultStylingData = {};
+
+ if (styling.vertices?.length) {
+ result.vertices = {};
+ for (const vertex of styling.vertices) {
+ const { type, ...rest } = vertex;
+ result.vertices[type] = rest;
+ }
+ }
+
+ if (styling.edges?.length) {
+ result.edges = {};
+ for (const edge of styling.edges) {
+ const { type, ...rest } = edge;
+ result.edges[type] = rest;
+ }
+ }
+
+ return result;
+}
diff --git a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx
index 78b47951d..7c7239662 100644
--- a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx
+++ b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx
@@ -1,10 +1,18 @@
-import { useAtom } from "jotai";
+import { useAtom, useAtomValue, useSetAtom } from "jotai";
import localforage from "localforage";
-import { SaveAllIcon } from "lucide-react";
+import {
+ DownloadIcon,
+ RotateCcwIcon,
+ SaveAllIcon,
+ UploadIcon,
+} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
+import { useCallback } from "react";
+import { toast } from "sonner";
import {
Button,
+ FileButton,
FormItem,
ImportantBlock,
Input,
@@ -21,11 +29,15 @@ import {
allowLoggingDbQueryAtom,
defaultNeighborExpansionLimitAtom,
defaultNeighborExpansionLimitEnabledAtom,
+ defaultStylingAtom,
showDebugActionsAtom,
+ userStylingAtom,
} from "@/core";
import { saveLocalForageToFile } from "@/core/StateProvider/localDb";
import LoadConfigButton from "./LoadConfigButton";
+import { useExportStylingFile } from "./useExportStylingFile";
+import { useImportStylingFile } from "./useImportStylingFile";
export default function SettingsGeneral() {
const [isDebugOptionsEnabled, setIsDebugOptionsEnabled] =
@@ -43,6 +55,22 @@ export default function SettingsGeneral() {
setDefaultNeighborExpansionLimitEnabled,
] = useAtom(defaultNeighborExpansionLimitEnabledAtom);
+ const exportStyling = useExportStylingFile();
+ const importStyling = useImportStylingFile();
+ const defaultStyling = useAtomValue(defaultStylingAtom);
+ const setUserStyling = useSetAtom(userStylingAtom);
+
+ const resetAllStyling = useCallback(() => {
+ if (defaultStyling) {
+ setUserStyling(defaultStyling);
+ } else {
+ setUserStyling({});
+ }
+ toast.success("Styling Reset", {
+ description: "All styling has been reset to defaults",
+ });
+ }, [defaultStyling, setUserStyling]);
+
return (
General Settings
@@ -106,6 +134,52 @@ export default function SettingsGeneral() {
+
+
+
+
+ {
+ if (file) {
+ void importStyling(file);
+ }
+ }}
+ >
+
+ Import
+
+
+
+
+
+
+
+ Importing styling will replace your current node and edge styling.
+ Resetting will revert all types to their default appearance.
+
+
+
({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("useExportStylingFile", () => {
+ it("should export current styling as JSON file", async () => {
+ const saveSpy = vi.spyOn(fileData, "saveFile").mockResolvedValue(undefined);
+
+ const state = new DbState();
+ state.activeStyling = {
+ vertices: [{ type: createVertexType("User"), color: "#1565C0" }],
+ edges: [{ type: createEdgeType("OWNS"), lineColor: "#2E7D32" }],
+ };
+
+ const { result } = renderHookWithState(() => useExportStylingFile(), state);
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(saveSpy).toHaveBeenCalledWith(
+ expect.any(Blob),
+ "defaultStyling.json",
+ );
+
+ // Verify the blob content
+ const blob = saveSpy.mock.calls[0][0];
+ const text = await blob.text();
+ const parsed = JSON.parse(text);
+ expect(parsed.vertices?.User).toEqual({ color: "#1565C0" });
+ expect(parsed.edges?.OWNS).toEqual({ lineColor: "#2E7D32" });
+
+ saveSpy.mockRestore();
+ });
+
+ it("should export empty styling when no customizations", async () => {
+ const saveSpy = vi.spyOn(fileData, "saveFile").mockResolvedValue(undefined);
+
+ const state = new DbState();
+ state.activeStyling = {};
+ const { result } = renderHookWithState(() => useExportStylingFile(), state);
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(saveSpy).toHaveBeenCalled();
+
+ const blob = saveSpy.mock.calls[0][0];
+ const text = await blob.text();
+ const parsed = JSON.parse(text);
+ expect(parsed).toEqual({});
+
+ saveSpy.mockRestore();
+ });
+
+ it("should show error toast when save fails", async () => {
+ const saveSpy = vi
+ .spyOn(fileData, "saveFile")
+ .mockRejectedValue(new Error("Save failed"));
+
+ const state = new DbState();
+ const { result } = renderHookWithState(() => useExportStylingFile(), state);
+
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(toast.error).toHaveBeenCalledWith(
+ "Export Failed",
+ expect.anything(),
+ );
+
+ saveSpy.mockRestore();
+ });
+});
diff --git a/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts
new file mode 100644
index 000000000..d8151a5dc
--- /dev/null
+++ b/packages/graph-explorer/src/routes/Settings/useExportStylingFile.ts
@@ -0,0 +1,25 @@
+import { useAtomValue } from "jotai";
+import { useCallback } from "react";
+import { toast } from "sonner";
+
+import { userStylingToExportFormat } from "@/core/defaultStyling";
+import { userStylingAtom } from "@/core/StateProvider";
+import { logger } from "@/utils";
+import { saveFile, toJsonFileData } from "@/utils/fileData";
+
+export function useExportStylingFile() {
+ const userStyling = useAtomValue(userStylingAtom);
+
+ return useCallback(async () => {
+ try {
+ const exportData = userStylingToExportFormat(userStyling);
+ const blob = toJsonFileData(exportData);
+ await saveFile(blob, "defaultStyling.json");
+ } catch (error) {
+ logger.warn("Failed to export styling file", error);
+ toast.error("Export Failed", {
+ description: "Could not save the styling file",
+ });
+ }
+ }, [userStyling]);
+}
diff --git a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx
new file mode 100644
index 000000000..463897de9
--- /dev/null
+++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx
@@ -0,0 +1,125 @@
+import { act } from "@testing-library/react";
+import { toast } from "sonner";
+
+import { defaultStylingAtom, getAppStore, userStylingAtom } from "@/core";
+import { DbState, renderHookWithState } from "@/utils/testing";
+
+import { useImportStylingFile } from "./useImportStylingFile";
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+describe("useImportStylingFile", () => {
+ it("should import valid styling file and update default reference", async () => {
+ const state = new DbState();
+ state.activeStyling = {};
+ const { result } = renderHookWithState(() => useImportStylingFile(), state);
+
+ const validStyling = {
+ vertices: { User: { color: "#1565C0" } },
+ edges: { OWNS: { lineColor: "#2E7D32" } },
+ };
+
+ const file = new File([JSON.stringify(validStyling)], "styling.json", {
+ type: "application/json",
+ });
+
+ await act(async () => {
+ await result.current(file);
+ });
+
+ const store = getAppStore();
+ const styling = store.get(userStylingAtom);
+ expect(styling.vertices?.find(v => v.type === "User")?.color).toBe(
+ "#1565C0",
+ );
+ expect(styling.edges?.find(e => e.type === "OWNS")?.lineColor).toBe(
+ "#2E7D32",
+ );
+
+ // defaultStylingAtom should be updated so reset works
+ const defaults = store.get(defaultStylingAtom);
+ expect(defaults?.vertices?.find(v => v.type === "User")?.color).toBe(
+ "#1565C0",
+ );
+
+ expect(toast.success).toHaveBeenCalled();
+ });
+
+ it("should show error for invalid styling file", async () => {
+ const state = new DbState();
+ const { result } = renderHookWithState(() => useImportStylingFile(), state);
+
+ const invalidStyling = { vertices: "not-an-object" };
+ const file = new File([JSON.stringify(invalidStyling)], "bad.json", {
+ type: "application/json",
+ });
+
+ await act(async () => {
+ await result.current(file);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith("Invalid File", expect.anything());
+ });
+
+ it("should show error for non-JSON file", async () => {
+ const state = new DbState();
+ const { result } = renderHookWithState(() => useImportStylingFile(), state);
+
+ const file = new File(["not json"], "bad.txt", {
+ type: "text/plain",
+ });
+
+ await act(async () => {
+ await result.current(file);
+ });
+
+ expect(toast.error).toHaveBeenCalledWith(
+ "Import Failed",
+ expect.anything(),
+ );
+ });
+
+ it("should resolve lucide icon names during import", async () => {
+ const state = new DbState();
+ state.activeStyling = {};
+ const { result } = renderHookWithState(() => useImportStylingFile(), state);
+
+ const styling = {
+ vertices: { User: { icon: "user", color: "#1565C0" } },
+ };
+
+ const file = new File([JSON.stringify(styling)], "styling.json", {
+ type: "application/json",
+ });
+
+ await act(async () => {
+ await result.current(file);
+ });
+
+ const store = getAppStore();
+ const imported = store.get(userStylingAtom);
+ expect(imported.vertices?.find(v => v.type === "User")?.iconUrl).toMatch(
+ /^data:image\/svg\+xml;base64,/,
+ );
+ });
+
+ it("should import empty styling file", async () => {
+ const state = new DbState();
+ const { result } = renderHookWithState(() => useImportStylingFile(), state);
+
+ const file = new File([JSON.stringify({})], "empty.json", {
+ type: "application/json",
+ });
+
+ await act(async () => {
+ await result.current(file);
+ });
+
+ expect(toast.success).toHaveBeenCalled();
+ });
+});
diff --git a/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts
new file mode 100644
index 000000000..c79a1fefe
--- /dev/null
+++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.ts
@@ -0,0 +1,52 @@
+import { useSetAtom } from "jotai";
+import { useCallback } from "react";
+import { toast } from "sonner";
+
+import {
+ parseDefaultStyling,
+ resolveDefaultStyling,
+} from "@/core/defaultStyling";
+import { defaultStylingAtom, userStylingAtom } from "@/core/StateProvider";
+import { logger } from "@/utils";
+import { fromFileToJson } from "@/utils/fileData";
+
+export function useImportStylingFile() {
+ const setUserStyling = useSetAtom(userStylingAtom);
+ const setDefaultStyling = useSetAtom(defaultStylingAtom);
+
+ return useCallback(
+ async (file: File) => {
+ try {
+ const fileContent = await fromFileToJson(file);
+ const parsed = parseDefaultStyling(fileContent);
+
+ if (!parsed) {
+ toast.error("Invalid File", {
+ description: "The styling file is not valid",
+ });
+ return;
+ }
+
+ const resolved = await resolveDefaultStyling(parsed);
+
+ // Update the reset reference so "Reset to Default" uses the
+ // imported values
+ setDefaultStyling(resolved);
+
+ // Replace user styling with the imported values — an explicit import
+ // should override existing styling
+ setUserStyling(resolved);
+
+ toast.success("Styling Imported", {
+ description: "Styling has been applied successfully",
+ });
+ } catch (error) {
+ logger.warn("Failed to import styling file", error);
+ toast.error("Import Failed", {
+ description: "Could not read the styling file",
+ });
+ }
+ },
+ [setUserStyling, setDefaultStyling],
+ );
+}
diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.test.ts b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts
new file mode 100644
index 000000000..1020e9362
--- /dev/null
+++ b/packages/graph-explorer/src/utils/lucideIconUrl.test.ts
@@ -0,0 +1,80 @@
+import type dynamicIconImports from "lucide-react/dynamicIconImports";
+
+import { lucideIconToDataUri } from "./lucideIconUrl";
+
+vi.mock("lucide-react/dynamicIconImports", async () => {
+ const actual = await vi.importActual("lucide-react/dynamicIconImports");
+ return {
+ ...actual,
+ default: {
+ ...(actual as { default: typeof dynamicIconImports }).default,
+ // Icon that loads but has no __iconNode
+ "missing-icon-node": () => Promise.resolve({ default: {} }),
+ // Icon that throws on import
+ "throwing-icon": () => Promise.reject(new Error("import failed")),
+ },
+ };
+});
+
+describe("lucideIconToDataUri", () => {
+ it("should return a data URI for a valid icon name", async () => {
+ const result = await lucideIconToDataUri("user");
+ expect(result).not.toBeNull();
+ expect(result).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("should return a valid SVG when decoded", async () => {
+ const result = await lucideIconToDataUri("user");
+ expect(result).not.toBeNull();
+
+ const base64 = result!.replace("data:image/svg+xml;base64,", "");
+ const svg = atob(base64);
+ expect(svg).toContain("