diff --git a/modules/editable-layers/src/lib/layers/segments-layer.ts b/modules/editable-layers/src/lib/layers/segments-layer.ts index dcb77a45..79053546 100644 --- a/modules/editable-layers/src/lib/layers/segments-layer.ts +++ b/modules/editable-layers/src/lib/layers/segments-layer.ts @@ -6,7 +6,7 @@ import {ArrowStyles, DEFAULT_STYLE, MAX_ARROWS} from '../style'; import {NebulaLayer} from '../nebula-layer'; import {toDeckColor} from '../../utils/utils'; import {DeckCache} from '../deck-renderer/deck-cache'; -import {PathMarkerLayer} from '@deck.gl-community/layers'; +import {PathMarkerLayer} from '../../../../layers/src/path-marker-layer/path-marker-layer'; const NEBULA_TO_DECK_DIRECTIONS = { [ArrowStyles.NONE]: {forward: false, backward: false}, diff --git a/modules/graph-layers/package.json b/modules/graph-layers/package.json index 0133c21c..104d4e6e 100644 --- a/modules/graph-layers/package.json +++ b/modules/graph-layers/package.json @@ -55,6 +55,10 @@ "raf": "^3.4.1" }, "devDependencies": { - "ngraph.generators": "^20.1.0" + "ngraph.generators": "^20.1.0", + "zod": "^4.0.0" + }, + "peerDependencies": { + "zod": "^3.23.8 || ^4.0.0" } } diff --git a/modules/graph-layers/src/index.ts b/modules/graph-layers/src/index.ts index dc9c09cc..6a6fdd52 100644 --- a/modules/graph-layers/src/index.ts +++ b/modules/graph-layers/src/index.ts @@ -30,11 +30,14 @@ export {StyleEngine} from './style/style-engine'; export {GraphStyleEngine} from './style/graph-style-engine'; export type { GraphStylesheet, + GraphStylesheetInput, + GraphStylesheetParsed, GraphStyleAttributeReference, GraphStyleScale, GraphStyleScaleType, GraphStyleValue } from './style/graph-style-engine'; +export {GraphStylesheetSchema} from './style/graph-style-engine'; export { DEFAULT_GRAPH_LAYER_STYLESHEET, type GraphLayerStylesheet, diff --git a/modules/graph-layers/src/layers/graph-layer.ts b/modules/graph-layers/src/layers/graph-layer.ts index 5fce2348..76041009 100644 --- a/modules/graph-layers/src/layers/graph-layer.ts +++ b/modules/graph-layers/src/layers/graph-layer.ts @@ -270,11 +270,17 @@ export class GraphLayer extends CompositeLayer { const engine = this.state.graphEngine; const {edges: edgeStyles} = this._getResolvedStylesheet(); - if (!engine || !edgeStyles || edgeStyles.length === 0) { + if (!engine || !edgeStyles) { return []; } - return edgeStyles + const edgeStyleArray = Array.isArray(edgeStyles) ? edgeStyles : [edgeStyles]; + + if (edgeStyleArray.length === 0) { + return []; + } + + return edgeStyleArray .filter(Boolean) .flatMap((style, idx) => { const {decorators, data = (edges) => edges, visible = true, ...restEdgeStyle} = style; diff --git a/modules/graph-layers/src/style/graph-layer-stylesheet.ts b/modules/graph-layers/src/style/graph-layer-stylesheet.ts index f3dcc80b..e2570bbc 100644 --- a/modules/graph-layers/src/style/graph-layer-stylesheet.ts +++ b/modules/graph-layers/src/style/graph-layer-stylesheet.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import type {GraphStylesheet, GraphStyleValue, GraphStyleType} from './graph-style-engine'; +import type {GraphStylesheet, GraphStyleType} from './graph-style-engine'; export type GraphNodeStyleType = Exclude< GraphStyleType, @@ -35,13 +35,13 @@ export type GraphLayerStylesheet = { edges?: GraphLayerEdgeStyle | GraphLayerEdgeStyle[]; }; +export type GraphLayerStylesheetInput = GraphLayerStylesheet | null | undefined; + export type NormalizedGraphLayerStylesheet = { nodes: GraphLayerNodeStyle[]; edges: GraphLayerEdgeStyle[]; }; -export type GraphLayerStylesheetInput = GraphLayerStylesheet | null | undefined; - const DEFAULT_EDGE_STYLE: GraphLayerEdgeStyle = { type: 'edge', stroke: 'black', @@ -76,18 +76,18 @@ export function normalizeGraphLayerStylesheet({ ? resolvedNodeStyles.filter(Boolean) : [...DEFAULT_GRAPH_LAYER_STYLESHEET.nodes]; - const edgesArray = Array.isArray(resolvedEdgeStyles) + const edgeEntries = Array.isArray(resolvedEdgeStyles) ? resolvedEdgeStyles : resolvedEdgeStyles ? [resolvedEdgeStyles] : DEFAULT_GRAPH_LAYER_STYLESHEET.edges; - const edges = edgesArray + const edges: GraphLayerEdgeStyle[] = (edgeEntries) .filter(Boolean) .map((edgeStyleEntry) => ({ ...edgeStyleEntry, - type: ((edgeStyleEntry as GraphLayerEdgeStyle).type ?? 'edge') as EdgeStyleType, - decorators: (edgeStyleEntry as GraphLayerEdgeStyle).decorators ?? [] + type: ((edgeStyleEntry).type ?? 'edge'), + decorators: (edgeStyleEntry).decorators ?? [] })) as GraphLayerEdgeStyle[]; return { @@ -96,8 +96,4 @@ export function normalizeGraphLayerStylesheet({ }; } -export type { - GraphStyleValue, - GraphStylesheet, - GraphStyleType -} from './graph-style-engine'; +export type {GraphStyleValue, GraphStylesheet, GraphStyleType} from './graph-style-engine'; diff --git a/modules/graph-layers/src/style/graph-style-engine.ts b/modules/graph-layers/src/style/graph-style-engine.ts index 472b5f55..b142e242 100644 --- a/modules/graph-layers/src/style/graph-style-engine.ts +++ b/modules/graph-layers/src/style/graph-style-engine.ts @@ -2,55 +2,50 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import {StyleEngine, type DeckGLAccessorMap, type DeckGLUpdateTriggers} from './style-engine'; +import {ZodError} from 'zod'; + +/** Supported scale families for attribute references. * +export type GraphStyleScaleType -/** Supported scale families for attribute references. */ -export type GraphStyleScaleType = - | 'linear' | 'log' | 'pow' | 'sqrt' | 'quantize' | 'quantile' | 'ordinal'; +/** Configuration for attribute scale mapping. * +export type GraphStyleScale = -/** Configuration for attribute scale mapping. */ -export type GraphStyleScale = { - type?: GraphStyleScaleType; domain?: (number | string)[]; range?: any[]; clamp?: boolean; nice?: boolean | number; base?: number; exponent?: number; - unknown?: unknown; }; -/** Declares that a style property should derive its value from a graph attribute. */ -export type GraphStyleAttributeReference = - | `@${string}` +/** Declares that a style property should derive its value from a graph attribute. * +export type GraphStyleAttributeReference + | { attribute: string; fallback?: TValue; scale?: GraphStyleScale | ((value: unknown) => unknown); }; -/** Acceptable value for a single style state or accessor. */ -export type GraphStyleLeafValue = - | TValue +export type GraphStyleLeafValue + | GraphStyleAttributeReference | ((datum: unknown) => TValue); -/** Acceptable value for a style property, including optional interaction states. */ -export type GraphStyleValue = - | GraphStyleLeafValue - | {[state: string]: GraphStyleLeafValue}; +/** Acceptable value for a style property, including optional interaction states. * +export type GraphStyleValue + const COMMON_DECKGL_PROPS = { getOffset: 'offset', opacity: 'opacity' } as const; - const GRAPH_DECKGL_ACCESSOR_MAP = { circle: { ...COMMON_DECKGL_PROPS, @@ -164,6 +159,15 @@ export type GraphStylesheet< > = {type: TType} & GraphStylePropertyMap & Partial>>; +*/ + +import {StyleEngine, type DeckGLUpdateTriggers} from './style-engine'; +import { + GraphStylesheetSchema, + GRAPH_DECKGL_ACCESSOR_MAP, + type GraphStylesheet, + type GraphStylesheetParsed +} from './graph-stylesheet.schema'; const GRAPH_DECKGL_UPDATE_TRIGGERS: DeckGLUpdateTriggers = { circle: ['getFillColor', 'getRadius', 'getLineColor', 'getLineWidth'], @@ -179,12 +183,55 @@ const GRAPH_DECKGL_UPDATE_TRIGGERS: DeckGLUpdateTriggers = { arrow: ['getColor', 'getSize', 'getOffset'] }; +function formatStylesheetError(error: ZodError) { + const details = error.issues + .map((issue) => { + const path = issue.path.length ? issue.path.join('.') : 'root'; + return ` • ${path}: ${issue.message}`; + }) + .join('\n'); + return `Invalid graph stylesheet:\n${details}`; +} + export class GraphStyleEngine extends StyleEngine { constructor(style: GraphStylesheet, {stateUpdateTrigger}: {stateUpdateTrigger?: unknown} = {}) { - super(style, { + let parsedStyle: GraphStylesheetParsed; + try { + parsedStyle = GraphStylesheetSchema.parse(style); + } catch (error) { + if (error instanceof ZodError) { + throw new Error(formatStylesheetError(error)); + } + throw error; + } + + super(parsedStyle as GraphStylesheet, { deckglAccessorMap: GRAPH_DECKGL_ACCESSOR_MAP, deckglUpdateTriggers: GRAPH_DECKGL_UPDATE_TRIGGERS, stateUpdateTrigger }); } } + +export { + GraphStyleScaleTypeEnum, + GraphStyleScaleSchema, + GraphStyleAttributeReferenceSchema, + GraphStyleLeafValueSchema, + GraphStyleStateMapSchema, + GraphStyleValueSchema, + GraphStylesheetSchema +} from './graph-stylesheet.schema'; + +export type { + GraphStyleAttributeReference, + GraphStyleLeafValue, + GraphStyleScale, + GraphStyleScaleType, + GraphStyleSelector, + GraphStyleType, + GraphStyleValue, + GraphStylesheet, + GraphStylesheetInput, + GraphStylesheetParsed +} from './graph-stylesheet.schema'; diff --git a/modules/graph-layers/src/style/graph-stylesheet.schema.ts b/modules/graph-layers/src/style/graph-stylesheet.schema.ts new file mode 100644 index 00000000..4e567ad4 --- /dev/null +++ b/modules/graph-layers/src/style/graph-stylesheet.schema.ts @@ -0,0 +1,344 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/* eslint-disable no-continue */ + +import {z, type ZodTypeAny} from 'zod'; + +const GraphStylePrimitiveSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(z.union([z.string(), z.number(), z.boolean(), z.null()])) +]); + +const GraphStyleFunctionSchema = z.custom<(...args: unknown[]) => unknown>( + (value) => typeof value === 'function', + { + message: 'Style functions must be callable.' + } +); + +/** + * Supported scale identifiers for mapping data values to visual encodings. + */ +export const GraphStyleScaleTypeEnum = z.enum([ + 'linear', + 'log', + 'pow', + 'sqrt', + 'quantize', + 'quantile', + 'ordinal' +]); + +/** + * TypeScript union of {@link GraphStyleScaleTypeEnum} values. + */ +export type GraphStyleScaleType = z.infer; + +/** + * Configuration for data-driven style scaling. Supports deck.gl compatible numeric and + * categorical scaling with optional d3-scale like parameters. + */ +export const GraphStyleScaleSchema = z + .object({ + type: GraphStyleScaleTypeEnum.optional(), + domain: z.array(z.union([z.number(), z.string()])).optional(), + range: z.array(z.any()).optional(), + clamp: z.boolean().optional(), + nice: z.union([z.boolean(), z.number()]).optional(), + base: z.number().optional(), + exponent: z.number().optional(), + unknown: z.any().optional() + }) + .strict(); + +/** + * TypeScript view of {@link GraphStyleScaleSchema} after parsing. + */ +export type GraphStyleScale = z.infer; + +/** + * Reference to node/edge attributes, optionally including fallback values and scale + * configuration for data-driven styling. + */ +export const GraphStyleAttributeReferenceSchema = z.union([ + z + .string() + .regex(/^@.+/, 'Attribute reference strings must start with "@" and include an attribute name.'), + z + .object({ + attribute: z.string().min(1, 'Attribute name is required.'), + fallback: GraphStylePrimitiveSchema.optional(), + scale: z.union([GraphStyleScaleSchema, GraphStyleFunctionSchema]).optional() + }) + .strict() +]); + +/** + * Parsed value produced by {@link GraphStyleAttributeReferenceSchema}. + */ +export type GraphStyleAttributeReference = z.infer; + +/** + * Primitive value allowed in stylesheet definitions. Supports literal values, attribute + * references and imperative resolver functions. + */ +export const GraphStyleLeafValueSchema = z.union([ + GraphStylePrimitiveSchema, + GraphStyleAttributeReferenceSchema, + GraphStyleFunctionSchema +]); + +/** + * Union of literal, attribute-driven and functional style values. + */ +export type GraphStyleLeafValue = z.infer; + +const RESERVED_STATE_KEYS = new Set(['attribute', 'fallback', 'scale']); + +/** + * Mapping of interaction or application state keys to leaf style values. + */ +export const GraphStyleStateMapSchema = z.record( + z + .string() + .refine((key) => !RESERVED_STATE_KEYS.has(key), 'State overrides must not use reserved keys.'), + GraphStyleLeafValueSchema +); + +/** + * Style value that may be either a simple leaf value or a keyed map of overrides. + */ +export const GraphStyleValueSchema = z.union([ + GraphStyleLeafValueSchema, + GraphStyleStateMapSchema +]); + +/** + * Parsed style property value that may include state overrides. + */ +export type GraphStyleValue = z.infer; + +const COMMON_DECKGL_PROPS = { + getOffset: 'offset', + opacity: 'opacity' +} as const; + +/** + * Translation table between graph style properties and the underlying deck.gl accessors for + * each supported style primitive type. + */ +export const GRAPH_DECKGL_ACCESSOR_MAP = { + circle: { + ...COMMON_DECKGL_PROPS, + getFillColor: 'fill', + getLineColor: 'stroke', + getLineWidth: 'strokeWidth', + getRadius: 'radius' + }, + + rectangle: { + ...COMMON_DECKGL_PROPS, + getWidth: 'width', + getHeight: 'height', + getFillColor: 'fill', + getLineColor: 'stroke', + getLineWidth: 'strokeWidth' + }, + + 'rounded-rectangle': { + ...COMMON_DECKGL_PROPS, + getCornerRadius: 'cornerRadius', + getRadius: 'radius', + getWidth: 'width', + getHeight: 'height', + getFillColor: 'fill', + getLineColor: 'stroke', + getLineWidth: 'strokeWidth' + }, + + 'path-rounded-rectangle': { + ...COMMON_DECKGL_PROPS, + getWidth: 'width', + getHeight: 'height', + getFillColor: 'fill', + getLineColor: 'stroke', + getLineWidth: 'strokeWidth', + getCornerRadius: 'cornerRadius' + }, + + label: { + ...COMMON_DECKGL_PROPS, + getColor: 'color', + getText: 'text', + getSize: 'fontSize', + getTextAnchor: 'textAnchor', + getAlignmentBaseline: 'alignmentBaseline', + getAngle: 'angle', + scaleWithZoom: 'scaleWithZoom', + textMaxWidth: 'textMaxWidth', + textWordBreak: 'textWordBreak', + textSizeMinPixels: 'textSizeMinPixels' + }, + + marker: { + ...COMMON_DECKGL_PROPS, + getColor: 'fill', + getSize: 'size', + getMarker: 'marker', + scaleWithZoom: 'scaleWithZoom' + }, + + Edge: { + getColor: 'stroke', + getWidth: 'strokeWidth' + }, + edge: { + getColor: 'stroke', + getWidth: 'strokeWidth' + }, + 'edge-label': { + getColor: 'color', + getText: 'text', + getSize: 'fontSize', + getTextAnchor: 'textAnchor', + getAlignmentBaseline: 'alignmentBaseline', + scaleWithZoom: 'scaleWithZoom', + textMaxWidth: 'textMaxWidth', + textWordBreak: 'textWordBreak', + textSizeMinPixels: 'textSizeMinPixels' + }, + flow: { + getColor: 'color', + getWidth: 'width', + getSpeed: 'speed', + getTailLength: 'tailLength' + }, + arrow: { + getColor: 'color', + getSize: 'size', + getOffset: 'offset' + } +} as const; + +/** + * Supported graph style primitive identifiers (e.g. `circle`, `edge`). + */ +export type GraphStyleType = keyof typeof GRAPH_DECKGL_ACCESSOR_MAP; + +/** + * CSS-like pseudo selector supported by the stylesheet for state overrides. + */ +export type GraphStyleSelector = `:${string}`; + +type GraphStylePropertyKey = Extract< + (typeof GRAPH_DECKGL_ACCESSOR_MAP)[TType][keyof (typeof GRAPH_DECKGL_ACCESSOR_MAP)[TType]], + PropertyKey +>; + +type GraphStyleStatefulValue = TValue | {[state: string]: TValue}; + +type GraphStylePropertyMap = Partial< + Record, GraphStyleStatefulValue> +>; + +/** + * Typed representation of a stylesheet definition for a specific graph primitive. + */ +export type GraphStylesheet< + TType extends GraphStyleType = GraphStyleType, + TValue = GraphStyleLeafValue +> = {type: TType} & + GraphStylePropertyMap & + Partial>>; + +const GraphStyleSelectorKeySchema = z.string().regex(/^:[^\s]+/, 'Selectors must start with ":".'); + +function createPropertiesSchema(keys: readonly string[]) { + const shape = keys.reduce>((acc, key) => { + acc[key] = GraphStyleValueSchema.optional(); + return acc; + }, {}); + return z.object(shape).partial().strict(); +} + +const GraphStylesheetVariants = ( + Object.entries(GRAPH_DECKGL_ACCESSOR_MAP) as Array< + [GraphStyleType, (typeof GRAPH_DECKGL_ACCESSOR_MAP)[GraphStyleType]] + > +).map(([type, accessors]) => { + const propertyKeys = Object.values(accessors); + const propertyKeySet = new Set(propertyKeys); + const propertiesSchema = createPropertiesSchema(propertyKeys); + const baseShape: Record = { + type: z.literal(type) + }; + for (const key of propertyKeys) { + baseShape[key] = GraphStyleValueSchema.optional(); + } + + return z + .object(baseShape) + .catchall(z.unknown()) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (key === 'type') { + continue; + } + if (propertyKeySet.has(key)) { + continue; + } + if (!key.startsWith(':')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: `Unknown style property "${key}".` + }); + continue; + } + if (!GraphStyleSelectorKeySchema.safeParse(key).success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: 'Selectors must start with ":".' + }); + continue; + } + const selectorResult = propertiesSchema.safeParse(value[key]); + if (!selectorResult.success) { + for (const issue of selectorResult.error.issues) { + ctx.addIssue({ + ...issue, + path: [key, ...(issue.path ?? [])] + }); + } + } + } + }); +}); + +type GraphStylesheetVariantSchema = (typeof GraphStylesheetVariants)[number]; + +/** + * Schema that validates stylesheet definitions for all graph style primitives. + */ +export const GraphStylesheetSchema = z.discriminatedUnion( + 'type', + GraphStylesheetVariants as [ + GraphStylesheetVariantSchema, + ...GraphStylesheetVariantSchema[] + ] +); + +/** + * Runtime type accepted by {@link GraphStylesheetSchema} before validation. + */ +export type GraphStylesheetInput = z.input; +/** + * Type returned by {@link GraphStylesheetSchema} after successful parsing. + */ +export type GraphStylesheetParsed = z.infer; diff --git a/modules/graph-layers/src/style/style-property.ts b/modules/graph-layers/src/style/style-property.ts index ad99db93..850f1841 100644 --- a/modules/graph-layers/src/style/style-property.ts +++ b/modules/graph-layers/src/style/style-property.ts @@ -154,20 +154,20 @@ type SupportedScale = | ReturnType | ReturnType | ReturnType - | ReturnType + | ReturnType | ReturnType | ReturnType; -const SCALE_FACTORIES = { - linear: scaleLinear, - log: scaleLog, - pow: scalePow, - sqrt: scaleSqrt, - quantize: scaleQuantize, - quantile: scaleQuantile, - ordinal: scaleOrdinal -} as const satisfies Record SupportedScale>; +const SCALE_FACTORIES: Record SupportedScale> = { + linear: () => scaleLinear(), + log: () => scaleLog(), + pow: () => scalePow(), + sqrt: () => scaleSqrt(), + quantize: () => scaleQuantize(), + quantile: () => scaleQuantile(), + ordinal: () => scaleOrdinal() +}; /** Resolved attribute reference with guaranteed defaults. */ type NormalizedAttributeReference = { @@ -178,9 +178,10 @@ type NormalizedAttributeReference = { }; /** Create a D3 scale instance based on a declarative configuration. */ +/* eslint-disable-next-line complexity */ function createScaleFromConfig(config: GraphStyleScale): SupportedScale { const type = config.type ?? 'linear'; - const factory = SCALE_FACTORIES[type as GraphStyleScaleType]; + const factory = SCALE_FACTORIES[type]; if (!factory) { log.warn(`Invalid scale type: ${type}`); throw new Error(`Invalid scale type: ${type}`); @@ -215,8 +216,12 @@ function createScaleFromConfig(config: GraphStyleScale): SupportedScale { ) { anyScale.base(config.base); } - if ('unknown' in config && 'unknown' in scale && typeof anyScale.unknown === 'function') { - anyScale.unknown(config.unknown); + if ( + typeof config.unknown !== 'undefined' && + 'unknown' in scale && + typeof (scale as {unknown?: (value: unknown) => unknown}).unknown === 'function' + ) { + (scale as {unknown: (value: unknown) => unknown}).unknown(config.unknown); } return scale; } @@ -338,6 +343,29 @@ type LeafParseResult = { updateTrigger: unknown; }; +function describeStyleValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'undefined') { + return String(value); + } + if (value === null) { + return 'null'; + } + if (typeof value === 'function') { + return value.name ? `[Function ${value.name}]` : '[Function]'; + } + if (Array.isArray(value)) { + return `[${value.map((item) => describeStyleValue(item)).join(', ')}]`; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + /** Parse a non-stateful style value into deck.gl compatible form. */ function parseLeafValue(key: string, value: GraphStyleLeafValue | undefined): LeafParseResult { const formatter = PROPERTY_FORMATTERS[key] || IDENTITY; @@ -345,8 +373,9 @@ function parseLeafValue(key: string, value: GraphStyleLeafValue | undefined): Le if (typeof value === 'undefined') { const formatted = formatter(DEFAULT_STYLES[key]); if (formatted === null) { - log.warn(`Invalid ${key} value: ${value}`); - throw new Error(`Invalid ${key} value: ${value}`); + const description = describeStyleValue(value); + log.warn(`Invalid ${key} value: ${description}`); + throw new Error(`Invalid ${key} value: ${description}`); } return {value: formatted, isAccessor: false, updateTrigger: false}; } @@ -367,8 +396,9 @@ function parseLeafValue(key: string, value: GraphStyleLeafValue | undefined): Le const formatted = formatter(value); if (formatted === null) { - log.warn(`Invalid ${key} value: ${value}`); - throw new Error(`Invalid ${key} value: ${value}`); + const description = describeStyleValue(value); + log.warn(`Invalid ${key} value: ${description}`); + throw new Error(`Invalid ${key} value: ${description}`); } return {value: formatted, isAccessor: false, updateTrigger: false}; @@ -432,7 +462,7 @@ export class StyleProperty { if (isStatefulValue(value)) { const {accessor, updateTrigger: triggers} = createStatefulAccessor( key, - value as Record, + value, updateTrigger ); this._value = accessor; diff --git a/modules/graph-layers/test/style/graph-stylesheet.schema.spec.ts b/modules/graph-layers/test/style/graph-stylesheet.schema.spec.ts new file mode 100644 index 00000000..aad02fa2 --- /dev/null +++ b/modules/graph-layers/test/style/graph-stylesheet.schema.spec.ts @@ -0,0 +1,85 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, it, expect} from 'vitest'; + +import { + GraphStyleEngine, + GraphStylesheetSchema, + type GraphStylesheet +} from '../../src/style/graph-style-engine'; + +describe('GraphStylesheetSchema', () => { + it('accepts a valid stylesheet definition', () => { + const stylesheet: GraphStylesheet<'circle'> = { + type: 'circle', + fill: '#ffffff', + radius: { + default: 4, + hover: 8 + }, + ':hover': { + stroke: '#0f172a' + } + }; + + expect(() => GraphStylesheetSchema.parse(stylesheet)).not.toThrow(); + expect(() => new GraphStyleEngine(stylesheet)).not.toThrow(); + }); + + it('reports unknown properties', () => { + const invalidStylesheet = { + type: 'circle', + foo: 'bar' + } as unknown as GraphStylesheet; + + const result = GraphStylesheetSchema.safeParse(invalidStylesheet); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => issue.message)).toContain( + 'Unknown style property "foo".' + ); + + expect(() => new GraphStyleEngine(invalidStylesheet)).toThrowError( + /Unknown style property "foo"/i + ); + }); + + it('validates selector overrides', () => { + const invalidSelectorStylesheet = { + type: 'circle', + ':hover': { + unknown: '#ffffff' + } + } as unknown as GraphStylesheet; + + const result = GraphStylesheetSchema.safeParse(invalidSelectorStylesheet); + expect(result.success).toBe(false); + const messages = result.success ? [] : result.error.issues.map((issue) => issue.message); + expect(messages.some((message) => /Unrecognized key/.test(message) && message.includes('unknown'))).toBe(true); + + expect(() => new GraphStyleEngine(invalidSelectorStylesheet)).toThrowError( + /:hover.*unknown/i + ); + }); + + it('validates attribute references', () => { + const invalidAttributeReference = { + type: 'circle', + radius: { + attribute: '', + fallback: 4 + } + } as unknown as GraphStylesheet; + + const result = GraphStylesheetSchema.safeParse(invalidAttributeReference); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((issue) => issue.message)).toContain( + 'Attribute name is required.' + ); + + expect(() => new GraphStyleEngine(invalidAttributeReference)).toThrowError( + /radius\.attribute.*Attribute name is required\./i + ); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0551e264..e00fb9c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1466,6 +1466,9 @@ __metadata: ngraph.generators: "npm:^20.1.0" preact: "npm:^10.17.0" raf: "npm:^3.4.1" + zod: "npm:^4.0.0" + peerDependencies: + zod: ^3.23.8 || ^4.0.0 languageName: unknown linkType: soft @@ -22265,6 +22268,13 @@ __metadata: languageName: node linkType: hard +"zod@npm:^4.0.0": + version: 4.1.12 + resolution: "zod@npm:4.1.12" + checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 + languageName: node + linkType: hard + "zstd-codec@npm:^0.1": version: 0.1.5 resolution: "zstd-codec@npm:0.1.5"