diff --git a/Changelog.md b/Changelog.md index d6c14e039..7eb222fd3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,11 @@ # Graph Explorer Change Log +## Unreleased + +- Add defaultStyling.json support for persistent per-type vertex and edge + styling (#1265, #112, #173, #573, #689) +- Add Lucide icon picker to node styling dialog + ## Release 3.0.2 This patch release fixes a bug where schema sync did not automatically trigger when switching connections and adds a new configuration option for controlling allowed origins. diff --git a/docs/references/README.md b/docs/references/README.md index 7cb72f4c4..e6bb029c1 100644 --- a/docs/references/README.md +++ b/docs/references/README.md @@ -7,3 +7,5 @@ Technical reference documentation for Graph Explorer covering configuration, sec - [Security](./security.md) - HTTPS connections, certificates, and permissions - [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..ebcce86b8 --- /dev/null +++ b/docs/references/default-styling.md @@ -0,0 +1,165 @@ +# 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 three ways to set vertex icons: + +- **Icon Picker (UI)** — In the Node Style dialog, click **Browse** to search + and select from the full Lucide icon library (~1,900 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/app.ts b/packages/graph-explorer-proxy-server/src/app.ts index 341a87c04..4939952b0 100644 --- a/packages/graph-explorer-proxy-server/src/app.ts +++ b/packages/graph-explorer-proxy-server/src/app.ts @@ -98,6 +98,14 @@ export function createApp({ "/defaultConnection", express.static(path.join(configPath, "defaultConnection.json")), ); + app.use( + "/defaultStyling", + express.static(path.join(configPath, "defaultStyling.json")), + ); + app.use( + "/custom-icons", + express.static(path.join(configPath, "custom-icons")), + ); // Host the Graph Explorer UI static files app.use(staticFilesVirtualPath, express.static(staticFilesPath)); diff --git a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx index e27572690..cf7395901 100644 --- a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx +++ b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx @@ -94,6 +94,7 @@ const useInitCytoscape = ({ }, 100); cy.on("pan", debouncedPan); + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: tracking external Cytoscape instance setCy(cy); return () => { @@ -101,6 +102,7 @@ const useInitCytoscape = ({ (cy as any).removeAllListeners(); cy.destroy(); cy.unmount(); + setCy(undefined); }; } diff --git a/packages/graph-explorer/src/components/IconPicker.test.tsx b/packages/graph-explorer/src/components/IconPicker.test.tsx new file mode 100644 index 000000000..6babd1d37 --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { IconPicker } from "./IconPicker"; + +describe("IconPicker", () => { + it("should render Browse button", () => { + render(); + expect(screen.getByRole("button", { name: /browse/i })).toBeInTheDocument(); + }); + + it("should open popover with search input on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + expect(screen.getByPlaceholderText("Search icons...")).toBeInTheDocument(); + }); + + it("should show icons in the grid", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for at least some icon buttons to appear in the grid + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should filter icons when searching", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "user"); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title.includes("user")); + expect(iconButtons.length).toBeGreaterThan(0); + }); + }); + + it("should show no results message for invalid search", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + const searchInput = screen.getByPlaceholderText("Search icons..."); + + await user.type(searchInput, "zzzznotanicon"); + + expect(screen.getByText("No icons found")).toBeInTheDocument(); + }); + + it("should call onSelect with data URI when icon is clicked", async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + // Wait for icons to load then click the first one + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith( + expect.stringMatching(/^data:image\/svg\+xml;base64,/), + "image/svg+xml", + ); + }); + }); + + it("should close popover after selecting an icon", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /browse/i })); + + await waitFor(() => { + const iconButtons = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== ""); + expect(iconButtons.length).toBeGreaterThan(0); + }); + + const firstIcon = screen + .getAllByRole("button") + .filter(btn => btn.title && btn.title !== "")[0]; + await user.click(firstIcon); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText("Search icons..."), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/graph-explorer/src/components/IconPicker.tsx b/packages/graph-explorer/src/components/IconPicker.tsx new file mode 100644 index 000000000..f8b6f852c --- /dev/null +++ b/packages/graph-explorer/src/components/IconPicker.tsx @@ -0,0 +1,135 @@ +import { SearchIcon } from "lucide-react"; +import dynamicIconImports from "lucide-react/dynamicIconImports"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { lucideIconToDataUri } from "@/utils/lucideIconUrl"; + +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "."; + +const allIconNames = Object.keys(dynamicIconImports).sort(); + +const MAX_VISIBLE = 50; + +export function IconPicker({ + onSelect, +}: { + onSelect: (iconUrl: string, iconImageType: string) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + if (!search) return allIconNames.slice(0, MAX_VISIBLE); + const lower = search.toLowerCase(); + const results: string[] = []; + for (const name of allIconNames) { + if (name.includes(lower)) { + results.push(name); + if (results.length >= MAX_VISIBLE) break; + } + } + return results; + }, [search]); + + const handleSelect = useCallback( + async (iconName: string) => { + const dataUri = await lucideIconToDataUri(iconName); + if (dataUri) { + onSelect(dataUri, "image/svg+xml"); + setOpen(false); + setSearch(""); + } + }, + [onSelect], + ); + + // Focus search input when popover opens + useEffect(() => { + if (open) { + // Small delay to allow popover animation + const timer = setTimeout(() => inputRef.current?.focus(), 100); + return () => clearTimeout(timer); + } + }, [open]); + + return ( + + + + + + setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ {filtered.map(name => ( + + ))} + {filtered.length === 0 && ( +

