From 690149ac4f0e1e42cf4901f17796387f1b2cf854 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sun, 8 Mar 2026 00:11:46 -0500 Subject: [PATCH] feat: add @deck.gl-community/ai-skills module Adds a new module that handles both approaches discussed in #534: - Pattern A (native TypeScript): typed factory functions for all common layer types with sensible defaults, backed by llms.txt reference docs for LLM code generation (addresses akre54's docs-first feedback) - Pattern B (JSON descriptors): fully serializable layer configs with dot-path accessors, validateDescriptor, and hydrateDescriptor for low-code UIs and server-side LLM output (the noodle approach) Also includes DeckBuilder fluent compositor, viewport helpers (fitViewport, getBoundingBox), and a comprehensive llms.txt that serves as the single agent-facing reference for both patterns. 14/14 tests passing. Co-Authored-By: Claude Sonnet 4.6 --- .eslintignore | 1 + .prettierignore | 1 + modules/ai-skills/README.md | 94 ++++++++ modules/ai-skills/llms.txt | 215 +++++++++++++++++ modules/ai-skills/package.json | 46 ++++ modules/ai-skills/src/deck-builder.ts | 50 ++++ modules/ai-skills/src/index.ts | 39 +++ modules/ai-skills/src/layer-descriptors.ts | 128 ++++++++++ modules/ai-skills/src/layer-factories.ts | 226 ++++++++++++++++++ modules/ai-skills/src/types.ts | 64 +++++ modules/ai-skills/src/viewport-skills.ts | 68 ++++++ .../ai-skills/test/layer-factories.spec.ts | 131 ++++++++++ modules/ai-skills/tsconfig.json | 11 + tsconfig.json | 1 + yarn.lock | 9 + 15 files changed, 1084 insertions(+) create mode 100644 modules/ai-skills/README.md create mode 100644 modules/ai-skills/llms.txt create mode 100644 modules/ai-skills/package.json create mode 100644 modules/ai-skills/src/deck-builder.ts create mode 100644 modules/ai-skills/src/index.ts create mode 100644 modules/ai-skills/src/layer-descriptors.ts create mode 100644 modules/ai-skills/src/layer-factories.ts create mode 100644 modules/ai-skills/src/types.ts create mode 100644 modules/ai-skills/src/viewport-skills.ts create mode 100644 modules/ai-skills/test/layer-factories.spec.ts create mode 100644 modules/ai-skills/tsconfig.json diff --git a/.eslintignore b/.eslintignore index 4bf71fd44..01c2670be 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,3 +8,4 @@ public/ modules/arrow-layers/ modules/basemap-props/ *.json +*.txt diff --git a/.prettierignore b/.prettierignore index bb347ea7b..cfbbaddb0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ public/ .cache/ modules/arrow-layers/ modules/basemap-props/ +*.txt diff --git a/modules/ai-skills/README.md b/modules/ai-skills/README.md new file mode 100644 index 000000000..e2fda0919 --- /dev/null +++ b/modules/ai-skills/README.md @@ -0,0 +1,94 @@ +# @deck.gl-community/ai-skills + +AI agent helpers for building deck.gl visualizations. Supports two complementary patterns: + +**Pattern A — Native TypeScript** (recommended for LLM code generation) +Typed factory functions that return correct props with sensible defaults, backed by full TypeScript types. LLMs write native code; `llms.txt` provides the reference. + +**Pattern B — JSON descriptors** (for serializable configs and low-code UIs) +Fully JSON-serializable layer descriptors with dot-path accessors, pre-flight validation, and a hydration step that converts them to runtime functions. Safe to store, transmit, or emit from an LLM to a server. + +## Installation + +```bash +npm install @deck.gl-community/ai-skills +# peer deps +npm install @deck.gl/core @deck.gl/layers +``` + +## Quick start + +See [`llms.txt`](./llms.txt) for the complete agent-facing reference with worked examples for both patterns. + +### Pattern A — factory functions + +```ts +import {ScatterplotLayer} from '@deck.gl/layers'; +import {scatterplotLayer, fitViewport} from '@deck.gl-community/ai-skills'; + +const layer = new ScatterplotLayer( + scatterplotLayer({ + data: cities, + getPosition: (d) => d.coordinates, + getRadius: (d) => d.population, + getFillColor: [255, 140, 0], + radiusScale: 0.00003 + }) +); +const viewState = fitViewport(cities.map((c) => c.coordinates)); +``` + +### Pattern B — JSON descriptors + +```ts +import { + createDescriptor, + validateDescriptor, + hydrateDescriptor +} from '@deck.gl-community/ai-skills'; +import {ScatterplotLayer} from '@deck.gl/layers'; + +const desc = createDescriptor('ScatterplotLayer', { + data: cities, + getPosition: 'coordinates', // dot-path string, resolved at hydration + getFillColor: [255, 140, 0] +}); +const {valid, errors} = validateDescriptor(desc); +const layer = new ScatterplotLayer(hydrateDescriptor(desc)); +``` + +## API + +| Export | Description | +| ------------------------------------------ | ---------------------------------------------------------------- | +| `scatterplotLayer(options)` | Factory for ScatterplotLayer props | +| `pathLayer(options)` | Factory for PathLayer props | +| `polygonLayer(options)` | Factory for PolygonLayer props | +| `textLayer(options)` | Factory for TextLayer props | +| `arcLayer(options)` | Factory for ArcLayer props | +| `heatmapLayer(options)` | Factory for HeatmapLayer props | +| `createDescriptor(type, props, id?)` | Build a JSON-serializable layer descriptor | +| `validateDescriptor(desc)` | Pre-flight validation returning `{valid, errors}` | +| `hydrateDescriptor(desc)` | Resolve dot-path accessors to runtime functions | +| `DeckBuilder` | Fluent builder composing layers + view state into a `DeckConfig` | +| `fitViewport(positions, w?, h?, padding?)` | Fit Web Mercator viewport to a set of coordinates | +| `getBoundingBox(positions)` | Get `[minLng, minLat, maxLng, maxLat]` | +| `createViewState(lng, lat, zoom, opts?)` | Convenience view state constructor | + +## For AI agents + +This module ships `llms.txt` at its package root — a single clean reference file covering both patterns, all layer types, and a decision guide. Point your agent at it: + +``` +https://unpkg.com/@deck.gl-community/ai-skills/llms.txt +``` + +Or read it locally after install: + +``` +node_modules/@deck.gl-community/ai-skills/llms.txt +``` + +## License + +MIT © vis.gl contributors diff --git a/modules/ai-skills/llms.txt b/modules/ai-skills/llms.txt new file mode 100644 index 000000000..260ae19db --- /dev/null +++ b/modules/ai-skills/llms.txt @@ -0,0 +1,215 @@ +# @deck.gl-community/ai-skills + +AI agent reference for deck.gl visualizations. + +This module supports two patterns. Pick the one that fits your context. + +--- + +## Pattern A — Native TypeScript (recommended for code generation) + +Write TypeScript directly. Use the typed factory functions to get correct props +with sensible defaults, then pass them to standard deck.gl layer constructors. + +**Install:** +``` +npm install @deck.gl/core @deck.gl/layers @deck.gl-community/ai-skills +``` + +**Basic map with scatterplot:** +```ts +import {Deck} from '@deck.gl/core'; +import {ScatterplotLayer} from '@deck.gl/layers'; +import {scatterplotLayer, fitViewport} from '@deck.gl-community/ai-skills'; + +const cities = [ + {name: 'New York', coordinates: [-74.006, 40.7128], population: 8_300_000}, + {name: 'Los Angeles', coordinates: [-118.2437, 34.0522], population: 3_900_000}, + {name: 'Chicago', coordinates: [-87.6298, 41.8781], population: 2_700_000} +]; + +const layer = new ScatterplotLayer( + scatterplotLayer({ + data: cities, + getPosition: d => d.coordinates, + getRadius: d => d.population, + getFillColor: [255, 140, 0], + radiusScale: 0.00003, + id: 'cities' + }) +); + +const viewState = fitViewport(cities.map(c => c.coordinates)); + +new Deck({initialViewState: viewState, layers: [layer], ...}); +``` + +**Arc layer (origin→destination flows):** +```ts +import {ArcLayer} from '@deck.gl/layers'; +import {arcLayer} from '@deck.gl-community/ai-skills'; + +const flights = [{source: [-74, 40.7], target: [-118.2, 34.1], value: 1200}]; + +const layer = new ArcLayer( + arcLayer({ + data: flights, + getSourcePosition: d => d.source, + getTargetPosition: d => d.target, + getSourceColor: [0, 128, 200], + getTargetColor: [200, 0, 80], + getWidth: d => Math.sqrt(d.value) + }) +); +``` + +**Heatmap:** +```ts +import {HeatmapLayer} from '@deck.gl/aggregation-layers'; +import {heatmapLayer} from '@deck.gl-community/ai-skills'; + +const layer = new HeatmapLayer( + heatmapLayer({ + data: events, + getPosition: d => [d.lng, d.lat], + getWeight: d => d.intensity, + radiusPixels: 40 + }) +); +``` + +--- + +## Pattern B — JSON descriptors (for serializable configs / low-code UI) + +Produce pure JSON that is safe to store, transmit, or emit from an LLM to a +server. Accessor props are dot-path strings resolved at hydration time. + +**Produce a descriptor:** +```ts +import {createDescriptor, validateDescriptor, hydrateDescriptor} from '@deck.gl-community/ai-skills'; +import {ScatterplotLayer} from '@deck.gl/layers'; + +// Agent or UI emits this as plain JSON — no callbacks, no runtime execution +const desc = createDescriptor('ScatterplotLayer', { + data: cities, + getPosition: 'coordinates', // resolved to d => d.coordinates + getRadius: 'population', // resolved to d => d.population + getFillColor: [255, 140, 0], + radiusScale: 0.00003 +}); + +// Validate before use +const {valid, errors} = validateDescriptor(desc); +if (!valid) throw new Error(errors.join(', ')); + +// Hydrate and render +const layer = new ScatterplotLayer(hydrateDescriptor(desc)); +``` + +**Compose with DeckBuilder:** +```ts +import {DeckBuilder, createDescriptor, fitViewport} from '@deck.gl-community/ai-skills'; + +const config = new DeckBuilder() + .addLayer(createDescriptor('ScatterplotLayer', { + data: cities, + getPosition: 'coordinates', + getFillColor: [255, 140, 0] + })) + .addLayer(createDescriptor('TextLayer', { + data: cities, + getPosition: 'coordinates', + getText: 'name', + getSize: 14 + })) + .setViewState(fitViewport(cities.map(c => c.coordinates))) + .setMapStyle('https://basemaps.cartocdn.com/gl/positron-gl-style/style.json') + .build(); +// config is a plain JSON object — safe to store/transmit +``` + +--- + +## Layer reference + +All layers accept `id` (string), `data` (array or URL), `pickable` (boolean), +`opacity` (0–1). Accessor props (prefixed with `get`) accept functions or, in +descriptor mode, dot-path strings. + +### ScatterplotLayer +Required: `data`, `getPosition` +Key props: `getRadius`, `getFillColor`, `radiusScale`, `radiusUnits` ('meters'|'pixels'), `stroked`, `getLineColor`, `getLineWidth` + +### PathLayer +Required: `data`, `getPath` +Key props: `getColor`, `getWidth`, `widthUnits` ('meters'|'pixels') + +### PolygonLayer +Required: `data`, `getPolygon` +Key props: `getFillColor`, `getLineColor`, `extruded`, `getElevation` + +### TextLayer +Required: `data`, `getText`, `getPosition` +Key props: `getColor`, `getSize`, `sizeUnits`, `getTextAnchor`, `getAlignmentBaseline` + +### ArcLayer +Required: `data`, `getSourcePosition`, `getTargetPosition` +Key props: `getSourceColor`, `getTargetColor`, `getWidth` + +### HeatmapLayer +Required: `data`, `getPosition` +Key props: `getWeight`, `radiusPixels`, `intensity`, `threshold`, `colorRange` + +### GeoJsonLayer +Required: `data` (GeoJSON FeatureCollection or URL) +Key props: `getFillColor`, `getLineColor`, `getLineWidth`, `extruded`, `getElevation` + +--- + +## Viewport helpers + +```ts +import {fitViewport, getBoundingBox, createViewState} from '@deck.gl-community/ai-skills'; + +// Fit viewport to a set of [lng, lat] positions +const vs = fitViewport(positions); // auto zoom +const vs = fitViewport(positions, 1280, 720, 0.05); // custom viewport + padding + +// Just bounding box: [minLng, minLat, maxLng, maxLat] +const bbox = getBoundingBox(positions); + +// Explicit view state +const vs = createViewState(-74.006, 40.7128, 12, {pitch: 45}); +``` + +--- + +## Color conventions + +Colors are `[R, G, B]` or `[R, G, B, A]` with values 0–255. + +```ts +const red: ColorRGBA = [255, 0, 0]; +const semiTransparentBlue: ColorRGBA = [0, 0, 255, 180]; +``` + +--- + +## Decision guide + +| Situation | Use | +|-----------|-----| +| LLM writing TypeScript code directly | Pattern A — factories + native constructors | +| LLM emitting config to a server/API | Pattern B — descriptors | +| Low-code drag-and-drop UI | Pattern B — descriptors | +| UNDO history / saved dashboards | Pattern B — descriptors | +| Need full deck.gl prop surface | Pattern A — factories are a starting point; add extra props directly | + +--- + +## Further reading + +- deck.gl layer catalog: https://deck.gl/docs/api-reference/layers +- deck.gl/json module (runtime JSON rendering): https://deck.gl/docs/api-reference/json/json-converter +- vis.gl community modules: https://github.com/visgl/deck.gl-community diff --git a/modules/ai-skills/package.json b/modules/ai-skills/package.json new file mode 100644 index 000000000..66726cf12 --- /dev/null +++ b/modules/ai-skills/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deck.gl-community/ai-skills", + "version": "9.2.8", + "description": "AI agent helpers for deck.gl — typed factories, JSON descriptors, and llms.txt reference docs", + "license": "MIT", + "_publishConfig": { + "access": "public" + }, + "keywords": [ + "webgl", + "visualization", + "deck.gl", + "ai", + "llm", + "agents" + ], + "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", + "llms.txt" + ], + "scripts": { + "test": "vitest run", + "test-watch": "vitest" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/layers": "~9.2.0" + } +} diff --git a/modules/ai-skills/src/deck-builder.ts b/modules/ai-skills/src/deck-builder.ts new file mode 100644 index 000000000..a021436be --- /dev/null +++ b/modules/ai-skills/src/deck-builder.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * DeckBuilder — fluent builder that composes layer descriptors and view state + * into a single serializable DeckConfig. + * + * Works with both the native-code path (wrap factory output in createDescriptor) + * and the JSON descriptor path directly. + * + * Example: + * import {DeckBuilder, createDescriptor, fitViewport} from '@deck.gl-community/ai-skills'; + * + * const config = new DeckBuilder() + * .addLayer(createDescriptor('ScatterplotLayer', {data: cities, getPosition: 'coordinates'})) + * .setViewState(fitViewport(cities.map(c => c.coordinates))) + * .setMapStyle('https://basemaps.cartocdn.com/gl/positron-gl-style/style.json') + * .build(); + */ + +import type {DeckConfig, LayerDescriptor, ViewState} from './types'; + +export class DeckBuilder { + private _layers: LayerDescriptor[] = []; + private _viewState: ViewState = {longitude: 0, latitude: 0, zoom: 2}; + private _mapStyle?: string; + + addLayer(descriptor: LayerDescriptor): this { + this._layers.push(descriptor); + return this; + } + + setViewState(viewState: ViewState): this { + this._viewState = viewState; + return this; + } + + setMapStyle(mapStyle: string): this { + this._mapStyle = mapStyle; + return this; + } + + build(): DeckConfig { + return { + layers: [...this._layers], + viewState: {...this._viewState}, + ...(this._mapStyle ? {mapStyle: this._mapStyle} : {}) + }; + } +} diff --git a/modules/ai-skills/src/index.ts b/modules/ai-skills/src/index.ts new file mode 100644 index 000000000..7a29c4732 --- /dev/null +++ b/modules/ai-skills/src/index.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Types +export type { + ColorRGBA, + LayerType, + ViewState, + LayerDescriptor, + DeckConfig, + ValidationResult +} from './types'; + +// Native-code path — typed factory functions +export type { + ScatterplotLayerOptions, + PathLayerOptions, + PolygonLayerOptions, + TextLayerOptions, + ArcLayerOptions, + HeatmapLayerOptions +} from './layer-factories'; +export { + scatterplotLayer, + pathLayer, + polygonLayer, + textLayer, + arcLayer, + heatmapLayer +} from './layer-factories'; + +// JSON descriptor path — serializable IR + hydration +export {createDescriptor, validateDescriptor, hydrateDescriptor} from './layer-descriptors'; + +// Fluent builder +export {DeckBuilder} from './deck-builder'; + +// Viewport helpers +export {createViewState, getBoundingBox, fitViewport} from './viewport-skills'; diff --git a/modules/ai-skills/src/layer-descriptors.ts b/modules/ai-skills/src/layer-descriptors.ts new file mode 100644 index 000000000..b65f45464 --- /dev/null +++ b/modules/ai-skills/src/layer-descriptors.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * layer-descriptors — JSON-serializable layer config ("noodle") path. + * + * Use this when you need configs that are safe to store or transmit without + * runtime execution: LLM output to a server, low-code UI builders, UNDO + * history, saved dashboards, etc. + * + * Workflow: + * 1. `createDescriptor` — agent or UI produces a plain JSON object + * 2. `validateDescriptor` — pre-flight check before rendering + * 3. `hydrateDescriptor` — resolve dot-path accessors to runtime functions, + * then spread into a deck.gl layer constructor + * + * Example: + * const desc = createDescriptor('ScatterplotLayer', { + * data: cities, + * getPosition: 'coordinates', // resolved to d => d.coordinates + * getFillColor: [255, 0, 128], + * getRadius: 'population', // resolved to d => d.population + * }); + * const {valid, errors} = validateDescriptor(desc); + * const layer = new ScatterplotLayer(hydrateDescriptor(desc)); + */ + +import type {LayerDescriptor, LayerType, ValidationResult} from './types'; + +// --------------------------------------------------------------------------- +// Required prop names per layer type — used by validateDescriptor +// --------------------------------------------------------------------------- + +const REQUIRED_PROPS: Record = { + ScatterplotLayer: ['data', 'getPosition'], + PathLayer: ['data', 'getPath'], + PolygonLayer: ['data', 'getPolygon'], + TextLayer: ['data', 'getText', 'getPosition'], + IconLayer: ['data', 'getPosition', 'getIcon'], + HeatmapLayer: ['data', 'getPosition'], + ArcLayer: ['data', 'getSourcePosition', 'getTargetPosition'], + ColumnLayer: ['data', 'getPosition'], + GeoJsonLayer: ['data'] +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Construct a JSON-serializable LayerDescriptor. + * Accessor props may be dot-path strings (`"meta.radius"`) or literal values. + */ +export function createDescriptor( + type: LayerType, + props: Record, + id?: string +): LayerDescriptor { + return {type, id: id ?? type, props}; +} + +/** + * Validate a descriptor before hydration. + * Returns `{valid: true, errors: []}` on success. + */ +export function validateDescriptor(descriptor: LayerDescriptor): ValidationResult { + const errors: string[] = []; + const required = REQUIRED_PROPS[descriptor.type]; + + if (!required) { + errors.push(`Unknown layer type: "${descriptor.type}"`); + return {valid: false, errors}; + } + + for (const prop of required) { + if (!(prop in descriptor.props) || descriptor.props[prop] === undefined) { + errors.push(`Missing required prop "${prop}" for ${descriptor.type}`); + } + } + + return {valid: errors.length === 0, errors}; +} + +/** + * Hydrate a descriptor into runtime-ready layer props. + * + * Dot-path string accessors are converted to functions: + * `"meta.size"` => `(d) => d.meta.size` + * `"coordinates"` => `(d) => d.coordinates` + * + * Non-string values (numbers, arrays, existing functions) are passed through. + */ +export function hydrateDescriptor(descriptor: LayerDescriptor): Record { + const hydrated: Record = {id: descriptor.id}; + + for (const [key, value] of Object.entries(descriptor.props)) { + if (typeof value === 'string' && isAccessorProp(key)) { + hydrated[key] = makeDotPathAccessor(value); + } else { + hydrated[key] = value; + } + } + + return hydrated; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Props whose string values should be treated as dot-path accessors */ +const ACCESSOR_PROP_PREFIXES = ['get', 'Get']; + +function isAccessorProp(propName: string): boolean { + return ACCESSOR_PROP_PREFIXES.some((prefix) => propName.startsWith(prefix)); +} + +function makeDotPathAccessor(dotPath: string): (d: unknown) => unknown { + const parts = dotPath.split('.'); + return (d: unknown) => { + let value: unknown = d; + for (const part of parts) { + if (value === null || value === undefined) return undefined; + value = (value as Record)[part]; + } + return value; + }; +} diff --git a/modules/ai-skills/src/layer-factories.ts b/modules/ai-skills/src/layer-factories.ts new file mode 100644 index 000000000..fd6594221 --- /dev/null +++ b/modules/ai-skills/src/layer-factories.ts @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** + * layer-factories — typed helper functions for the native-code path. + * + * Each factory returns a plain props object with sensible defaults that can be + * spread directly into the corresponding deck.gl layer constructor: + * + * import {ScatterplotLayer} from '@deck.gl/layers'; + * import {scatterplotLayer} from '@deck.gl-community/ai-skills'; + * + * const layer = new ScatterplotLayer(scatterplotLayer({data: cities, ...})); + * + * This is the recommended path for LLM code generation: agents write native + * TypeScript backed by full type-checking, guided by llms.txt. + */ + +import type {ColorRGBA} from './types'; + +// --------------------------------------------------------------------------- +// Scatterplot +// --------------------------------------------------------------------------- + +export interface ScatterplotLayerOptions { + data: D[] | string; + id?: string; + getPosition?: ((d: D) => [number, number] | [number, number, number]) | string; + getFillColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getRadius?: ((d: D) => number) | number; + radiusScale?: number; + radiusUnits?: 'pixels' | 'meters'; + stroked?: boolean; + getLineColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getLineWidth?: ((d: D) => number) | number; + opacity?: number; + pickable?: boolean; +} + +const SCATTERPLOT_DEFAULTS = { + id: 'scatterplot-layer', + getPosition: (d: unknown) => (d as {coordinates: [number, number]}).coordinates, + getFillColor: [255, 140, 0] as ColorRGBA, + getRadius: 100, + radiusScale: 1, + radiusUnits: 'meters' as const, + stroked: false, + getLineColor: [0, 0, 0, 200] as ColorRGBA, + getLineWidth: 1, + opacity: 0.8, + pickable: true +}; + +export function scatterplotLayer(options: ScatterplotLayerOptions) { + return {...SCATTERPLOT_DEFAULTS, ...options}; +} + +// --------------------------------------------------------------------------- +// Path +// --------------------------------------------------------------------------- + +export interface PathLayerOptions { + data: D[] | string; + id?: string; + getPath?: (d: D) => [number, number][]; + getColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getWidth?: ((d: D) => number) | number; + widthScale?: number; + widthUnits?: 'pixels' | 'meters'; + opacity?: number; + pickable?: boolean; +} + +export function pathLayer(options: PathLayerOptions) { + return { + id: options.id ?? 'path-layer', + data: options.data, + getPath: options.getPath ?? ((d: unknown) => (d as {path: [number, number][]}).path), + getColor: options.getColor ?? ([255, 165, 0] as ColorRGBA), + getWidth: options.getWidth ?? 5, + widthScale: options.widthScale ?? 1, + widthUnits: options.widthUnits ?? 'pixels', + opacity: options.opacity ?? 0.8, + pickable: options.pickable ?? true + }; +} + +// --------------------------------------------------------------------------- +// Polygon +// --------------------------------------------------------------------------- + +export interface PolygonLayerOptions { + data: D[] | string; + id?: string; + getPolygon?: (d: D) => [number, number][]; + getFillColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getLineColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getLineWidth?: ((d: D) => number) | number; + stroked?: boolean; + filled?: boolean; + extruded?: boolean; + getElevation?: ((d: D) => number) | number; + opacity?: number; + pickable?: boolean; +} + +const POLYGON_DEFAULTS = { + id: 'polygon-layer', + getPolygon: (d: unknown) => (d as {contour: [number, number][]}).contour, + getFillColor: [0, 128, 255, 180] as ColorRGBA, + getLineColor: [255, 255, 255] as ColorRGBA, + getLineWidth: 1, + stroked: true, + filled: true, + extruded: false, + getElevation: 0, + opacity: 0.8, + pickable: true +}; + +export function polygonLayer(options: PolygonLayerOptions) { + return {...POLYGON_DEFAULTS, ...options}; +} + +// --------------------------------------------------------------------------- +// Text +// --------------------------------------------------------------------------- + +export interface TextLayerOptions { + data: D[] | string; + id?: string; + getText?: (d: D) => string; + getPosition?: (d: D) => [number, number] | [number, number, number]; + getColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getSize?: ((d: D) => number) | number; + sizeUnits?: 'pixels' | 'meters'; + getAngle?: ((d: D) => number) | number; + getTextAnchor?: ((d: D) => string) | string; + getAlignmentBaseline?: ((d: D) => string) | string; + pickable?: boolean; +} + +export function textLayer(options: TextLayerOptions) { + return { + id: options.id ?? 'text-layer', + data: options.data, + getText: options.getText ?? ((d: unknown) => String((d as {name: string}).name)), + getPosition: + options.getPosition ?? ((d: unknown) => (d as {coordinates: [number, number]}).coordinates), + getColor: options.getColor ?? ([255, 255, 255] as ColorRGBA), + getSize: options.getSize ?? 14, + sizeUnits: options.sizeUnits ?? 'pixels', + getAngle: options.getAngle ?? 0, + getTextAnchor: options.getTextAnchor ?? 'middle', + getAlignmentBaseline: options.getAlignmentBaseline ?? 'center', + pickable: options.pickable ?? true + }; +} + +// --------------------------------------------------------------------------- +// Arc +// --------------------------------------------------------------------------- + +export interface ArcLayerOptions { + data: D[] | string; + id?: string; + getSourcePosition?: (d: D) => [number, number]; + getTargetPosition?: (d: D) => [number, number]; + getSourceColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getTargetColor?: ((d: D) => ColorRGBA) | ColorRGBA; + getWidth?: ((d: D) => number) | number; + opacity?: number; + pickable?: boolean; +} + +export function arcLayer(options: ArcLayerOptions) { + return { + id: options.id ?? 'arc-layer', + data: options.data, + getSourcePosition: + options.getSourcePosition ?? ((d: unknown) => (d as {source: [number, number]}).source), + getTargetPosition: + options.getTargetPosition ?? ((d: unknown) => (d as {target: [number, number]}).target), + getSourceColor: options.getSourceColor ?? ([0, 128, 200] as ColorRGBA), + getTargetColor: options.getTargetColor ?? ([200, 0, 80] as ColorRGBA), + getWidth: options.getWidth ?? 2, + opacity: options.opacity ?? 0.8, + pickable: options.pickable ?? true + }; +} + +// --------------------------------------------------------------------------- +// Heatmap +// --------------------------------------------------------------------------- + +export interface HeatmapLayerOptions { + data: D[] | string; + id?: string; + getPosition?: (d: D) => [number, number]; + getWeight?: ((d: D) => number) | number; + radiusPixels?: number; + intensity?: number; + threshold?: number; + colorRange?: ColorRGBA[]; +} + +export function heatmapLayer(options: HeatmapLayerOptions) { + return { + id: options.id ?? 'heatmap-layer', + data: options.data, + getPosition: + options.getPosition ?? ((d: unknown) => (d as {coordinates: [number, number]}).coordinates), + getWeight: options.getWeight ?? 1, + radiusPixels: options.radiusPixels ?? 30, + intensity: options.intensity ?? 1, + threshold: options.threshold ?? 0.03, + colorRange: options.colorRange ?? [ + [255, 255, 178], + [254, 217, 118], + [254, 178, 76], + [253, 141, 60], + [240, 59, 32], + [189, 0, 38] + ] + }; +} diff --git a/modules/ai-skills/src/types.ts b/modules/ai-skills/src/types.ts new file mode 100644 index 000000000..1747b6c2e --- /dev/null +++ b/modules/ai-skills/src/types.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +/** RGBA color as a 3- or 4-element tuple */ +export type ColorRGBA = [number, number, number] | [number, number, number, number]; + +/** Supported layer types for the JSON descriptor path */ +export type LayerType = + | 'ScatterplotLayer' + | 'PathLayer' + | 'PolygonLayer' + | 'TextLayer' + | 'IconLayer' + | 'HeatmapLayer' + | 'ArcLayer' + | 'ColumnLayer' + | 'GeoJsonLayer'; + +/** + * Web Mercator view state understood by deck.gl's MapView. + */ +export interface ViewState { + longitude: number; + latitude: number; + zoom: number; + pitch?: number; + bearing?: number; + minZoom?: number; + maxZoom?: number; +} + +/** + * Fully-serializable layer descriptor — the JSON IR ("noodle") approach. + * + * Accessor props may be dot-path strings (e.g. `"meta.size"`) that are + * resolved to runtime functions by `hydrateDescriptor`. + * This format is safe to store, transmit, and emit from LLMs that must not + * produce executable code. + */ +export interface LayerDescriptor { + /** deck.gl layer class name */ + type: LayerType; + /** Stable id for reconciliation; defaults to `type` if omitted */ + id?: string; + /** Layer props — accessor values may be dot-path strings or literals */ + props: Record; +} + +/** + * Top-level deck.gl configuration returned by DeckBuilder. + */ +export interface DeckConfig { + layers: LayerDescriptor[]; + viewState: ViewState; + mapStyle?: string; +} + +/** + * Result of validating a LayerDescriptor before hydration. + */ +export interface ValidationResult { + valid: boolean; + errors: string[]; +} diff --git a/modules/ai-skills/src/viewport-skills.ts b/modules/ai-skills/src/viewport-skills.ts new file mode 100644 index 000000000..54e394768 --- /dev/null +++ b/modules/ai-skills/src/viewport-skills.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import type {ViewState} from './types'; + +/** + * Create a basic Web Mercator view state. + */ +export function createViewState( + longitude: number, + latitude: number, + zoom: number, + options: Partial = {} +): ViewState { + return {longitude, latitude, zoom, pitch: 0, bearing: 0, ...options}; +} + +/** + * Compute the bounding box of an array of [lng, lat] positions. + * Returns [minLng, minLat, maxLng, maxLat]. + */ +export function getBoundingBox( + positions: [number, number][] +): [number, number, number, number] | null { + if (positions.length === 0) return null; + let minLng = Infinity; + let minLat = Infinity; + let maxLng = -Infinity; + let maxLat = -Infinity; + for (const [lng, lat] of positions) { + 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]; +} + +/** + * Fit a Web Mercator viewport to a set of [lng, lat] positions. + * `viewportWidth` and `viewportHeight` default to 800×600 if omitted. + * + * Returns a ViewState centered on the data with a zoom level that fits all + * points with optional padding (in degrees, default 0.1). + */ +export function fitViewport( + positions: [number, number][], + viewportWidth = 800, + viewportHeight = 600, + paddingDeg = 0.1 +): ViewState { + const bbox = getBoundingBox(positions); + if (!bbox) return {longitude: 0, latitude: 0, zoom: 2}; + + const [minLng, minLat, maxLng, maxLat] = bbox; + const centerLng = (minLng + maxLng) / 2; + const centerLat = (minLat + maxLat) / 2; + + const lngSpan = maxLng - minLng + paddingDeg * 2; + const latSpan = maxLat - minLat + paddingDeg * 2; + + // Mercator zoom: fit the larger of the two spans to the viewport + const zoomLng = Math.log2((viewportWidth / 256) * (360 / lngSpan)); + const zoomLat = Math.log2((viewportHeight / 256) * (180 / latSpan)); + const zoom = Math.max(0, Math.min(20, Math.floor(Math.min(zoomLng, zoomLat)))); + + return {longitude: centerLng, latitude: centerLat, zoom, pitch: 0, bearing: 0}; +} diff --git a/modules/ai-skills/test/layer-factories.spec.ts b/modules/ai-skills/test/layer-factories.spec.ts new file mode 100644 index 000000000..e08bcd87a --- /dev/null +++ b/modules/ai-skills/test/layer-factories.spec.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, it, expect} from 'vitest'; +import {scatterplotLayer, arcLayer, heatmapLayer} from '../src/layer-factories'; +import {createDescriptor, validateDescriptor, hydrateDescriptor} from '../src/layer-descriptors'; +import {DeckBuilder} from '../src/deck-builder'; +import {fitViewport, getBoundingBox} from '../src/viewport-skills'; + +describe('scatterplotLayer', () => { + it('applies sensible defaults', () => { + const props = scatterplotLayer({data: [], id: 'test'}); + expect(props.id).toBe('test'); + expect(props.radiusScale).toBe(1); + expect(props.pickable).toBe(true); + }); + + it('overrides defaults', () => { + const props = scatterplotLayer({data: [], opacity: 0.5, radiusScale: 2}); + expect(props.opacity).toBe(0.5); + expect(props.radiusScale).toBe(2); + }); +}); + +describe('arcLayer', () => { + it('returns default colors', () => { + const props = arcLayer({data: []}); + expect(props.getSourceColor).toEqual([0, 128, 200]); + expect(props.getTargetColor).toEqual([200, 0, 80]); + }); +}); + +describe('heatmapLayer', () => { + it('returns a 6-stop color range by default', () => { + const props = heatmapLayer({data: []}); + expect(props.colorRange).toHaveLength(6); + }); +}); + +describe('createDescriptor / validateDescriptor', () => { + it('validates a correct descriptor', () => { + const desc = createDescriptor('ScatterplotLayer', { + data: [], + getPosition: 'coordinates' + }); + const result = validateDescriptor(desc); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('reports missing required props', () => { + const desc = createDescriptor('ScatterplotLayer', {data: []}); + const result = validateDescriptor(desc); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.includes('getPosition'))).toBe(true); + }); + + it('reports unknown layer type', () => { + const desc = createDescriptor('FakeLayer' as never, {data: []}); + const result = validateDescriptor(desc); + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatch(/Unknown layer type/); + }); +}); + +describe('hydrateDescriptor', () => { + it('resolves dot-path accessor strings to functions', () => { + const desc = createDescriptor('ScatterplotLayer', { + data: [], + getPosition: 'location.coords', + getFillColor: [255, 0, 0] + }); + const hydrated = hydrateDescriptor(desc); + expect(typeof hydrated.getPosition).toBe('function'); + expect(typeof hydrated.getFillColor).not.toBe('function'); + }); + + it('resolves nested dot-paths correctly', () => { + const desc = createDescriptor('ScatterplotLayer', { + data: [], + getPosition: 'meta.position' + }); + const hydrated = hydrateDescriptor(desc); + const accessor = hydrated.getPosition as (d: unknown) => unknown; + expect(accessor({meta: {position: [1, 2]}})).toEqual([1, 2]); + }); +}); + +describe('DeckBuilder', () => { + it('builds a DeckConfig with layers and viewState', () => { + const config = new DeckBuilder() + .addLayer(createDescriptor('ScatterplotLayer', {data: [], getPosition: 'coords'})) + .setViewState({longitude: -74, latitude: 40.7, zoom: 10}) + .build(); + + expect(config.layers).toHaveLength(1); + expect(config.viewState.zoom).toBe(10); + expect(config.mapStyle).toBeUndefined(); + }); + + it('includes mapStyle when set', () => { + const config = new DeckBuilder().setMapStyle('https://example.com/style.json').build(); + expect(config.mapStyle).toBe('https://example.com/style.json'); + }); +}); + +describe('viewport helpers', () => { + const positions: [number, number][] = [ + [-74.006, 40.7128], + [-118.2437, 34.0522], + [-87.6298, 41.8781] + ]; + + it('getBoundingBox returns correct bounds', () => { + const bbox = getBoundingBox(positions); + if (!bbox) throw new Error('expected bbox'); + expect(bbox[0]).toBe(-118.2437); + expect(bbox[3]).toBe(41.8781); + }); + + it('getBoundingBox returns null for empty array', () => { + expect(getBoundingBox([])).toBeNull(); + }); + + it('fitViewport returns a valid zoom level', () => { + const vs = fitViewport(positions); + expect(vs.zoom).toBeGreaterThanOrEqual(0); + expect(vs.zoom).toBeLessThanOrEqual(20); + expect(vs.longitude).toBeCloseTo(-96.12, 1); + }); +}); diff --git a/modules/ai-skills/tsconfig.json b/modules/ai-skills/tsconfig.json new file mode 100644 index 000000000..175b131b2 --- /dev/null +++ b/modules/ai-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/tsconfig.json b/tsconfig.json index dcc5733fe..cabae2a4c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ "@deck.gl-community/geo-layers": ["./modules/geo-layers/src"], "@deck.gl-community/infovis-layers": ["./modules/infovis-layers/src"], "@deck.gl-community/react": ["./modules/react/src"], + "@deck.gl-community/ai-skills": ["./modules/ai-skills/src"], "@deck.gl-community/template": ["./modules/template/src"], "@deck.gl-community/graph-layers": ["./modules/graph-layers/src"], "@deck.gl-community/three": ["./modules/three/src"] diff --git a/yarn.lock b/yarn.lock index 07cd83423..0c09a0694 100644 --- a/yarn.lock +++ b/yarn.lock @@ -286,6 +286,15 @@ __metadata: languageName: node linkType: hard +"@deck.gl-community/ai-skills@workspace:modules/ai-skills": + version: 0.0.0-use.local + resolution: "@deck.gl-community/ai-skills@workspace:modules/ai-skills" + peerDependencies: + "@deck.gl/core": ~9.2.0 + "@deck.gl/layers": ~9.2.0 + languageName: unknown + linkType: soft + "@deck.gl-community/arrow-layers@workspace:*, @deck.gl-community/arrow-layers@workspace:modules/arrow-layers": version: 0.0.0-use.local resolution: "@deck.gl-community/arrow-layers@workspace:modules/arrow-layers"