diff --git a/docs/whats-new.md b/docs/whats-new.md index 1cf74b37..ab8ed1a6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,6 +13,10 @@ Scope tracked in the [v9.4 milestone](https://github.com/visgl/deck.gl-community - Added generic animation, block, fast-text, UTF8 Arrow string-view, view-layout, and viewport-bounds helpers for trace-style visualizations. +### `@deck.gl-community/json` (NEW module) + +- Added shared Zod-backed GeoJSON schemas and inferred TypeScript types for positions, bounding boxes, geometries, features, and feature collections. + ### `@deck.gl-community/timeline-layers` - `TimeAxisLayer` now supports adaptive trace-style duration and timestamp grids plus exported tick formatting helpers. diff --git a/modules/json/package.json b/modules/json/package.json new file mode 100644 index 00000000..1cff6952 --- /dev/null +++ b/modules/json/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deck.gl-community/json", + "description": "Zod schemas for GeoJSON primitives", + "license": "MIT", + "version": "9.3.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/visgl/deck.gl-community" + }, + "keywords": [ + "geojson", + "zod", + "schema", + "validation", + "ai" + ], + "type": "module", + "sideEffects": false, + "types": "./dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "require": "./dist/index.cjs", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "test": "vitest run", + "test-watch": "vitest" + }, + "dependencies": { + "zod": "^4.0.0" + } +} diff --git a/modules/json/src/geojson/bbox.ts b/modules/json/src/geojson/bbox.ts new file mode 100644 index 00000000..ac1e67dc --- /dev/null +++ b/modules/json/src/geojson/bbox.ts @@ -0,0 +1,16 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; + +/** + * GeoJSON bounding box — either 4 values [minLon, minLat, maxLon, maxLat] + * or 6 values [minLon, minLat, minAlt, maxLon, maxLat, maxAlt] per RFC 7946 §5. + */ +export const BBoxSchema = z.union([ + z.tuple([z.number(), z.number(), z.number(), z.number()]), + z.tuple([z.number(), z.number(), z.number(), z.number(), z.number(), z.number()]) +]); + +export type BBox = z.infer; diff --git a/modules/json/src/geojson/feature-collection.ts b/modules/json/src/geojson/feature-collection.ts new file mode 100644 index 00000000..020a3c03 --- /dev/null +++ b/modules/json/src/geojson/feature-collection.ts @@ -0,0 +1,18 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {FeatureSchema} from './feature'; +import {BBoxSchema} from './bbox'; + +/** + * GeoJSON FeatureCollection per RFC 7946 §3.3. + */ +export const FeatureCollectionSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(FeatureSchema), + bbox: BBoxSchema.optional() +}); + +export type FeatureCollection = z.infer; diff --git a/modules/json/src/geojson/feature.ts b/modules/json/src/geojson/feature.ts new file mode 100644 index 00000000..97a20bae --- /dev/null +++ b/modules/json/src/geojson/feature.ts @@ -0,0 +1,23 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {GeometrySchema} from './geometry'; +import {BBoxSchema} from './bbox'; + +/** + * GeoJSON Feature per RFC 7946 §3.2. + * - geometry may be null (to represent features without geometry) + * - properties may be null + * - id is optional, and may be a string or number + */ +export const FeatureSchema = z.object({ + type: z.literal('Feature'), + geometry: GeometrySchema.nullable(), + properties: z.record(z.string(), z.unknown()).nullable(), + id: z.union([z.string(), z.number()]).optional(), + bbox: BBoxSchema.optional() +}); + +export type Feature = z.infer; diff --git a/modules/json/src/geojson/geometry-collection.ts b/modules/json/src/geojson/geometry-collection.ts new file mode 100644 index 00000000..82b81649 --- /dev/null +++ b/modules/json/src/geojson/geometry-collection.ts @@ -0,0 +1,49 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PointSchema} from './point'; +import {LineStringSchema} from './line-string'; +import {PolygonSchema} from './polygon'; +import {MultiPointSchema} from './multi-point'; +import {MultiLineStringSchema} from './multi-line-string'; +import {MultiPolygonSchema} from './multi-polygon'; +import {BBoxSchema} from './bbox'; + +/** + * Forward-declare the GeometryCollection schema using z.lazy() to support + * recursive nesting (a GeometryCollection can contain other GeometryCollections). + * RFC 7946 §3.1.8. + */ +export type GeometryCollection = { + type: 'GeometryCollection'; + geometries: Array< + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | z.infer + | GeometryCollection + >; + bbox?: z.infer; +}; + +export const GeometryCollectionSchema: z.ZodType = z.lazy(() => + z.object({ + type: z.literal('GeometryCollection'), + geometries: z.array( + z.union([ + PointSchema, + LineStringSchema, + PolygonSchema, + MultiPointSchema, + MultiLineStringSchema, + MultiPolygonSchema, + GeometryCollectionSchema + ]) + ), + bbox: BBoxSchema.optional() + }) +); diff --git a/modules/json/src/geojson/geometry.ts b/modules/json/src/geojson/geometry.ts new file mode 100644 index 00000000..9b0c145f --- /dev/null +++ b/modules/json/src/geojson/geometry.ts @@ -0,0 +1,32 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PointSchema} from './point'; +import {LineStringSchema} from './line-string'; +import {PolygonSchema} from './polygon'; +import {MultiPointSchema} from './multi-point'; +import {MultiLineStringSchema} from './multi-line-string'; +import {MultiPolygonSchema} from './multi-polygon'; +import {GeometryCollectionSchema} from './geometry-collection'; + +/** + * Union of all GeoJSON geometry types per RFC 7946 §3.1. + * + * Note: uses z.union rather than z.discriminatedUnion because GeometryCollectionSchema + * is a z.lazy()-wrapped opaque ZodType (needed for recursive nesting), which is not + * directly accepted by z.discriminatedUnion's type constraints. Functionally equivalent + * for validation; z.union tries each variant in order. + */ +export const GeometrySchema = z.union([ + PointSchema, + LineStringSchema, + PolygonSchema, + MultiPointSchema, + MultiLineStringSchema, + MultiPolygonSchema, + GeometryCollectionSchema +]); + +export type Geometry = z.infer; diff --git a/modules/json/src/geojson/index.ts b/modules/json/src/geojson/index.ts new file mode 100644 index 00000000..32c0e628 --- /dev/null +++ b/modules/json/src/geojson/index.ts @@ -0,0 +1,39 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export {BBoxSchema} from './bbox'; +export type {BBox} from './bbox'; + +export {PositionSchema} from './position'; +export type {Position} from './position'; + +export {PointSchema} from './point'; +export type {Point} from './point'; + +export {LineStringSchema} from './line-string'; +export type {LineString} from './line-string'; + +export {PolygonSchema, LinearRingSchema} from './polygon'; +export type {Polygon} from './polygon'; + +export {MultiPointSchema} from './multi-point'; +export type {MultiPoint} from './multi-point'; + +export {MultiLineStringSchema} from './multi-line-string'; +export type {MultiLineString} from './multi-line-string'; + +export {MultiPolygonSchema} from './multi-polygon'; +export type {MultiPolygon} from './multi-polygon'; + +export {GeometryCollectionSchema} from './geometry-collection'; +export type {GeometryCollection} from './geometry-collection'; + +export {GeometrySchema} from './geometry'; +export type {Geometry} from './geometry'; + +export {FeatureSchema} from './feature'; +export type {Feature} from './feature'; + +export {FeatureCollectionSchema} from './feature-collection'; +export type {FeatureCollection} from './feature-collection'; diff --git a/modules/json/src/geojson/line-string.ts b/modules/json/src/geojson/line-string.ts new file mode 100644 index 00000000..90a8cb6e --- /dev/null +++ b/modules/json/src/geojson/line-string.ts @@ -0,0 +1,18 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PositionSchema} from './position'; +import {BBoxSchema} from './bbox'; + +/** + * GeoJSON LineString — array of two or more positions per RFC 7946 §3.1.4. + */ +export const LineStringSchema = z.object({ + type: z.literal('LineString'), + coordinates: z.array(PositionSchema).min(2), + bbox: BBoxSchema.optional() +}); + +export type LineString = z.infer; diff --git a/modules/json/src/geojson/multi-line-string.ts b/modules/json/src/geojson/multi-line-string.ts new file mode 100644 index 00000000..d3d24750 --- /dev/null +++ b/modules/json/src/geojson/multi-line-string.ts @@ -0,0 +1,15 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PositionSchema} from './position'; +import {BBoxSchema} from './bbox'; + +export const MultiLineStringSchema = z.object({ + type: z.literal('MultiLineString'), + coordinates: z.array(z.array(PositionSchema).min(2)), + bbox: BBoxSchema.optional() +}); + +export type MultiLineString = z.infer; diff --git a/modules/json/src/geojson/multi-point.ts b/modules/json/src/geojson/multi-point.ts new file mode 100644 index 00000000..c78c88f2 --- /dev/null +++ b/modules/json/src/geojson/multi-point.ts @@ -0,0 +1,15 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PositionSchema} from './position'; +import {BBoxSchema} from './bbox'; + +export const MultiPointSchema = z.object({ + type: z.literal('MultiPoint'), + coordinates: z.array(PositionSchema), + bbox: BBoxSchema.optional() +}); + +export type MultiPoint = z.infer; diff --git a/modules/json/src/geojson/multi-polygon.ts b/modules/json/src/geojson/multi-polygon.ts new file mode 100644 index 00000000..24f9ac5a --- /dev/null +++ b/modules/json/src/geojson/multi-polygon.ts @@ -0,0 +1,15 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {LinearRingSchema} from './polygon'; +import {BBoxSchema} from './bbox'; + +export const MultiPolygonSchema = z.object({ + type: z.literal('MultiPolygon'), + coordinates: z.array(z.array(LinearRingSchema)), + bbox: BBoxSchema.optional() +}); + +export type MultiPolygon = z.infer; diff --git a/modules/json/src/geojson/point.ts b/modules/json/src/geojson/point.ts new file mode 100644 index 00000000..f86bf8b7 --- /dev/null +++ b/modules/json/src/geojson/point.ts @@ -0,0 +1,15 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PositionSchema} from './position'; +import {BBoxSchema} from './bbox'; + +export const PointSchema = z.object({ + type: z.literal('Point'), + coordinates: PositionSchema, + bbox: BBoxSchema.optional() +}); + +export type Point = z.infer; diff --git a/modules/json/src/geojson/polygon.ts b/modules/json/src/geojson/polygon.ts new file mode 100644 index 00000000..b91f2caa --- /dev/null +++ b/modules/json/src/geojson/polygon.ts @@ -0,0 +1,40 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; +import {PositionSchema} from './position'; +import {BBoxSchema} from './bbox'; + +/** + * A linear ring for a Polygon: + * - Must have ≥ 4 positions (RFC 7946 §3.1.6) + * - First and last position must be identical (ring closure) + */ +const LinearRingSchema = z + .array(PositionSchema) + .min(4) + .refine( + ring => { + const first = ring[0]; + const last = ring[ring.length - 1]; + // Compare all coordinate components + return first.length === last.length && first.every((v, i) => v === last[i]); + }, + {message: 'Linear ring must be closed: first and last position must be identical'} + ); + +/** + * GeoJSON Polygon — array of linear rings per RFC 7946 §3.1.6. + * First ring is the exterior; subsequent rings are holes. + */ +export const PolygonSchema = z.object({ + type: z.literal('Polygon'), + coordinates: z.array(LinearRingSchema), + bbox: BBoxSchema.optional() +}); + +export type Polygon = z.infer; + +/** Exported for reuse in other schemas that need to validate individual rings. */ +export {LinearRingSchema}; diff --git a/modules/json/src/geojson/position.ts b/modules/json/src/geojson/position.ts new file mode 100644 index 00000000..472749c5 --- /dev/null +++ b/modules/json/src/geojson/position.ts @@ -0,0 +1,17 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {z} from 'zod'; + +/** + * GeoJSON Position — either [longitude, latitude] or [longitude, latitude, altitude]. + * RFC 7946 §3.1.1: "A position is an array of numbers. There MUST be two or more elements." + * We support exactly 2D and 3D per the spec's normative wording. + */ +export const PositionSchema = z.union([ + z.tuple([z.number(), z.number()]), + z.tuple([z.number(), z.number(), z.number()]) +]); + +export type Position = z.infer; diff --git a/modules/json/src/index.ts b/modules/json/src/index.ts new file mode 100644 index 00000000..48ba1fbb --- /dev/null +++ b/modules/json/src/index.ts @@ -0,0 +1,5 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +export * from './geojson/index'; diff --git a/modules/json/src/test/geojson.test.ts b/modules/json/src/test/geojson.test.ts new file mode 100644 index 00000000..df3c918a --- /dev/null +++ b/modules/json/src/test/geojson.test.ts @@ -0,0 +1,440 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, it, expect} from 'vitest'; +import { + BBoxSchema, + PositionSchema, + PointSchema, + LineStringSchema, + PolygonSchema, + MultiPointSchema, + MultiLineStringSchema, + MultiPolygonSchema, + GeometryCollectionSchema, + GeometrySchema, + FeatureSchema, + FeatureCollectionSchema +} from '../geojson/index'; + +// ── BBox ───────────────────────────────────────────────────────────────────── + +describe('BBoxSchema', () => { + it('accepts 4-element bbox', () => { + expect(BBoxSchema.safeParse([-180, -90, 180, 90]).success).toBe(true); + }); + it('accepts 6-element bbox', () => { + expect(BBoxSchema.safeParse([-180, -90, -1000, 180, 90, 5000]).success).toBe(true); + }); + it('rejects 3-element array', () => { + expect(BBoxSchema.safeParse([-180, -90, 180]).success).toBe(false); + }); + it('rejects 5-element array', () => { + expect(BBoxSchema.safeParse([0, 0, 0, 0, 0]).success).toBe(false); + }); +}); + +// ── Position ───────────────────────────────────────────────────────────────── + +describe('PositionSchema', () => { + it('accepts 2D position', () => { + expect(PositionSchema.safeParse([-73.985, 40.748]).success).toBe(true); + }); + it('accepts 3D position', () => { + expect(PositionSchema.safeParse([-73.985, 40.748, 10.0]).success).toBe(true); + }); + it('rejects 1-element array', () => { + expect(PositionSchema.safeParse([-73.985]).success).toBe(false); + }); + it('rejects 4-element array', () => { + expect(PositionSchema.safeParse([0, 1, 2, 3]).success).toBe(false); + }); + it('rejects non-number elements', () => { + expect(PositionSchema.safeParse(['lng', 'lat']).success).toBe(false); + }); +}); + +// ── Point ───────────────────────────────────────────────────────────────────── + +describe('PointSchema', () => { + it('accepts a valid 2D Point', () => { + expect(PointSchema.safeParse({type: 'Point', coordinates: [-73.985, 40.748]}).success).toBe( + true + ); + }); + it('accepts a valid 3D Point', () => { + expect( + PointSchema.safeParse({type: 'Point', coordinates: [-73.985, 40.748, 100]}).success + ).toBe(true); + }); + it('accepts Point with bbox', () => { + expect( + PointSchema.safeParse({ + type: 'Point', + coordinates: [0, 0], + bbox: [-1, -1, 1, 1] + }).success + ).toBe(true); + }); + it('rejects wrong type literal', () => { + expect(PointSchema.safeParse({type: 'LineString', coordinates: [0, 0]}).success).toBe(false); + }); + it('rejects missing coordinates', () => { + expect(PointSchema.safeParse({type: 'Point'}).success).toBe(false); + }); +}); + +// ── LineString ──────────────────────────────────────────────────────────────── + +describe('LineStringSchema', () => { + const valid = { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }; + it('accepts valid LineString', () => { + expect(LineStringSchema.safeParse(valid).success).toBe(true); + }); + it('rejects fewer than 2 positions', () => { + expect(LineStringSchema.safeParse({type: 'LineString', coordinates: [[0, 0]]}).success).toBe( + false + ); + }); + it('rejects empty coordinates', () => { + expect(LineStringSchema.safeParse({type: 'LineString', coordinates: []}).success).toBe(false); + }); +}); + +// ── Polygon ─────────────────────────────────────────────────────────────────── + +describe('PolygonSchema', () => { + const validRing = [ + [0, 0], + [1, 0], + [1, 1], + [0, 0] // closed + ]; + it('accepts a valid closed Polygon', () => { + expect(PolygonSchema.safeParse({type: 'Polygon', coordinates: [validRing]}).success).toBe(true); + }); + it('rejects an unclosed ring', () => { + const unclosed = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] // first !== last + ]; + expect(PolygonSchema.safeParse({type: 'Polygon', coordinates: [unclosed]}).success).toBe(false); + }); + it('rejects a ring with fewer than 4 positions', () => { + const short = [ + [0, 0], + [1, 0], + [0, 0] + ]; + expect(PolygonSchema.safeParse({type: 'Polygon', coordinates: [short]}).success).toBe(false); + }); + it('accepts Polygon with hole', () => { + const hole = [ + [0.1, 0.1], + [0.9, 0.1], + [0.9, 0.9], + [0.1, 0.1] + ]; + expect(PolygonSchema.safeParse({type: 'Polygon', coordinates: [validRing, hole]}).success).toBe( + true + ); + }); +}); + +// ── MultiPoint ──────────────────────────────────────────────────────────────── + +describe('MultiPointSchema', () => { + it('accepts valid MultiPoint', () => { + expect( + MultiPointSchema.safeParse({ + type: 'MultiPoint', + coordinates: [ + [0, 0], + [1, 1] + ] + }).success + ).toBe(true); + }); + it('accepts empty coordinates array (spec allows it)', () => { + expect(MultiPointSchema.safeParse({type: 'MultiPoint', coordinates: []}).success).toBe(true); + }); + it('rejects invalid position in coordinates', () => { + expect(MultiPointSchema.safeParse({type: 'MultiPoint', coordinates: [[0]]}).success).toBe( + false + ); + }); +}); + +// ── MultiLineString ─────────────────────────────────────────────────────────── + +describe('MultiLineStringSchema', () => { + it('accepts valid MultiLineString', () => { + expect( + MultiLineStringSchema.safeParse({ + type: 'MultiLineString', + coordinates: [ + [ + [0, 0], + [1, 1] + ], + [ + [2, 2], + [3, 3] + ] + ] + }).success + ).toBe(true); + }); + it('rejects line with fewer than 2 positions', () => { + expect( + MultiLineStringSchema.safeParse({ + type: 'MultiLineString', + coordinates: [[[0, 0]]] + }).success + ).toBe(false); + }); +}); + +// ── MultiPolygon ────────────────────────────────────────────────────────────── + +describe('MultiPolygonSchema', () => { + const ring = [ + [0, 0], + [1, 0], + [1, 1], + [0, 0] + ]; + it('accepts valid MultiPolygon', () => { + expect( + MultiPolygonSchema.safeParse({ + type: 'MultiPolygon', + coordinates: [[ring]] + }).success + ).toBe(true); + }); + it('rejects unclosed ring within MultiPolygon', () => { + const unclosed = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ]; + expect( + MultiPolygonSchema.safeParse({ + type: 'MultiPolygon', + coordinates: [[unclosed]] + }).success + ).toBe(false); + }); +}); + +// ── GeometryCollection ──────────────────────────────────────────────────────── + +describe('GeometryCollectionSchema', () => { + it('accepts a GeometryCollection with mixed geometries', () => { + expect( + GeometryCollectionSchema.safeParse({ + type: 'GeometryCollection', + geometries: [ + {type: 'Point', coordinates: [0, 0]}, + { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + } + ] + }).success + ).toBe(true); + }); + it('accepts a nested GeometryCollection (recursive)', () => { + expect( + GeometryCollectionSchema.safeParse({ + type: 'GeometryCollection', + geometries: [ + { + type: 'GeometryCollection', + geometries: [{type: 'Point', coordinates: [0, 0]}] + } + ] + }).success + ).toBe(true); + }); + it('rejects invalid child geometry', () => { + expect( + GeometryCollectionSchema.safeParse({ + type: 'GeometryCollection', + geometries: [{type: 'Triangle', coordinates: []}] + }).success + ).toBe(false); + }); +}); + +// ── Geometry (union) ────────────────────────────────────────────────────────── + +describe('GeometrySchema', () => { + it('accepts each geometry type', () => { + const cases = [ + {type: 'Point', coordinates: [0, 0]}, + { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }, + { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0] + ] + ] + }, + {type: 'MultiPoint', coordinates: []}, + {type: 'MultiLineString', coordinates: []}, + {type: 'MultiPolygon', coordinates: []}, + {type: 'GeometryCollection', geometries: []} + ]; + for (const c of cases) { + expect(GeometrySchema.safeParse(c).success).toBe(true); + } + }); + it('rejects unknown geometry type', () => { + expect(GeometrySchema.safeParse({type: 'Cube', coordinates: []}).success).toBe(false); + }); +}); + +// ── Feature ─────────────────────────────────────────────────────────────────── + +describe('FeatureSchema', () => { + it('accepts a simple Feature', () => { + expect( + FeatureSchema.safeParse({ + type: 'Feature', + geometry: {type: 'Point', coordinates: [0, 0]}, + properties: {name: 'test'} + }).success + ).toBe(true); + }); + it('accepts a Feature with null geometry', () => { + expect( + FeatureSchema.safeParse({ + type: 'Feature', + geometry: null, + properties: null + }).success + ).toBe(true); + }); + it('accepts a Feature with string id', () => { + expect( + FeatureSchema.safeParse({ + type: 'Feature', + geometry: {type: 'Point', coordinates: [0, 0]}, + properties: null, + id: 'feature-1' + }).success + ).toBe(true); + }); + it('accepts a Feature with numeric id', () => { + expect( + FeatureSchema.safeParse({ + type: 'Feature', + geometry: {type: 'Point', coordinates: [0, 0]}, + properties: null, + id: 42 + }).success + ).toBe(true); + }); + it('rejects wrong type literal', () => { + expect( + FeatureSchema.safeParse({ + type: 'Foo', + geometry: null, + properties: null + }).success + ).toBe(false); + }); +}); + +// ── FeatureCollection ───────────────────────────────────────────────────────── + +describe('FeatureCollectionSchema', () => { + it('accepts a FeatureCollection with mixed geometry types', () => { + expect( + FeatureCollectionSchema.safeParse({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: {type: 'Point', coordinates: [-73.985, 40.748]}, + properties: {name: 'NY'} + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-73.985, 40.748], + [-122.4194, 37.7749] + ] + }, + properties: null + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0] + ] + ] + }, + properties: {area: 100} + } + ] + }).success + ).toBe(true); + }); + it('accepts an empty FeatureCollection', () => { + expect( + FeatureCollectionSchema.safeParse({type: 'FeatureCollection', features: []}).success + ).toBe(true); + }); + it('rejects when features is not an array', () => { + expect( + FeatureCollectionSchema.safeParse({type: 'FeatureCollection', features: null}).success + ).toBe(false); + }); + it('rejects when a feature has invalid geometry', () => { + expect( + FeatureCollectionSchema.safeParse({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: {type: 'Point'}, // missing coordinates + properties: null + } + ] + }).success + ).toBe(false); + }); +}); diff --git a/modules/json/tsconfig.json b/modules/json/tsconfig.json new file mode 100644 index 00000000..175b131b --- /dev/null +++ b/modules/json/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist", + "noEmit": false + } +} diff --git a/tsconfig.json b/tsconfig.json index 3cb8ab63..5669c8d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,7 +39,8 @@ "@deck.gl-community/trace-layers/*": ["./modules/trace-layers/src/*"], "@deck.gl-community/graph-layers": ["./modules/graph-layers/src"], "@deck.gl-community/three": ["./modules/three/src"], - "@deck.gl-community/timeline-layers": ["./dev/timeline-layers/src"] + "@deck.gl-community/timeline-layers": ["./dev/timeline-layers/src"], + "@deck.gl-community/json": ["./modules/json/src"] }, "typeRoots": ["./node_modules/@types"], "plugins": [ diff --git a/vitest.config.ts b/vitest.config.ts index 25d5bafe..9a948b4d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,6 +35,10 @@ const ALIASES = [ find: /^@deck\.gl-community\/layers$/, replacement: fileURLToPath(new URL('./modules/layers/src/index.ts', import.meta.url)) }, + { + find: /^@deck\.gl-community\/json$/, + replacement: fileURLToPath(new URL('./modules/json/src/index.ts', import.meta.url)) + }, { find: /^@deck\.gl-community\/timeline-layers$/, replacement: fileURLToPath(new URL('./dev/timeline-layers/src/index.ts', import.meta.url)) @@ -104,6 +108,8 @@ const CONFIG = defineConfig({ exclude: [ 'modules/**/dist/**', 'dev/**/dist/**', + 'modules/**/node_modules/**', + 'dev/**/node_modules/**', 'modules/**/*.browser.{test,spec}.{js,ts}', 'dev/**/*.browser.{test,spec}.{js,ts}', 'modules/widgets/src/widget-panels/toolbar-widget.test.ts', diff --git a/yarn.lock b/yarn.lock index 8ad84892..0c1ca648 100644 --- a/yarn.lock +++ b/yarn.lock @@ -589,6 +589,14 @@ __metadata: languageName: unknown linkType: soft +"@deck.gl-community/json@workspace:modules/json": + version: 0.0.0-use.local + resolution: "@deck.gl-community/json@workspace:modules/json" + dependencies: + zod: "npm:^4.0.0" + languageName: unknown + linkType: soft + "@deck.gl-community/layers@workspace:*, @deck.gl-community/layers@workspace:modules/layers": version: 0.0.0-use.local resolution: "@deck.gl-community/layers@workspace:modules/layers"