+ No icons found +

+ )} +
+ {!search && ( +

+ Showing {MAX_VISIBLE} of {allIconNames.length} icons. Type to + search. +

+ )} +
+
+ ); +} + +function IconButton({ + name, + onSelect, +}: { + name: string; + onSelect: (name: string) => void; +}) { + const [src, setSrc] = useState(null); + + useEffect(() => { + let cancelled = false; + lucideIconToDataUri(name).then( + uri => { + if (!cancelled && uri) setSrc(uri); + }, + () => { + // Icon failed to load, leave as placeholder + }, + ); + return () => { + cancelled = true; + }; + }, [name]); + + return ( + + ); +} diff --git a/packages/graph-explorer/src/components/Tabular/Tabular.tsx b/packages/graph-explorer/src/components/Tabular/Tabular.tsx index f708f0998..3ac63ee92 100644 --- a/packages/graph-explorer/src/components/Tabular/Tabular.tsx +++ b/packages/graph-explorer/src/components/Tabular/Tabular.tsx @@ -148,11 +148,13 @@ const TabularContent = ({ useEffect(() => { if (!headerControlsRef.current || headerControlsPosition !== "sticky") { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: DOM measurement requires setState in effect setStickyHeaderTop(0); return; } const { height } = headerControlsRef.current.getBoundingClientRect(); + setStickyHeaderTop(height); // headerControlsChildren can affect to the container's height }, [headerControlsRef, headerControlsPosition]); diff --git a/packages/graph-explorer/src/components/Tabular/useTabular.ts b/packages/graph-explorer/src/components/Tabular/useTabular.ts index 916c5f60e..f2827658c 100644 --- a/packages/graph-explorer/src/components/Tabular/useTabular.ts +++ b/packages/graph-explorer/src/components/Tabular/useTabular.ts @@ -326,7 +326,7 @@ export const useTabular = (options: TabularOptions) => { // Avoid table to delete filter on re-render useEffect(() => { skipPageResetRef.current = true; - // eslint-disable-next-line react-hooks/set-state-in-effect -- react-table v7 pattern to prevent auto-reset on data changes + setUpdatedData(data); }, [data]); @@ -424,7 +424,7 @@ export const useTabular = (options: TabularOptions) => { }, stateReducer, useControlledState, - /* eslint-disable react-hooks/refs -- react-table v7 requires reading refs during render to control auto-reset */ + autoResetPage: !skipPageResetRef.current, autoResetExpanded: !skipPageResetRef.current, autoResetGroupBy: !skipPageResetRef.current, @@ -432,7 +432,6 @@ export const useTabular = (options: TabularOptions) => { autoResetSortBy: !skipPageResetRef.current, autoResetFilters: !skipPageResetRef.current, autoResetRowState: !skipPageResetRef.current, - /* eslint-enable react-hooks/refs */ }, useColumnOrder, useResizeColumns, diff --git a/packages/graph-explorer/src/components/index.ts b/packages/graph-explorer/src/components/index.ts index 2bc9878eb..4343b7aec 100644 --- a/packages/graph-explorer/src/components/index.ts +++ b/packages/graph-explorer/src/components/index.ts @@ -36,6 +36,7 @@ export * from "./Form"; export * from "./numberFormat"; export * from "./icons"; +export * from "./IconPicker"; export * from "./Input"; export { default as InputField } from "./InputField"; 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 75ac8d23f..eee105a4e 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -261,6 +261,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 4811a4fbb..c5d9ae306 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -8,6 +8,7 @@ import { defaultEdgePreferences, defaultVertexPreferences, type EdgePreferencesStorageModel, + mergeDefaultsIntoUserStyling, useEdgeStyling, useVertexStyling, type VertexPreferencesStorageModel, @@ -340,6 +341,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..d1b07cb7b --- /dev/null +++ b/packages/graph-explorer/src/core/defaultStyling.test.ts @@ -0,0 +1,420 @@ +// @vitest-environment happy-dom +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/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 4dd74e54e..905a14764 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -10,6 +10,7 @@ import { FieldLabel, FieldSet, FileButton, + IconPicker, Input, Select, SelectContent, @@ -208,6 +209,11 @@ function Content({ vertexType }: { vertexType: VertexType }) { Icon
+ + setVertexStyle({ iconUrl, iconImageType }) + } + /> { diff --git a/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx b/packages/graph-explorer/src/routes/Settings/SettingsGeneral.tsx index 451011b7a..3322835a8 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,12 +29,16 @@ import { allowLoggingDbQueryAtom, defaultNeighborExpansionLimitAtom, defaultNeighborExpansionLimitEnabledAtom, + defaultStylingAtom, diagnosticLoggingAtom, 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] = @@ -48,6 +60,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 @@ -111,6 +139,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..5fc315872 --- /dev/null +++ b/packages/graph-explorer/src/routes/Settings/useImportStylingFile.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +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(""); + }); + + it("should return null for an unknown icon name", async () => { + const result = await lucideIconToDataUri("not-a-real-icon-name"); + expect(result).toBeNull(); + }); + + it("should return null for an empty string", async () => { + const result = await lucideIconToDataUri(""); + expect(result).toBeNull(); + }); + + it("should handle kebab-case icon names", async () => { + const result = await lucideIconToDataUri("log-in"); + expect(result).not.toBeNull(); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should produce different SVGs for different icons", async () => { + const user = await lucideIconToDataUri("user"); + const mail = await lucideIconToDataUri("mail"); + expect(user).not.toBeNull(); + expect(mail).not.toBeNull(); + expect(user).not.toEqual(mail); + }); + + it("should return null when module has no __iconNode", async () => { + const result = await lucideIconToDataUri("missing-icon-node"); + expect(result).toBeNull(); + }); + + it("should return null when dynamic import throws", async () => { + const result = await lucideIconToDataUri("throwing-icon"); + expect(result).toBeNull(); + }); + + it("should not include React key attributes in the SVG output", 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).not.toContain("key="); + }); +}); diff --git a/packages/graph-explorer/src/utils/lucideIconUrl.ts b/packages/graph-explorer/src/utils/lucideIconUrl.ts new file mode 100644 index 000000000..4feef4f22 --- /dev/null +++ b/packages/graph-explorer/src/utils/lucideIconUrl.ts @@ -0,0 +1,91 @@ +import dynamicIconImports from "lucide-react/dynamicIconImports"; + +type IconNodeChild = [string, Record]; + +interface LucideIconModule { + __iconNode?: IconNodeChild[]; + default?: unknown; +} + +/** + * Converts a lucide icon name to a base64-encoded SVG data URI. + * + * Icon names use kebab-case (e.g., "user", "log-in", "landmark"). + * See https://lucide.dev/icons for available icon names. + * + * @param iconName The kebab-case icon name from lucide. + * @returns A data URI string for the SVG icon, or null if the icon name is not found. + */ +export async function lucideIconToDataUri( + iconName: string, +): Promise { + const iconNode = await getLucideIconNode(iconName); + if (!iconNode) { + return null; + } + const svgString = buildSvgString(iconNode); + return `data:image/svg+xml;base64,${btoa(svgString)}`; +} + +const iconCache = new Map(); + +async function getLucideIconNode( + iconName: string, +): Promise { + if (iconCache.has(iconName)) { + return iconCache.get(iconName) ?? null; + } + + try { + // Use lucide-react's dynamicIconImports which provides Vite-compatible + // lazy loaders for each icon. Each module exports __iconNode as a named + // export containing SVG element data as [elementTag, attributes][] tuples. + const importFn = + dynamicIconImports[iconName as keyof typeof dynamicIconImports]; + if (!importFn) { + iconCache.set(iconName, null); + return null; + } + + const mod = (await importFn()) as LucideIconModule; + + if (!mod.__iconNode) { + iconCache.set(iconName, null); + return null; + } + + iconCache.set(iconName, mod.__iconNode); + return mod.__iconNode; + } catch { + iconCache.set(iconName, null); + return null; + } +} + +function escapeXmlAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildSvgString(nodes: IconNodeChild[]): string { + const children = nodes + .map(([tag, attrs]) => { + const attrStr = Object.entries(attrs) + .filter(([key]) => key !== "key") + .map(([key, value]) => `${key}="${escapeXmlAttr(value)}"`) + .join(" "); + return `<${tag} ${attrStr} />`; + }) + .join(""); + + return ( + `` + + `${children}` + ); +} diff --git a/packages/graph-explorer/src/utils/testing/DbState.ts b/packages/graph-explorer/src/utils/testing/DbState.ts index 52feadc3b..0fbdddc68 100644 --- a/packages/graph-explorer/src/utils/testing/DbState.ts +++ b/packages/graph-explorer/src/utils/testing/DbState.ts @@ -5,6 +5,7 @@ import { allGraphSessionsAtom, type AppStore, configurationAtom, + defaultStylingAtom, type Edge, type EdgeId, type EdgePreferencesStorageModel, @@ -15,6 +16,7 @@ import { explorerForTestingAtom, mapEdgeToTypeConfig, mapVertexToTypeConfigs, + mergeDefaultsIntoUserStyling, nodesAtom, nodesFilteredIdsAtom, nodesTypesFilteredAtom, @@ -49,6 +51,7 @@ export class DbState { private _activeSchema: SchemaStorageModel | null; activeConfig: RawConfiguration; activeStyling: UserStyling; + activeDefaultStyling: UserStyling | null = null; explorer: Explorer; @@ -168,6 +171,15 @@ export class DbState { return composedStyle; } + /** + * Sets the default styling (simulates a loaded defaultStyling.json). + * @param styling The default styling to use. + */ + setDefaultStyling(styling: UserStyling) { + this.activeDefaultStyling = styling; + return this; + } + /** * Adds a style configuration for the edge type to the user styling. * @param edgeType The type of the edge to add the style to. @@ -202,8 +214,16 @@ export class DbState { } store.set(activeConfigurationAtom, this.activeConfig.id); - // Styling - store.set(userStylingAtom, this.activeStyling); + // Styling — merge default styling into user styling (mirrors + // AppStatusLoader production behavior). + const mergedStyling = this.activeDefaultStyling + ? mergeDefaultsIntoUserStyling( + this.activeStyling, + this.activeDefaultStyling, + ) + : this.activeStyling; + store.set(userStylingAtom, mergedStyling); + store.set(defaultStylingAtom, this.activeDefaultStyling); // Vertices store.set(nodesAtom, toNodeMap(this.vertices));