diff --git a/modules/visgl-skills/README.md b/modules/visgl-skills/README.md new file mode 100644 index 000000000..e7710becb --- /dev/null +++ b/modules/visgl-skills/README.md @@ -0,0 +1,200 @@ +# @deck.gl-community/visgl-skills + +[![NPM Version](https://img.shields.io/npm/v/@deck.gl-community/visgl-skills.svg)](https://www.npmjs.com/package/@deck.gl-community/visgl-skills) + +Agent skills for deck.gl – typed helpers that simplify layer construction for +**Claude Code**, **Openclaw**, **GitHub Copilot**, and other AI coding agents. + +## Overview + +`visgl-skills` provides a thin, typed abstraction layer over deck.gl that +makes it easy for both humans and AI coding agents to build WebGL +visualisations. It offers three complementary APIs: + +| API | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Layer skills** | Factory functions that return a `LayerDescriptor` (plain object) for every common deck.gl layer type. | +| **Noodles** | A JSON-serializable layer recipe system. AI agents can produce noodles as pure data; the runtime converts them to real deck.gl props with `hydrateNoodle`. | +| **DeckBuilder** | A fluent builder that composes multiple layers and view-state into a single `DeckConfig` object. | + +Helper skills for **viewport** (`fitViewport`, `createViewState`) and +**data** (`createColorAccessor`, `flattenGeoJSON`, …) round out the toolkit. + +## Installation + +```bash +npm install @deck.gl-community/visgl-skills +# peer deps +npm install @deck.gl/core @deck.gl/layers +# optional – only needed for HeatmapLayer +npm install @deck.gl/aggregation-layers +``` + +## Quick Start + +### Layer skills + +```ts +import { + createScatterplotLayer, + createPathLayer, + DeckBuilder, + fitViewport, + getBoundingBox +} from '@deck.gl-community/visgl-skills'; +import {Deck} from '@deck.gl/core'; +import {ScatterplotLayer, PathLayer} from '@deck.gl/layers'; + +const cities = [ + {name: 'San Francisco', coordinates: [-122.4, 37.8], population: 900_000}, + {name: 'Los Angeles', coordinates: [-118.2, 34.1], population: 4_000_000} +]; + +// 1. Build layer descriptors using skills +const scatterDescriptor = createScatterplotLayer({ + id: 'cities', + data: cities, + getPosition: (d) => d.coordinates, + getRadius: (d) => d.population / 100, + getFillColor: [255, 140, 0], + radiusUnits: 'meters', + pickable: true +}); + +// 2. Auto-fit the viewport +const bbox = getBoundingBox(cities.map((d) => d.coordinates)); +const viewState = fitViewport(bbox, {width: 800, height: 600, padding: 60}); + +// 3. Assemble with DeckBuilder +const config = new DeckBuilder().setViewState(viewState).addLayer(scatterDescriptor).build(); + +// 4. Instantiate the real deck.gl layers from the descriptors +const deck = new Deck({ + ...config, + layers: [new ScatterplotLayer(scatterDescriptor.props)] +}); +``` + +### Noodles 🍜 + +Noodles are JSON-serializable layer recipes that AI agents can generate as +pure data. Field accessors are encoded as dot-notation **path strings** +(`"coordinates"`, `"meta.size"`); the runtime resolves them via `hydrateNoodle`. + +```ts +import {createNoodle, hydrateNoodle, validateNoodle} from '@deck.gl-community/visgl-skills'; +import {ScatterplotLayer} from '@deck.gl/layers'; + +// An AI agent produces this noodle as plain JSON: +const noodle = createNoodle('ScatterplotLayer', { + data: cities, + position: 'coordinates', // reads d.coordinates + radius: 'population', // reads d.population + fillColor: [255, 0, 128], // static colour + radiusUnits: 'meters', + radiusScale: 0.001, + pickable: true +}); + +// Validate before use +const {valid, errors} = validateNoodle(noodle); +if (!valid) throw new Error(errors.join('\n')); + +// Hydrate into real props +const props = hydrateNoodle(noodle); +const layer = new ScatterplotLayer(props); +``` + +### Data skills + +```ts +import { + createColorAccessor, + createRadiusAccessor, + flattenGeoJSON +} from '@deck.gl-community/visgl-skills'; + +// Linear colour scale +const getColor = createColorAccessor({ + getValue: (d) => d.temperature, + domainMin: -10, + domainMax: 40, + colorLow: [0, 0, 255], + colorHigh: [255, 0, 0] +}); + +// Proportional radius +const getRadius = createRadiusAccessor({ + getValue: (d) => d.population, + domainMin: 0, + domainMax: 10_000_000, + minPixels: 3, + maxPixels: 40 +}); + +// Flatten GeoJSON β†’ plain objects for easy data binding +const flat = flattenGeoJSON(myFeatureCollection); +// flat[0] β†’ { longitude, latitude, ...properties } +``` + +## API Reference + +### Layer skills + +| Function | deck.gl Layer | +| ------------------------------ | ----------------------------------- | +| `createScatterplotLayer(opts)` | `ScatterplotLayer` | +| `createPathLayer(opts)` | `PathLayer` | +| `createPolygonLayer(opts)` | `PolygonLayer` | +| `createTextLayer(opts)` | `TextLayer` | +| `createIconLayer(opts)` | `IconLayer` | +| `createHeatmapLayer(opts)` | `HeatmapLayer` (aggregation-layers) | + +All factories accept a typed options object and return `{ id, type, props }`. + +### Noodle API + +| Symbol | Description | +| --------------------------- | ---------------------------------------------- | +| `createNoodle(kind, props)` | Create a typed noodle descriptor | +| `hydrateNoodle(noodle)` | Convert a noodle to deck.gl layer props | +| `validateNoodle(noodle)` | Validate a noodle; returns `{ valid, errors }` | + +### Viewport skills + +| Function | Description | +| --------------------------- | --------------------------------------------- | +| `createViewState(opts?)` | Create a view state with sensible defaults | +| `getBoundingBox(positions)` | Compute `[minLng, minLat, maxLng, maxLat]` | +| `fitViewport(bbox, opts)` | Compute a view state that fits a bounding box | + +### Data skills + +| Function | Description | +| ------------------------------ | --------------------------------------------------- | +| `createColorAccessor(opts)` | Linear colour interpolation accessor | +| `createRadiusAccessor(opts)` | Proportional radius accessor | +| `flattenGeoJSON(collection)` | Flatten GeoJSON FeatureCollection to plain objects | +| `extractPositions(collection)` | Extract `[lng, lat]` pairs from a FeatureCollection | + +### DeckBuilder + +```ts +new DeckBuilder() + .setId(id) + .setContainer(elementOrId) + .setViewState(viewState) + .setController(controllerConfig) + .setSize(width, height) + .addLayer(layerDescriptor) + .prependLayer(layerDescriptor) + .removeLayer(id) + .replaceLayer(layerDescriptor) + .build() // β†’ DeckConfig + .getLayers() // β†’ LayerDescriptor[] + .getViewState(); // β†’ ViewState +``` + +## License + +MIT – see [LICENSE](../../LICENSE) diff --git a/modules/visgl-skills/package.json b/modules/visgl-skills/package.json new file mode 100644 index 000000000..a67768866 --- /dev/null +++ b/modules/visgl-skills/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deck.gl-community/visgl-skills", + "version": "9.2.6", + "description": "Agent skills for deck.gl – typed helpers that simplify layer construction for Claude Code, Openclaw, and other AI coding agents", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "deck.gl", + "visualization", + "ai", + "agent", + "skills", + "claude", + "copilot" + ], + "repository": { + "type": "git", + "url": "https://github.com/visgl/deck.gl-community.git" + }, + "type": "module", + "sideEffects": false, + "types": "./dist/index.d.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.cjs", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "test": "vitest run", + "test-watch": "vitest" + }, + "peerDependencies": { + "@deck.gl/aggregation-layers": ">=9.0.0", + "@deck.gl/core": ">=9.0.0", + "@deck.gl/layers": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@deck.gl/aggregation-layers": { + "optional": true + } + } +} diff --git a/modules/visgl-skills/src/data-skills.ts b/modules/visgl-skills/src/data-skills.ts new file mode 100644 index 000000000..89828ad15 --- /dev/null +++ b/modules/visgl-skills/src/data-skills.ts @@ -0,0 +1,156 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {Color, LngLat} from './types'; + +// --------------------------------------------------------------------------- +// Color helpers +// --------------------------------------------------------------------------- + +/** + * Skill: create a linear colour accessor that maps a numeric value in + * [domainMin, domainMax] to an RGBA colour interpolated between + * `colorLow` and `colorHigh`. + * + * @example + * ```ts + * const getColor = createColorAccessor({ + * getValue: d => d.temperature, + * domainMin: -10, + * domainMax: 40, + * colorLow: [0, 0, 255], + * colorHigh: [255, 0, 0], + * }); + * // Use directly as a deck.gl getFillColor accessor. + * ``` + */ +export function createColorAccessor(options: { + getValue: (d: D) => number; + domainMin: number; + domainMax: number; + colorLow?: Color; + colorHigh?: Color; + /** Alpha for low end. Defaults to 255. */ + alphaLow?: number; + /** Alpha for high end. Defaults to 255. */ + alphaHigh?: number; +}): (d: D) => Color { + const { + getValue, + domainMin, + domainMax, + colorLow = [0, 0, 255], + colorHigh = [255, 0, 0], + alphaLow = 255, + alphaHigh = 255 + } = options; + + return (d: D): Color => { + const t = Math.max(0, Math.min(1, (getValue(d) - domainMin) / (domainMax - domainMin))); + const r = Math.round(colorLow[0] + t * (colorHigh[0] - colorLow[0])); + const g = Math.round(colorLow[1] + t * (colorHigh[1] - colorLow[1])); + const b = Math.round(colorLow[2] + t * (colorHigh[2] - colorLow[2])); + const a = Math.round(alphaLow + t * (alphaHigh - alphaLow)); + return [r, g, b, a]; + }; +} + +// --------------------------------------------------------------------------- +// GeoJSON helpers +// --------------------------------------------------------------------------- + +/** Minimal GeoJSON point feature type (avoids a heavy @types/geojson dep). */ +export type PointFeature = { + type: 'Feature'; + geometry: {type: 'Point'; coordinates: [number, number] | [number, number, number]}; + properties: Record; +}; + +/** Minimal GeoJSON FeatureCollection type. */ +export type PointFeatureCollection = { + type: 'FeatureCollection'; + features: PointFeature[]; +}; + +/** + * Skill: extract an array of positions (LngLat) from a GeoJSON FeatureCollection + * containing Point geometries. + * + * @example + * ```ts + * const positions = extractPositions(geojson); + * const bbox = getBoundingBox(positions); + * ``` + */ +export function extractPositions(geojson: PointFeatureCollection): LngLat[] { + return geojson.features.map((f) => f.geometry.coordinates); +} + +/** + * Skill: flatten a GeoJSON FeatureCollection into an array of plain data + * objects that combine the feature properties with the position. + * + * The returned objects contain `longitude`, `latitude`, `altitude` (optional) + * and all feature properties, making them easy to pass directly to layer + * `data` arrays. + * + * @example + * ```ts + * const data = flattenGeoJSON(geojson); + * const layer = createScatterplotLayer({ + * data, + * getPosition: d => [d.longitude, d.latitude], + * getRadius: d => d.radius ?? 50, + * }); + * ``` + */ +export function flattenGeoJSON( + geojson: PointFeatureCollection +): ({longitude: number; latitude: number; altitude?: number} & Record)[] { + return geojson.features.map((f) => { + const [lng, lat, alt] = f.geometry.coordinates; + const base: {longitude: number; latitude: number; altitude?: number} = { + longitude: lng, + latitude: lat + }; + if (alt !== undefined) { + base.altitude = alt; + } + return {...f.properties, ...base}; + }); +} + +// --------------------------------------------------------------------------- +// Radius / size helpers +// --------------------------------------------------------------------------- + +/** + * Skill: create a radius accessor that maps a numeric property to a pixel + * radius clamped between `minPixels` and `maxPixels`. + * + * @example + * ```ts + * const getRadius = createRadiusAccessor({ + * getValue: d => d.population, + * domainMin: 0, + * domainMax: 1_000_000, + * minPixels: 3, + * maxPixels: 40, + * }); + * ``` + */ +export function createRadiusAccessor(options: { + getValue: (d: D) => number; + domainMin: number; + domainMax: number; + minPixels?: number; + maxPixels?: number; +}): (d: D) => number { + const {getValue, domainMin, domainMax, minPixels = 2, maxPixels = 30} = options; + + return (d: D): number => { + const t = Math.max(0, Math.min(1, (getValue(d) - domainMin) / (domainMax - domainMin))); + return minPixels + t * (maxPixels - minPixels); + }; +} diff --git a/modules/visgl-skills/src/deck-builder.ts b/modules/visgl-skills/src/deck-builder.ts new file mode 100644 index 000000000..b00bb9ec6 --- /dev/null +++ b/modules/visgl-skills/src/deck-builder.ts @@ -0,0 +1,136 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * # DeckBuilder + * + * A fluent builder that composes a full deck.gl Deck configuration from layer + * descriptors. Designed for easy use by AI coding agents that need to + * assemble visualisations programmatically. + * + * @example + * ```ts + * import {DeckBuilder, createScatterplotLayer} from '@deck.gl-community/visgl-skills'; + * + * const config = new DeckBuilder() + * .setViewState({longitude: -122.4, latitude: 37.8, zoom: 11}) + * .addLayer(createScatterplotLayer({ data: cities, getPosition: d => d.coord })) + * .setContainer('map') + * .build(); + * ``` + */ + +import type {LayerDescriptor, ViewState} from './types'; + +/** Options produced by {@link DeckBuilder.build}. */ +export type DeckConfig = { + /** Unique id for the Deck instance. */ + id?: string; + /** The DOM element or element id to mount the canvas into. */ + container?: string | HTMLElement; + /** Initial view state. */ + initialViewState: ViewState; + /** Controller configuration. `true` enables the default map controller. */ + controller: boolean | Record; + /** Ordered array of layer descriptors. */ + layers: LayerDescriptor[]; + /** Canvas width. `'100%'` fills the container. */ + width: string | number; + /** Canvas height. `'100%'` fills the container. */ + height: string | number; +}; + +/** + * Fluent builder for assembling a deck.gl configuration object. + * + * All methods return `this` so that calls can be chained. + */ +export class DeckBuilder { + private _id?: string; + private _container?: string | HTMLElement; + private _viewState: ViewState = {longitude: 0, latitude: 0, zoom: 1}; + private _controller: boolean | Record = true; + private _layers: LayerDescriptor[] = []; + private _width: string | number = '100%'; + private _height: string | number = '100%'; + + /** Set the deck instance identifier. */ + setId(id: string): this { + this._id = id; + return this; + } + + /** Set the DOM container (id string or HTMLElement). */ + setContainer(container: string | HTMLElement): this { + this._container = container; + return this; + } + + /** Set the initial map view state. */ + setViewState(viewState: ViewState): this { + this._viewState = viewState; + return this; + } + + /** Configure the map controller (pass `false` to disable). */ + setController(controller: boolean | Record): this { + this._controller = controller; + return this; + } + + /** Set the canvas dimensions. Defaults to '100%'. */ + setSize(width: string | number, height: string | number): this { + this._width = width; + this._height = height; + return this; + } + + /** Append a layer descriptor. Layers are rendered in insertion order. */ + addLayer(layer: LayerDescriptor): this { + this._layers = [...this._layers, layer]; + return this; + } + + /** Prepend a layer descriptor (rendered below existing layers). */ + prependLayer(layer: LayerDescriptor): this { + this._layers = [layer, ...this._layers]; + return this; + } + + /** Remove a layer by its id. */ + removeLayer(id: string): this { + this._layers = this._layers.filter((l) => l.id !== id); + return this; + } + + /** Replace an existing layer (matched by id) with a new descriptor. */ + replaceLayer(layer: LayerDescriptor): this { + this._layers = this._layers.map((l) => (l.id === layer.id ? layer : l)); + return this; + } + + /** Produce the final DeckConfig object. */ + build(): DeckConfig { + const config: DeckConfig = { + initialViewState: {...this._viewState}, + controller: this._controller, + layers: [...this._layers], + width: this._width, + height: this._height + }; + if (this._id !== undefined) config.id = this._id; + if (this._container !== undefined) config.container = this._container; + return config; + } + + /** Return the current list of layer descriptors (non-mutating). */ + getLayers(): LayerDescriptor[] { + return [...this._layers]; + } + + /** Return the current view state (non-mutating). */ + getViewState(): ViewState { + return {...this._viewState}; + } +} diff --git a/modules/visgl-skills/src/index.ts b/modules/visgl-skills/src/index.ts new file mode 100644 index 000000000..a34d1797b --- /dev/null +++ b/modules/visgl-skills/src/index.ts @@ -0,0 +1,64 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Types +export type { + LngLat, + Color, + BoundingBox, + ViewState, + LayerDescriptor, + BaseLayerOptions +} from './types'; + +// Layer skills +export type { + ScatterplotSkillOptions, + PathSkillOptions, + PolygonSkillOptions, + TextSkillOptions, + IconSkillOptions, + HeatmapSkillOptions +} from './layer-skills'; +export { + createScatterplotLayer, + createPathLayer, + createPolygonLayer, + createTextLayer, + createIconLayer, + createHeatmapLayer, + DEFAULT_HEATMAP_COLOR_RANGE +} from './layer-skills'; + +// Viewport skills +export type {CreateViewStateOptions, FitViewportOptions} from './viewport-skills'; +export {createViewState, getBoundingBox, fitViewport} from './viewport-skills'; + +// Data skills +export type {PointFeature, PointFeatureCollection} from './data-skills'; +export { + createColorAccessor, + extractPositions, + flattenGeoJSON, + createRadiusAccessor +} from './data-skills'; + +// Noodles +export type { + NoodleKind, + NoodleAccessor, + BaseNoodleProps, + ScatterplotNoodle, + PathNoodle, + PolygonNoodle, + TextNoodle, + HeatmapNoodle, + Noodle, + NoodleValidationResult +} from './noodles'; +export {createNoodle, hydrateNoodle, validateNoodle} from './noodles'; + +// DeckBuilder +export type {DeckConfig} from './deck-builder'; +export {DeckBuilder} from './deck-builder'; diff --git a/modules/visgl-skills/src/layer-skills.ts b/modules/visgl-skills/src/layer-skills.ts new file mode 100644 index 000000000..860b5d3a7 --- /dev/null +++ b/modules/visgl-skills/src/layer-skills.ts @@ -0,0 +1,494 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {BaseLayerOptions, Color, LayerDescriptor} from './types'; + +// --------------------------------------------------------------------------- +// ScatterplotLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createScatterplotLayer}. */ +export type ScatterplotSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** Accessor that returns [longitude, latitude] for each datum. */ + getPosition: (d: D) => [number, number] | [number, number, number]; + /** Fill colour accessor or static colour. Defaults to [255, 140, 0]. */ + getFillColor?: ((d: D) => Color) | Color; + /** Line colour accessor or static colour. Defaults to [255, 255, 255]. */ + getLineColor?: ((d: D) => Color) | Color; + /** Radius accessor (metres) or static value. Defaults to 30. */ + getRadius?: ((d: D) => number) | number; + /** Whether to stroke the circles. Defaults to false. */ + stroked?: boolean; + /** Whether to fill the circles. Defaults to true. */ + filled?: boolean; + /** Radius units: 'meters' | 'pixels' | 'common'. Defaults to 'meters'. */ + radiusUnits?: 'meters' | 'pixels' | 'common'; + /** Scale multiplier applied to getRadius. Defaults to 1. */ + radiusScale?: number; + /** Minimum rendered radius in pixels. Defaults to 0. */ + radiusMinPixels?: number; + /** Maximum rendered radius in pixels. Defaults to Infinity. */ + radiusMaxPixels?: number; +}; + +/** + * Skill: create a ScatterplotLayer descriptor. + * + * @example + * ```ts + * const layer = createScatterplotLayer({ + * data: cities, + * getPosition: d => [d.lng, d.lat], + * getRadius: d => d.population / 100, + * getFillColor: [255, 0, 128], + * }); + * // β†’ { id, type: 'ScatterplotLayer', props: { … } } + * ``` + */ +export function createScatterplotLayer(options: ScatterplotSkillOptions): LayerDescriptor { + const { + id = 'scatterplot-layer', + opacity = 1, + visible = true, + pickable = false, + data, + getPosition, + getFillColor = [255, 140, 0], + getLineColor = [255, 255, 255], + getRadius = 30, + stroked = false, + filled = true, + radiusUnits = 'meters', + radiusScale = 1, + radiusMinPixels = 0, + radiusMaxPixels = Infinity + } = options; + + return { + id, + type: 'ScatterplotLayer', + props: { + id, + data, + opacity, + visible, + pickable, + getPosition, + getFillColor, + getLineColor, + getRadius, + stroked, + filled, + radiusUnits, + radiusScale, + radiusMinPixels, + radiusMaxPixels + } + }; +} + +// --------------------------------------------------------------------------- +// PathLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createPathLayer}. */ +export type PathSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** Accessor that returns an array of positions for each path. */ + getPath: (d: D) => ([number, number] | [number, number, number])[]; + /** Colour accessor or static colour. Defaults to [255, 255, 255]. */ + getColor?: ((d: D) => Color) | Color; + /** Width accessor (units) or static value. Defaults to 2. */ + getWidth?: ((d: D) => number) | number; + /** Width units: 'meters' | 'pixels' | 'common'. Defaults to 'meters'. */ + widthUnits?: 'meters' | 'pixels' | 'common'; + /** Minimum rendered width in pixels. Defaults to 0. */ + widthMinPixels?: number; + /** Maximum rendered width in pixels. Defaults to Infinity. */ + widthMaxPixels?: number; + /** Whether to cap the ends of paths. Defaults to true. */ + capRounded?: boolean; + /** Whether to round path joints. Defaults to false. */ + jointRounded?: boolean; +}; + +/** + * Skill: create a PathLayer descriptor. + * + * @example + * ```ts + * const layer = createPathLayer({ + * data: routes, + * getPath: d => d.coordinates, + * getColor: [253, 128, 93], + * getWidth: 5, + * widthUnits: 'pixels', + * }); + * ``` + */ +export function createPathLayer(options: PathSkillOptions): LayerDescriptor { + const { + id = 'path-layer', + opacity = 1, + visible = true, + pickable = false, + data, + getPath, + getColor = [255, 255, 255], + getWidth = 2, + widthUnits = 'meters', + widthMinPixels = 0, + widthMaxPixels = Infinity, + capRounded = true, + jointRounded = false + } = options; + + return { + id, + type: 'PathLayer', + props: { + id, + data, + opacity, + visible, + pickable, + getPath, + getColor, + getWidth, + widthUnits, + widthMinPixels, + widthMaxPixels, + capRounded, + jointRounded + } + }; +} + +// --------------------------------------------------------------------------- +// PolygonLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createPolygonLayer}. */ +export type PolygonSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** Accessor that returns an array of positions (ring) or array of rings. */ + getPolygon: (d: D) => ([number, number] | [number, number, number])[]; + /** Fill colour accessor or static colour. Defaults to [80, 130, 200]. */ + getFillColor?: ((d: D) => Color) | Color; + /** Line colour accessor or static colour. Defaults to [255, 255, 255]. */ + getLineColor?: ((d: D) => Color) | Color; + /** Line width accessor or static value. Defaults to 1. */ + getLineWidth?: ((d: D) => number) | number; + /** Extrusion height accessor or static value. Defaults to 0 (flat). */ + getElevation?: ((d: D) => number) | number; + /** Whether to fill polygons. Defaults to true. */ + filled?: boolean; + /** Whether to draw polygon outlines. Defaults to false. */ + stroked?: boolean; + /** Whether to extrude polygons to 3D. Defaults to false. */ + extruded?: boolean; + /** Line width units. Defaults to 'meters'. */ + lineWidthUnits?: 'meters' | 'pixels' | 'common'; +}; + +/** + * Skill: create a PolygonLayer descriptor. + * + * @example + * ```ts + * const layer = createPolygonLayer({ + * data: districts, + * getPolygon: d => d.contour, + * getFillColor: d => [d.value * 255, 100, 100], + * stroked: true, + * }); + * ``` + */ +export function createPolygonLayer(options: PolygonSkillOptions): LayerDescriptor { + const { + id = 'polygon-layer', + opacity = 0.8, + visible = true, + pickable = false, + data, + getPolygon, + getFillColor = [80, 130, 200], + getLineColor = [255, 255, 255], + getLineWidth = 1, + getElevation = 0, + filled = true, + stroked = false, + extruded = false, + lineWidthUnits = 'meters' + } = options; + + return { + id, + type: 'PolygonLayer', + props: { + id, + data, + opacity, + visible, + pickable, + getPolygon, + getFillColor, + getLineColor, + getLineWidth, + getElevation, + filled, + stroked, + extruded, + lineWidthUnits + } + }; +} + +// --------------------------------------------------------------------------- +// TextLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createTextLayer}. */ +export type TextSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** Accessor that returns [longitude, latitude] for each datum. */ + getPosition: (d: D) => [number, number] | [number, number, number]; + /** Accessor that returns the text string to display. */ + getText: (d: D) => string; + /** Text colour accessor or static colour. Defaults to [255, 255, 255]. */ + getColor?: ((d: D) => Color) | Color; + /** Text size accessor (pixels) or static value. Defaults to 12. */ + getSize?: ((d: D) => number) | number; + /** Anchor point: 'start' | 'middle' | 'end'. Defaults to 'middle'. */ + getTextAnchor?: ((d: D) => string) | string; + /** Alignment baseline. Defaults to 'center'. */ + getAlignmentBaseline?: ((d: D) => string) | string; + /** Pixel offset [x, y]. Defaults to [0, 0]. */ + getPixelOffset?: ((d: D) => [number, number]) | [number, number]; + /** Font family. Defaults to 'Monaco, monospace'. */ + fontFamily?: string; + /** Whether to render text as world units (vs. screen pixels). Defaults to false. */ + billboard?: boolean; +}; + +/** + * Skill: create a TextLayer descriptor. + * + * @example + * ```ts + * const layer = createTextLayer({ + * data: labels, + * getPosition: d => [d.lng, d.lat], + * getText: d => d.name, + * getSize: 14, + * }); + * ``` + */ +export function createTextLayer(options: TextSkillOptions): LayerDescriptor { + const { + id = 'text-layer', + opacity = 1, + visible = true, + pickable = false, + data, + getPosition, + getText, + getColor = [255, 255, 255], + getSize = 12, + getTextAnchor = 'middle', + getAlignmentBaseline = 'center', + getPixelOffset = [0, 0], + fontFamily = 'Monaco, monospace', + billboard = false + } = options; + + return { + id, + type: 'TextLayer', + props: { + id, + data, + opacity, + visible, + pickable, + getPosition, + getText, + getColor, + getSize, + getTextAnchor, + getAlignmentBaseline, + getPixelOffset, + fontFamily, + billboard + } + }; +} + +// --------------------------------------------------------------------------- +// IconLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createIconLayer}. */ +export type IconSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** URL of the icon atlas image. */ + iconAtlas: string; + /** Icon mapping object: { iconName: {x, y, width, height, mask?} }. */ + iconMapping: Record< + string, + {x: number; y: number; width: number; height: number; mask?: boolean} + >; + /** Accessor that returns [longitude, latitude] for each datum. */ + getPosition: (d: D) => [number, number] | [number, number, number]; + /** Accessor that returns the icon name for each datum. */ + getIcon: (d: D) => string; + /** Size accessor (pixels) or static value. Defaults to 32. */ + getSize?: ((d: D) => number) | number; + /** Colour accessor or static colour. Defaults to [255, 255, 255] (no tint). */ + getColor?: ((d: D) => Color) | Color; + /** Pixel anchor offset [x, y]. Defaults to [0, 0]. */ + getPixelOffset?: ((d: D) => [number, number]) | [number, number]; +}; + +/** + * Skill: create an IconLayer descriptor. + * + * @example + * ```ts + * const layer = createIconLayer({ + * data: markers, + * iconAtlas: '/icons.png', + * iconMapping: { pin: {x:0, y:0, width:32, height:32} }, + * getPosition: d => [d.lng, d.lat], + * getIcon: () => 'pin', + * }); + * ``` + */ +export function createIconLayer(options: IconSkillOptions): LayerDescriptor { + const { + id = 'icon-layer', + opacity = 1, + visible = true, + pickable = false, + data, + iconAtlas, + iconMapping, + getPosition, + getIcon, + getSize = 32, + getColor = [255, 255, 255], + getPixelOffset = [0, 0] + } = options; + + return { + id, + type: 'IconLayer', + props: { + id, + data, + opacity, + visible, + pickable, + iconAtlas, + iconMapping, + getPosition, + getIcon, + getSize, + getColor, + getPixelOffset + } + }; +} + +// --------------------------------------------------------------------------- +// HeatmapLayer +// --------------------------------------------------------------------------- + +/** Options for {@link createHeatmapLayer}. */ +export type HeatmapSkillOptions = BaseLayerOptions & { + /** Source data array. */ + data: D[]; + /** Accessor that returns [longitude, latitude] for each datum. */ + getPosition: (d: D) => [number, number] | [number, number, number]; + /** Weight accessor or static value 0–1. Defaults to 1. */ + getWeight?: ((d: D) => number) | number; + /** Intensity multiplier. Defaults to 1. */ + intensity?: number; + /** Radius of influence in pixels. Defaults to 30. */ + radiusPixels?: number; + /** Threshold 0–1 below which pixels are hidden. Defaults to 0.03. */ + threshold?: number; + /** Color range array of RGBA colours. Defaults to a blue-to-red ramp. */ + colorRange?: Color[]; + /** Aggregation: 'SUM' | 'MEAN'. Defaults to 'SUM'. */ + aggregation?: 'SUM' | 'MEAN'; +}; + +/** Default blueβ†’red heatmap colour ramp. */ +export const DEFAULT_HEATMAP_COLOR_RANGE: Color[] = [ + [0, 25, 0, 25], + [0, 85, 80, 90], + [0, 170, 160, 160], + [0, 255, 255, 128], + [200, 200, 0, 128], + [255, 140, 0, 200], + [255, 0, 0, 230] +]; + +/** + * Skill: create a HeatmapLayer descriptor. + * + * Requires `@deck.gl/aggregation-layers` to be installed. + * + * @example + * ```ts + * const layer = createHeatmapLayer({ + * data: events, + * getPosition: d => [d.lng, d.lat], + * getWeight: d => d.magnitude, + * radiusPixels: 60, + * }); + * ``` + */ +export function createHeatmapLayer(options: HeatmapSkillOptions): LayerDescriptor { + const { + id = 'heatmap-layer', + opacity = 1, + visible = true, + pickable = false, + data, + getPosition, + getWeight = 1, + intensity = 1, + radiusPixels = 30, + threshold = 0.03, + colorRange = DEFAULT_HEATMAP_COLOR_RANGE, + aggregation = 'SUM' + } = options; + + return { + id, + type: 'HeatmapLayer', + props: { + id, + data, + opacity, + visible, + pickable, + getPosition, + getWeight, + intensity, + radiusPixels, + threshold, + colorRange, + aggregation + } + }; +} diff --git a/modules/visgl-skills/src/noodles.ts b/modules/visgl-skills/src/noodles.ts new file mode 100644 index 000000000..472b5dbd8 --- /dev/null +++ b/modules/visgl-skills/src/noodles.ts @@ -0,0 +1,403 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * # Noodles 🍜 + * + * A **noodle** is a lightweight, JSON-serializable descriptor for a deck.gl + * layer. Noodles let AI agents (Claude Code, Openclaw, Copilot, etc.) produce + * deck.gl layer configurations entirely as plain data – no executable code + * required – which can then be hydrated into real deck.gl Layer instances by + * the runtime. + * + * ## Motivation + * AI coding agents work best with _data_, not callbacks. By encoding a layer + * as a noodle the agent can: + * + * 1. Describe the desired visualisation in a structured way. + * 2. Store, compare, and diff layer configurations without running JavaScript. + * 3. Hydrate the noodle into a real deck.gl layer only when needed. + * + * ## Field accessor encoding + * Because noodles are JSON-serializable, accessor functions are encoded as + * **field path strings** (e.g. `"coordinates"` reads `d.coordinates`, + * `"meta.size"` reads `d.meta.size`). Static values are encoded directly. + * + * @example + * ```ts + * import {createNoodle, hydrateNoodle} from '@deck.gl-community/visgl-skills'; + * + * const noodle = createNoodle('ScatterplotLayer', { + * data: cities, + * position: 'coordinates', // β†’ d => d.coordinates + * fillColor: [255, 140, 0], // static + * radius: 'population', // β†’ d => d.population + * radiusUnits: 'meters', + * radiusScale: 0.01, + * pickable: true, + * }); + * ``` + */ + +import type {Color} from './types'; + +// --------------------------------------------------------------------------- +// Noodle types +// --------------------------------------------------------------------------- + +/** Layer kinds supported by the noodle system. */ +export type NoodleKind = + | 'ScatterplotLayer' + | 'PathLayer' + | 'PolygonLayer' + | 'TextLayer' + | 'IconLayer' + | 'HeatmapLayer'; + +/** An accessor encoded as either a field-path string or a static value. */ +export type NoodleAccessor = string | T; + +/** Base fields common to every noodle. */ +export type BaseNoodleProps = { + /** Unique layer id. */ + id?: string; + /** Layer opacity 0–1. */ + opacity?: number; + /** Whether the layer is visible. */ + visible?: boolean; + /** Whether to enable picking. */ + pickable?: boolean; +}; + +/** Noodle definition for ScatterplotLayer. */ +export type ScatterplotNoodle = BaseNoodleProps & { + type: 'ScatterplotLayer'; + data: unknown[]; + /** Field path or static position accessor. */ + position: NoodleAccessor<[number, number]>; + fillColor?: NoodleAccessor; + lineColor?: NoodleAccessor; + radius?: NoodleAccessor; + radiusUnits?: 'meters' | 'pixels' | 'common'; + radiusScale?: number; + radiusMinPixels?: number; + radiusMaxPixels?: number; + stroked?: boolean; + filled?: boolean; +}; + +/** Noodle definition for PathLayer. */ +export type PathNoodle = BaseNoodleProps & { + type: 'PathLayer'; + data: unknown[]; + path: NoodleAccessor<[number, number][]>; + color?: NoodleAccessor; + width?: NoodleAccessor; + widthUnits?: 'meters' | 'pixels' | 'common'; + widthMinPixels?: number; + capRounded?: boolean; + jointRounded?: boolean; +}; + +/** Noodle definition for PolygonLayer. */ +export type PolygonNoodle = BaseNoodleProps & { + type: 'PolygonLayer'; + data: unknown[]; + polygon: NoodleAccessor<[number, number][]>; + fillColor?: NoodleAccessor; + lineColor?: NoodleAccessor; + lineWidth?: NoodleAccessor; + elevation?: NoodleAccessor; + filled?: boolean; + stroked?: boolean; + extruded?: boolean; +}; + +/** Noodle definition for TextLayer. */ +export type TextNoodle = BaseNoodleProps & { + type: 'TextLayer'; + data: unknown[]; + position: NoodleAccessor<[number, number]>; + text: NoodleAccessor; + color?: NoodleAccessor; + size?: NoodleAccessor; + fontFamily?: string; +}; + +/** Noodle definition for HeatmapLayer. */ +export type HeatmapNoodle = BaseNoodleProps & { + type: 'HeatmapLayer'; + data: unknown[]; + position: NoodleAccessor<[number, number]>; + weight?: NoodleAccessor; + intensity?: number; + radiusPixels?: number; + threshold?: number; + aggregation?: 'SUM' | 'MEAN'; +}; + +/** Union of all noodle types. */ +export type Noodle = ScatterplotNoodle | PathNoodle | PolygonNoodle | TextNoodle | HeatmapNoodle; + +// --------------------------------------------------------------------------- +// Factory helpers +// --------------------------------------------------------------------------- + +/** + * Skill: create a typed noodle for a given layer kind. + * + * TypeScript will narrow the returned type to the correct noodle variant + * based on the `kind` argument. + * + * @example + * ```ts + * const noodle = createNoodle('ScatterplotLayer', { + * data: airports, + * position: 'coordinates', + * radius: 'size', + * radiusUnits: 'pixels', + * }); + * ``` + */ +export function createNoodle( + kind: 'ScatterplotLayer', + props: Omit +): ScatterplotNoodle; +export function createNoodle(kind: 'PathLayer', props: Omit): PathNoodle; +export function createNoodle( + kind: 'PolygonLayer', + props: Omit +): PolygonNoodle; +export function createNoodle(kind: 'TextLayer', props: Omit): TextNoodle; +export function createNoodle( + kind: 'HeatmapLayer', + props: Omit +): HeatmapNoodle; +export function createNoodle(kind: NoodleKind, props: Record): Noodle { + return {type: kind, ...props} as Noodle; +} + +// --------------------------------------------------------------------------- +// Hydration +// --------------------------------------------------------------------------- + +/** Resolve a NoodleAccessor into a deck.gl accessor. */ +function resolveAccessor( + accessor: NoodleAccessor | undefined, + defaultValue: T +): T | ((d: unknown) => T) { + if (accessor === undefined) return defaultValue; + if (typeof accessor === 'string') { + // Field path accessor: supports dot notation (e.g. "meta.size") + const path = (accessor as string).split('.'); + return (d: unknown) => { + let value: unknown = d; + for (const key of path) { + if (value === null || value === undefined || typeof value !== 'object') return defaultValue; + value = (value as Record)[key]; + } + return value as T; + }; + } + return accessor; +} + +/** + * Skill: hydrate a noodle into a plain layer-props object that can be + * spread onto a deck.gl Layer constructor, or passed to a LayerDescriptor. + * + * Field-path accessors are converted into functions; static values are kept + * as-is. + * + * @example + * ```ts + * import {ScatterplotLayer} from '@deck.gl/layers'; + * + * const noodle = createNoodle('ScatterplotLayer', {data: cities, position: 'coord'}); + * const props = hydrateNoodle(noodle); + * const layer = new ScatterplotLayer(props); + * ``` + */ +export function hydrateNoodle(noodle: Noodle): Record { + const { + type, + id, + opacity = 1, + visible = true, + pickable = false, + data + } = noodle as Noodle & { + data: unknown[]; + }; + + const base: Record = { + id: id ?? `${type.toLowerCase()}-${Math.random().toString(36).slice(2, 8)}`, + data, + opacity, + visible, + pickable + }; + + return {...base, ...hydrateNoodleProps(noodle)}; +} + +function hydrateNoodleProps(noodle: Noodle): Record { + switch (noodle.type) { + case 'ScatterplotLayer': + return hydrateScatterplotNoodle(noodle); + case 'PathLayer': + return hydratePathNoodle(noodle); + case 'PolygonLayer': + return hydratePolygonNoodle(noodle); + case 'TextLayer': + return hydrateTextNoodle(noodle); + case 'HeatmapLayer': + return hydrateHeatmapNoodle(noodle); + default: + return {}; + } +} + +function hydrateScatterplotNoodle(n: ScatterplotNoodle): Record { + return { + getPosition: resolveAccessor(n.position, [0, 0]), + getFillColor: resolveAccessor(n.fillColor, [255, 140, 0] as Color), + getLineColor: resolveAccessor(n.lineColor, [255, 255, 255] as Color), + getRadius: resolveAccessor(n.radius, 30), + radiusUnits: n.radiusUnits ?? 'meters', + radiusScale: n.radiusScale ?? 1, + radiusMinPixels: n.radiusMinPixels ?? 0, + radiusMaxPixels: n.radiusMaxPixels ?? Infinity, + stroked: n.stroked ?? false, + filled: n.filled ?? true + }; +} + +function hydratePathNoodle(n: PathNoodle): Record { + return { + getPath: resolveAccessor(n.path, []), + getColor: resolveAccessor(n.color, [255, 255, 255] as Color), + getWidth: resolveAccessor(n.width, 2), + widthUnits: n.widthUnits ?? 'meters', + widthMinPixels: n.widthMinPixels ?? 0, + capRounded: n.capRounded ?? true, + jointRounded: n.jointRounded ?? false + }; +} + +function hydratePolygonNoodle(n: PolygonNoodle): Record { + return { + getPolygon: resolveAccessor(n.polygon, []), + getFillColor: resolveAccessor(n.fillColor, [80, 130, 200] as Color), + getLineColor: resolveAccessor(n.lineColor, [255, 255, 255] as Color), + getLineWidth: resolveAccessor(n.lineWidth, 1), + getElevation: resolveAccessor(n.elevation, 0), + filled: n.filled ?? true, + stroked: n.stroked ?? false, + extruded: n.extruded ?? false + }; +} + +function hydrateTextNoodle(n: TextNoodle): Record { + return { + getPosition: resolveAccessor(n.position, [0, 0]), + getText: resolveAccessor(n.text, ''), + getColor: resolveAccessor(n.color, [255, 255, 255] as Color), + getSize: resolveAccessor(n.size, 12), + fontFamily: n.fontFamily ?? 'Monaco, monospace' + }; +} + +function hydrateHeatmapNoodle(n: HeatmapNoodle): Record { + return { + getPosition: resolveAccessor(n.position, [0, 0]), + getWeight: resolveAccessor(n.weight, 1), + intensity: n.intensity ?? 1, + radiusPixels: n.radiusPixels ?? 30, + threshold: n.threshold ?? 0.03, + aggregation: n.aggregation ?? 'SUM' + }; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** Validation result returned by {@link validateNoodle}. */ +export type NoodleValidationResult = { + valid: boolean; + errors: string[]; +}; + +/** Required position-like field names per layer type. */ +const POSITION_REQUIRED_TYPES: NoodleKind[] = ['ScatterplotLayer', 'TextLayer', 'HeatmapLayer']; + +/** Validate type-specific required fields and push errors into the array. */ +function validateTypeFields(n: Record, errors: string[]): void { + const type = n.type as NoodleKind; + if (POSITION_REQUIRED_TYPES.includes(type) && !n.position) { + errors.push(`Field "position" is required for ${String(type)}.`); + } + if (type === 'PathLayer' && !n.path) { + errors.push('Field "path" is required for PathLayer.'); + } + if (type === 'PolygonLayer' && !n.polygon) { + errors.push('Field "polygon" is required for PolygonLayer.'); + } + if (type === 'TextLayer' && !n.text) { + errors.push('Field "text" is required for TextLayer.'); + } +} + +/** + * Skill: validate a noodle descriptor and return a list of errors. + * + * Useful for catching mis-configurations before passing to deck.gl. + * + * @example + * ```ts + * const result = validateNoodle(noodle); + * if (!result.valid) console.error(result.errors); + * ``` + */ +export function validateNoodle(noodle: unknown): NoodleValidationResult { + const errors: string[] = []; + + if (typeof noodle !== 'object' || noodle === null) { + return {valid: false, errors: ['Noodle must be a non-null object.']}; + } + + const n = noodle as Record; + + const VALID_TYPES: NoodleKind[] = [ + 'ScatterplotLayer', + 'PathLayer', + 'PolygonLayer', + 'TextLayer', + 'HeatmapLayer' + ]; + + if (!n.type) { + errors.push('Missing required field: type.'); + } else if (!VALID_TYPES.includes(n.type as NoodleKind)) { + errors.push(`Unknown noodle type "${String(n.type)}". Valid types: ${VALID_TYPES.join(', ')}.`); + } + + if (!Array.isArray(n.data)) { + errors.push('Field "data" must be an array.'); + } + + if ( + n.opacity !== undefined && + (typeof n.opacity !== 'number' || n.opacity < 0 || n.opacity > 1) + ) { + errors.push('Field "opacity" must be a number between 0 and 1.'); + } + + if (n.type) { + validateTypeFields(n, errors); + } + + return {valid: errors.length === 0, errors}; +} diff --git a/modules/visgl-skills/src/types.ts b/modules/visgl-skills/src/types.ts new file mode 100644 index 000000000..89bbcb85e --- /dev/null +++ b/modules/visgl-skills/src/types.ts @@ -0,0 +1,49 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * Shared types for visgl-skills layer and viewport helpers. + */ + +/** A geographic coordinate [longitude, latitude] or [longitude, latitude, altitude]. */ +export type LngLat = [number, number] | [number, number, number]; + +/** An RGBA color tuple [r, g, b] or [r, g, b, a] where each component is 0-255. */ +export type Color = [number, number, number] | [number, number, number, number]; + +/** A bounding box [minLng, minLat, maxLng, maxLat]. */ +export type BoundingBox = [number, number, number, number]; + +/** deck.gl view-state used by MapView / OrthographicView. */ +export type ViewState = { + longitude: number; + latitude: number; + zoom: number; + pitch?: number; + bearing?: number; + minZoom?: number; + maxZoom?: number; +}; + +/** Minimal deck.gl layer descriptor returned by layer skill factories. */ +export type LayerDescriptor = { + /** Unique layer identifier. */ + id: string; + /** deck.gl layer class type (e.g. 'ScatterplotLayer'). */ + type: string; + /** Props forwarded to the deck.gl layer constructor. */ + props: Record; +}; + +/** Options shared by every layer skill factory. */ +export type BaseLayerOptions = { + /** Layer identifier – defaults to the layer type name. */ + id?: string; + /** Layer opacity 0–1. Defaults to 1. */ + opacity?: number; + /** Whether the layer is visible. Defaults to true. */ + visible?: boolean; + /** Whether to pick on hover. Defaults to false. */ + pickable?: boolean; +}; diff --git a/modules/visgl-skills/src/viewport-skills.ts b/modules/visgl-skills/src/viewport-skills.ts new file mode 100644 index 000000000..0dac0a0f7 --- /dev/null +++ b/modules/visgl-skills/src/viewport-skills.ts @@ -0,0 +1,148 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {BoundingBox, LngLat, ViewState} from './types'; + +// --------------------------------------------------------------------------- +// View-state helpers +// --------------------------------------------------------------------------- + +/** Options for {@link createViewState}. */ +export type CreateViewStateOptions = { + /** Map centre longitude. Defaults to 0. */ + longitude?: number; + /** Map centre latitude. Defaults to 0. */ + latitude?: number; + /** Zoom level (0 = world, 20 = building). Defaults to 1. */ + zoom?: number; + /** Camera tilt in degrees (0 = top-down). Defaults to 0. */ + pitch?: number; + /** Camera rotation in degrees (0 = north-up). Defaults to 0. */ + bearing?: number; + /** Minimum allowed zoom. Defaults to 0. */ + minZoom?: number; + /** Maximum allowed zoom. Defaults to 20. */ + maxZoom?: number; +}; + +/** + * Skill: construct a deck.gl view-state object with sensible defaults. + * + * @example + * ```ts + * const viewState = createViewState({longitude: -122.4, latitude: 37.8, zoom: 11}); + * ``` + */ +export function createViewState(options: CreateViewStateOptions = {}): ViewState { + const { + longitude = 0, + latitude = 0, + zoom = 1, + pitch = 0, + bearing = 0, + minZoom = 0, + maxZoom = 20 + } = options; + + return {longitude, latitude, zoom, pitch, bearing, minZoom, maxZoom}; +} + +// --------------------------------------------------------------------------- +// Bounding-box helpers +// --------------------------------------------------------------------------- + +/** + * Skill: compute the bounding box [[minLng, minLat], [maxLng, maxLat]] for + * an array of positions. + * + * @example + * ```ts + * const bbox = getBoundingBox([[0, 0], [10, 20], [-5, 15]]); + * // β†’ [-5, 0, 10, 20] + * ``` + */ +export function getBoundingBox(positions: LngLat[]): BoundingBox { + if (positions.length === 0) { + return [0, 0, 0, 0]; + } + + let minLng = Infinity; + let minLat = Infinity; + let maxLng = -Infinity; + let maxLat = -Infinity; + + for (const pos of positions) { + const lng = pos[0]; + const lat = pos[1]; + if (lng < minLng) minLng = lng; + if (lat < minLat) minLat = lat; + if (lng > maxLng) maxLng = lng; + if (lat > maxLat) maxLat = lat; + } + + return [minLng, minLat, maxLng, maxLat]; +} + +/** Options for {@link fitViewport}. */ +export type FitViewportOptions = { + /** Viewport width in pixels. Required for accurate zoom computation. */ + width: number; + /** Viewport height in pixels. Required for accurate zoom computation. */ + height: number; + /** Padding in pixels to add around the bounds. Defaults to 40. */ + padding?: number; + /** Camera tilt to include in the result. Defaults to 0. */ + pitch?: number; + /** Camera rotation to include in the result. Defaults to 0. */ + bearing?: number; + /** Maximum zoom to return. Defaults to 16. */ + maxZoom?: number; +}; + +/** + * Skill: compute a ViewState that fits a bounding box into the given + * viewport dimensions. + * + * The zoom is calculated using the standard Web Mercator formula so that the + * entire bounding box is visible with optional padding. + * + * @example + * ```ts + * const bbox = getBoundingBox(positions); + * const viewState = fitViewport(bbox, {width: 800, height: 600, padding: 50}); + * ``` + */ +export function fitViewport(bbox: BoundingBox, options: FitViewportOptions): ViewState { + const {width, height, padding = 40, pitch = 0, bearing = 0, maxZoom = 16} = options; + + const [minLng, minLat, maxLng, maxLat] = bbox; + + const centerLng = (minLng + maxLng) / 2; + const centerLat = (minLat + maxLat) / 2; + + // Approximate zoom using Web Mercator tile size (256 px per tile at zoom 0). + const TILE_SIZE = 256; + const lngDelta = Math.abs(maxLng - minLng) || 1; + const latDelta = Math.abs(maxLat - minLat) || 1; + + const usableWidth = Math.max(width - padding * 2, 1); + const usableHeight = Math.max(height - padding * 2, 1); + + const zoomLng = Math.log2((usableWidth / TILE_SIZE) * (360 / lngDelta)); + const zoomLat = Math.log2( + (usableHeight / TILE_SIZE) * (180 / (latDelta * Math.cos((centerLat * Math.PI) / 180))) + ); + + const zoom = Math.min(Math.max(0, Math.min(zoomLng, zoomLat)), maxZoom); + + return { + longitude: centerLng, + latitude: centerLat, + zoom, + pitch, + bearing, + minZoom: 0, + maxZoom + }; +} diff --git a/modules/visgl-skills/test/visgl-skills.spec.ts b/modules/visgl-skills/test/visgl-skills.spec.ts new file mode 100644 index 000000000..8ce55c901 --- /dev/null +++ b/modules/visgl-skills/test/visgl-skills.spec.ts @@ -0,0 +1,492 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, it, expect} from 'vitest'; + +import { + createScatterplotLayer, + createPathLayer, + createPolygonLayer, + createTextLayer, + createHeatmapLayer, + createIconLayer, + createViewState, + getBoundingBox, + fitViewport, + createColorAccessor, + createRadiusAccessor, + flattenGeoJSON, + extractPositions, + createNoodle, + hydrateNoodle, + validateNoodle, + DeckBuilder +} from '../src/index'; + +// --------------------------------------------------------------------------- +// Layer skills +// --------------------------------------------------------------------------- + +describe('createScatterplotLayer', () => { + it('returns a LayerDescriptor with type ScatterplotLayer', () => { + const layer = createScatterplotLayer({ + data: [{lng: 0, lat: 0}], + getPosition: (d) => [d.lng, d.lat] + }); + expect(layer.type).toBe('ScatterplotLayer'); + expect(layer.id).toBe('scatterplot-layer'); + expect(layer.props.data).toHaveLength(1); + }); + + it('accepts a custom id', () => { + const layer = createScatterplotLayer({ + id: 'my-layer', + data: [], + getPosition: (d: {lng: number; lat: number}) => [d.lng, d.lat] + }); + expect(layer.id).toBe('my-layer'); + }); + + it('forwards custom props', () => { + const layer = createScatterplotLayer({ + data: [], + getPosition: () => [0, 0], + stroked: true, + radiusMinPixels: 2, + radiusMaxPixels: 50, + radiusUnits: 'pixels' + }); + expect(layer.props.stroked).toBe(true); + expect(layer.props.radiusUnits).toBe('pixels'); + }); +}); + +describe('createPathLayer', () => { + it('returns a LayerDescriptor with type PathLayer', () => { + const layer = createPathLayer({ + data: [], + getPath: (d: {coords: [number, number][]}) => d.coords + }); + expect(layer.type).toBe('PathLayer'); + }); +}); + +describe('createPolygonLayer', () => { + it('returns a LayerDescriptor with type PolygonLayer', () => { + const layer = createPolygonLayer({ + data: [], + getPolygon: (d: {ring: [number, number][]}) => d.ring + }); + expect(layer.type).toBe('PolygonLayer'); + expect(layer.props.extruded).toBe(false); + }); + + it('supports extruded mode', () => { + const layer = createPolygonLayer({ + data: [], + getPolygon: () => [], + extruded: true, + getElevation: 100 + }); + expect(layer.props.extruded).toBe(true); + expect(layer.props.getElevation).toBe(100); + }); +}); + +describe('createTextLayer', () => { + it('returns a LayerDescriptor with type TextLayer', () => { + const layer = createTextLayer({ + data: [], + getPosition: () => [0, 0], + getText: (d: {label: string}) => d.label + }); + expect(layer.type).toBe('TextLayer'); + }); +}); + +describe('createHeatmapLayer', () => { + it('returns a LayerDescriptor with type HeatmapLayer', () => { + const layer = createHeatmapLayer({ + data: [], + getPosition: () => [0, 0] + }); + expect(layer.type).toBe('HeatmapLayer'); + expect(layer.props.radiusPixels).toBe(30); + }); +}); + +describe('createIconLayer', () => { + it('returns a LayerDescriptor with type IconLayer', () => { + const layer = createIconLayer({ + data: [], + iconAtlas: '/icons.png', + iconMapping: {pin: {x: 0, y: 0, width: 32, height: 32}}, + getPosition: () => [0, 0], + getIcon: () => 'pin' + }); + expect(layer.type).toBe('IconLayer'); + expect(layer.props.iconAtlas).toBe('/icons.png'); + }); +}); + +// --------------------------------------------------------------------------- +// Viewport skills +// --------------------------------------------------------------------------- + +describe('createViewState', () => { + it('returns defaults when called with no args', () => { + const vs = createViewState(); + expect(vs.longitude).toBe(0); + expect(vs.latitude).toBe(0); + expect(vs.zoom).toBe(1); + expect(vs.pitch).toBe(0); + expect(vs.bearing).toBe(0); + }); + + it('merges provided values with defaults', () => { + const vs = createViewState({longitude: -122.4, latitude: 37.8, zoom: 11}); + expect(vs.longitude).toBe(-122.4); + expect(vs.zoom).toBe(11); + expect(vs.pitch).toBe(0); + }); +}); + +describe('getBoundingBox', () => { + it('returns a [minLng, minLat, maxLng, maxLat] tuple', () => { + const bbox = getBoundingBox([ + [0, 0], + [10, 20], + [-5, 15] + ]); + expect(bbox).toEqual([-5, 0, 10, 20]); + }); + + it('returns [0,0,0,0] for an empty array', () => { + expect(getBoundingBox([])).toEqual([0, 0, 0, 0]); + }); + + it('handles a single position', () => { + expect(getBoundingBox([[5, 10]])).toEqual([5, 10, 5, 10]); + }); +}); + +describe('fitViewport', () => { + it('returns a valid ViewState', () => { + const bbox = getBoundingBox([ + [-122.5, 37.7], + [-122.3, 37.9] + ]); + const vs = fitViewport(bbox, {width: 800, height: 600}); + expect(vs.longitude).toBeCloseTo(-122.4, 1); + expect(vs.latitude).toBeCloseTo(37.8, 1); + expect(vs.zoom).toBeGreaterThan(0); + expect(vs.zoom).toBeLessThanOrEqual(16); + }); + + it('respects maxZoom', () => { + const bbox: [number, number, number, number] = [0, 0, 0.0001, 0.0001]; + const vs = fitViewport(bbox, {width: 800, height: 600, maxZoom: 10}); + expect(vs.zoom).toBeLessThanOrEqual(10); + }); +}); + +// --------------------------------------------------------------------------- +// Data skills +// --------------------------------------------------------------------------- + +describe('createColorAccessor', () => { + it('returns blue for domain minimum', () => { + const getColor = createColorAccessor({ + getValue: (d: {v: number}) => d.v, + domainMin: 0, + domainMax: 100, + colorLow: [0, 0, 255], + colorHigh: [255, 0, 0] + }); + const [r, g, b] = getColor({v: 0}); + expect(r).toBe(0); + expect(g).toBe(0); + expect(b).toBe(255); + }); + + it('returns red for domain maximum', () => { + const getColor = createColorAccessor({ + getValue: (d: {v: number}) => d.v, + domainMin: 0, + domainMax: 100, + colorLow: [0, 0, 255], + colorHigh: [255, 0, 0] + }); + const [r, g, b] = getColor({v: 100}); + expect(r).toBe(255); + expect(g).toBe(0); + expect(b).toBe(0); + }); + + it('clamps values outside the domain', () => { + const getColor = createColorAccessor({ + getValue: (d: {v: number}) => d.v, + domainMin: 0, + domainMax: 10 + }); + const belowMin = getColor({v: -5}); + const aboveMax = getColor({v: 999}); + expect(belowMin).toEqual(getColor({v: 0})); + expect(aboveMax).toEqual(getColor({v: 10})); + }); +}); + +describe('createRadiusAccessor', () => { + it('maps domain min to minPixels', () => { + const getRadius = createRadiusAccessor({ + getValue: (d: {size: number}) => d.size, + domainMin: 0, + domainMax: 100, + minPixels: 5, + maxPixels: 20 + }); + expect(getRadius({size: 0})).toBe(5); + expect(getRadius({size: 100})).toBe(20); + }); +}); + +describe('flattenGeoJSON', () => { + it('flattens a FeatureCollection into plain objects', () => { + const geojson = { + type: 'FeatureCollection' as const, + features: [ + { + type: 'Feature' as const, + geometry: {type: 'Point' as const, coordinates: [10, 20] as [number, number]}, + properties: {name: 'A'} + } + ] + }; + const flat = flattenGeoJSON(geojson); + expect(flat).toHaveLength(1); + expect(flat[0].longitude).toBe(10); + expect(flat[0].latitude).toBe(20); + expect(flat[0].name).toBe('A'); + }); +}); + +describe('extractPositions', () => { + it('extracts positions from a FeatureCollection', () => { + const geojson = { + type: 'FeatureCollection' as const, + features: [ + { + type: 'Feature' as const, + geometry: {type: 'Point' as const, coordinates: [1, 2] as [number, number]}, + properties: {} + }, + { + type: 'Feature' as const, + geometry: {type: 'Point' as const, coordinates: [3, 4] as [number, number]}, + properties: {} + } + ] + }; + const positions = extractPositions(geojson); + expect(positions).toEqual([ + [1, 2], + [3, 4] + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Noodles +// --------------------------------------------------------------------------- + +describe('createNoodle', () => { + it('creates a ScatterplotLayer noodle with correct type', () => { + const noodle = createNoodle('ScatterplotLayer', { + data: [{coord: [0, 0]}], + position: 'coord' + }); + expect(noodle.type).toBe('ScatterplotLayer'); + expect((noodle as {position: string}).position).toBe('coord'); + }); + + it('creates a HeatmapLayer noodle', () => { + const noodle = createNoodle('HeatmapLayer', { + data: [], + position: 'coordinates' + }); + expect(noodle.type).toBe('HeatmapLayer'); + }); +}); + +describe('hydrateNoodle', () => { + it('resolves field-path accessors into functions', () => { + const noodle = createNoodle('ScatterplotLayer', { + data: [{coord: [10, 20]}], + position: 'coord', + radius: 'size' + }); + const props = hydrateNoodle(noodle); + expect(typeof props.getPosition).toBe('function'); + expect((props.getPosition as (d: unknown) => unknown)({coord: [10, 20]})).toEqual([10, 20]); + }); + + it('supports nested dot-path accessors', () => { + const noodle = createNoodle('ScatterplotLayer', { + data: [{meta: {size: 42}}], + position: 'coord', + radius: 'meta.size' + }); + const props = hydrateNoodle(noodle); + expect((props.getRadius as (d: unknown) => unknown)({meta: {size: 42}})).toBe(42); + }); + + it('keeps static values as-is', () => { + const noodle = createNoodle('ScatterplotLayer', { + data: [], + position: 'coord', + fillColor: [255, 0, 0] + }); + const props = hydrateNoodle(noodle); + expect(props.getFillColor).toEqual([255, 0, 0]); + }); + + it('hydrates a PathLayer noodle', () => { + const noodle = createNoodle('PathLayer', { + data: [], + path: 'coords', + color: [100, 200, 50] + }); + const props = hydrateNoodle(noodle); + expect(props.getColor).toEqual([100, 200, 50]); + expect(typeof props.getPath).toBe('function'); + }); + + it('hydrates a TextLayer noodle', () => { + const noodle = createNoodle('TextLayer', { + data: [{pos: [1, 2], label: 'hello'}], + position: 'pos', + text: 'label' + }); + const props = hydrateNoodle(noodle); + expect((props.getText as (d: unknown) => unknown)({pos: [1, 2], label: 'hello'})).toBe('hello'); + }); +}); + +describe('validateNoodle', () => { + it('returns valid for a correct ScatterplotLayer noodle', () => { + const result = validateNoodle({ + type: 'ScatterplotLayer', + data: [], + position: 'coord' + }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('reports missing type', () => { + const result = validateNoodle({data: [], position: 'coord'}); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('type'))).toBe(true); + }); + + it('reports non-array data', () => { + const result = validateNoodle({type: 'ScatterplotLayer', data: 'oops', position: 'x'}); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('data'))).toBe(true); + }); + + it('reports missing position for ScatterplotLayer', () => { + const result = validateNoodle({type: 'ScatterplotLayer', data: []}); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('position'))).toBe(true); + }); + + it('reports missing path for PathLayer', () => { + const result = validateNoodle({type: 'PathLayer', data: []}); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('path'))).toBe(true); + }); + + it('reports invalid opacity', () => { + const result = validateNoodle({ + type: 'ScatterplotLayer', + data: [], + position: 'coord', + opacity: 2 + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('opacity'))).toBe(true); + }); + + it('returns invalid for a non-object', () => { + expect(validateNoodle(null).valid).toBe(false); + expect(validateNoodle('string').valid).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// DeckBuilder +// --------------------------------------------------------------------------- + +describe('DeckBuilder', () => { + it('builds a default config', () => { + const config = new DeckBuilder().build(); + expect(config.initialViewState.zoom).toBe(1); + expect(config.controller).toBe(true); + expect(config.layers).toHaveLength(0); + expect(config.width).toBe('100%'); + }); + + it('chains addLayer calls', () => { + const layerA = createScatterplotLayer({data: [], getPosition: () => [0, 0], id: 'a'}); + const layerB = createPathLayer({data: [], getPath: () => [], id: 'b'}); + + const config = new DeckBuilder().addLayer(layerA).addLayer(layerB).build(); + expect(config.layers).toHaveLength(2); + expect(config.layers[0].id).toBe('a'); + expect(config.layers[1].id).toBe('b'); + }); + + it('prependLayer inserts at the start', () => { + const layerA = createScatterplotLayer({data: [], getPosition: () => [0, 0], id: 'a'}); + const layerB = createPathLayer({data: [], getPath: () => [], id: 'b'}); + + const config = new DeckBuilder().addLayer(layerA).prependLayer(layerB).build(); + expect(config.layers[0].id).toBe('b'); + }); + + it('removeLayer removes by id', () => { + const layer = createScatterplotLayer({data: [], getPosition: () => [0, 0], id: 'a'}); + const config = new DeckBuilder().addLayer(layer).removeLayer('a').build(); + expect(config.layers).toHaveLength(0); + }); + + it('replaceLayer swaps by id', () => { + const layerA = createScatterplotLayer({data: [], getPosition: () => [0, 0], id: 'layer'}); + const layerB = createPathLayer({data: [], getPath: () => [], id: 'layer'}); + const config = new DeckBuilder().addLayer(layerA).replaceLayer(layerB).build(); + expect(config.layers[0].type).toBe('PathLayer'); + }); + + it('setViewState is reflected in build output', () => { + const config = new DeckBuilder() + .setViewState({longitude: -74, latitude: 40.7, zoom: 10}) + .build(); + expect(config.initialViewState.longitude).toBe(-74); + }); + + it('setContainer sets the container field', () => { + const config = new DeckBuilder().setContainer('map-div').build(); + expect(config.container).toBe('map-div'); + }); + + it('getLayers does not mutate the internal list', () => { + const builder = new DeckBuilder(); + const layer = createScatterplotLayer({data: [], getPosition: () => [0, 0]}); + builder.addLayer(layer); + const layers = builder.getLayers(); + layers.pop(); + expect(builder.getLayers()).toHaveLength(1); + }); +}); diff --git a/modules/visgl-skills/tsconfig.json b/modules/visgl-skills/tsconfig.json new file mode 100644 index 000000000..175b131b2 --- /dev/null +++ b/modules/visgl-skills/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/yarn.lock b/yarn.lock index 07cd83423..abba0db3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -543,6 +543,19 @@ __metadata: languageName: unknown linkType: soft +"@deck.gl-community/visgl-skills@workspace:modules/visgl-skills": + version: 0.0.0-use.local + resolution: "@deck.gl-community/visgl-skills@workspace:modules/visgl-skills" + peerDependencies: + "@deck.gl/aggregation-layers": ">=9.0.0" + "@deck.gl/core": ">=9.0.0" + "@deck.gl/layers": ">=9.0.0" + peerDependenciesMeta: + "@deck.gl/aggregation-layers": + optional: true + languageName: unknown + linkType: soft + "@deck.gl-community/widgets@workspace:*, @deck.gl-community/widgets@workspace:modules/widgets": version: 0.0.0-use.local resolution: "@deck.gl-community/widgets@workspace:modules/widgets"