From 78bf6b267b8440fbc0d64a814cdc13d9004ed675 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:44:51 +0000 Subject: [PATCH 1/4] Initial plan From 2d179f56f6015f5827d7d561c16541fc99a80922 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:03:55 +0000 Subject: [PATCH 2/4] feat(editable-layers): Add CoordinateSystem abstraction layer for non-geographic editing Co-authored-by: charlieforward9 <62311337+charlieforward9@users.noreply.github.com> --- .../src/edit-modes/coordinate-system.ts | 125 +++++++++++++++ .../src/edit-modes/measure-distance-mode.ts | 24 +-- .../src/edit-modes/translate-mode.ts | 13 +- .../editable-layers/src/edit-modes/types.ts | 4 + modules/editable-layers/src/index.ts | 8 + .../src/utils/translate-from-center.ts | 28 ++-- .../test/edit-modes/coordinate-system.spec.ts | 147 ++++++++++++++++++ .../__snapshots__/translate-mode.spec.ts.snap | 40 ++--- 8 files changed, 335 insertions(+), 54 deletions(-) create mode 100644 modules/editable-layers/src/edit-modes/coordinate-system.ts create mode 100644 modules/editable-layers/test/edit-modes/coordinate-system.spec.ts diff --git a/modules/editable-layers/src/edit-modes/coordinate-system.ts b/modules/editable-layers/src/edit-modes/coordinate-system.ts new file mode 100644 index 000000000..3f4f38ef1 --- /dev/null +++ b/modules/editable-layers/src/edit-modes/coordinate-system.ts @@ -0,0 +1,125 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import turfBearing from '@turf/bearing'; +import turfDistance from '@turf/distance'; +import turfDestination from '@turf/destination'; +import turfMidpoint from '@turf/midpoint'; +import {point} from '@turf/helpers'; + +import {Position} from '../utils/geojson-types'; + +/** + * Abstraction layer for coordinate system math used by edit modes. + * + * Allows edit modes to be used with geographic (WGS84) or Cartesian (screen/local) + * coordinate systems without changing the mode logic. + * + * - GeoCoordinateSystem: wraps turf.js — uses geodesic math, assumes WGS84 lon/lat. + * - CartesianCoordinateSystem: uses Euclidean math — suitable for OrthographicView or pixel space. + */ +export interface CoordinateSystem { + /** + * Returns the distance between two positions. + * For GeoCoordinateSystem the unit is kilometers; for CartesianCoordinateSystem it is the + * native coordinate unit (consistent with destination()). + */ + distance(a: Position, b: Position): number; + + /** + * Returns the bearing from position `a` to position `b`. + * Uses compass convention: 0° = north, clockwise, range [-180, 180]. + */ + bearing(a: Position, b: Position): number; + + /** + * Returns a new position reached by traveling `distance` units in the given `bearing` + * direction from `origin`. + */ + destination(origin: Position, distance: number, bearing: number): Position; + + /** + * Returns the midpoint between two positions. + */ + midpoint(a: Position, b: Position): Position; +} + +/** + * Geographic coordinate system using turf.js (WGS84 lon/lat, geodesic math). + * This is the default and preserves the existing behavior of all edit modes. + */ +export class GeoCoordinateSystem implements CoordinateSystem { + distance(a: Position, b: Position): number { + return turfDistance(point(a), point(b)); + } + + bearing(a: Position, b: Position): number { + return turfBearing(point(a), point(b)); + } + + destination(origin: Position, distance: number, bearing: number): Position { + return turfDestination(point(origin), distance, bearing).geometry.coordinates; + } + + midpoint(a: Position, b: Position): Position { + return turfMidpoint(point(a), point(b)).geometry.coordinates; + } +} + +/** + * Cartesian (Euclidean) coordinate system for non-geographic use cases such as + * OrthographicView or pixel/local coordinates. + * + * Bearing follows the same compass convention as the geographic system so that + * destination() and bearing() are consistent with each other: + * 0° = +Y axis, 90° = +X axis (clockwise from north/up). + */ +export class CartesianCoordinateSystem implements CoordinateSystem { + distance(a: Position, b: Position): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + return Math.sqrt(dx * dx + dy * dy); + } + + bearing(a: Position, b: Position): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + // atan2(dx, dy): angle from the +Y axis, clockwise — matches compass bearing convention + const angle = Math.atan2(dx, dy) * (180 / Math.PI); + // Normalize to [-180, 180] to match turf's bearing range + return angle > 180 ? angle - 360 : angle <= -180 ? angle + 360 : angle; + } + + destination(origin: Position, distance: number, bearing: number): Position { + const bearingRad = bearing * (Math.PI / 180); + return [ + origin[0] + distance * Math.sin(bearingRad), + origin[1] + distance * Math.cos(bearingRad) + ] as Position; + } + + midpoint(a: Position, b: Position): Position { + return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as Position; + } +} + +/** + * Default geographic coordinate system instance. + * Used by edit modes when no coordinate system is provided. + */ +export const geoCoordinateSystem = new GeoCoordinateSystem(); + +/** + * Default Cartesian coordinate system instance. + * Use this with OrthographicView or any non-geographic coordinate system. + */ +export const cartesianCoordinateSystem = new CartesianCoordinateSystem(); + +/** + * Returns the provided coordinate system, or `geoCoordinateSystem` as the default. + * Use this helper inside edit modes to avoid null checks scattered throughout the code. + */ +export function getCoordinateSystem(coordinateSystem?: CoordinateSystem): CoordinateSystem { + return coordinateSystem ?? geoCoordinateSystem; +} diff --git a/modules/editable-layers/src/edit-modes/measure-distance-mode.ts b/modules/editable-layers/src/edit-modes/measure-distance-mode.ts index 672df7f96..c62ea87dd 100644 --- a/modules/editable-layers/src/edit-modes/measure-distance-mode.ts +++ b/modules/editable-layers/src/edit-modes/measure-distance-mode.ts @@ -2,8 +2,6 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import turfDistance from '@turf/distance'; -import turfMidpoint from '@turf/midpoint'; import {FeatureCollection} from '../utils/geojson-types'; import { ClickEvent, @@ -15,15 +13,16 @@ import { } from './types'; import {getPickedEditHandle} from './utils'; import {GeoJsonEditMode} from './geojson-edit-mode'; +import {getCoordinateSystem} from './coordinate-system'; export class MeasureDistanceMode extends GeoJsonEditMode { _isMeasuringSessionFinished = false; _currentTooltips: Tooltip[] = []; _currentDistance = 0; - _calculateDistanceForTooltip = ({positionA, positionB, modeConfig}) => { - const {turfOptions, measurementCallback} = modeConfig || {}; - const distance = turfDistance(positionA, positionB, turfOptions); + _calculateDistanceForTooltip = ({positionA, positionB, modeConfig, coordinateSystem}) => { + const {measurementCallback} = modeConfig || {}; + const distance = getCoordinateSystem(coordinateSystem).distance(positionA, positionB); if (measurementCallback) { measurementCallback(distance); @@ -50,6 +49,7 @@ export class MeasureDistanceMode extends GeoJsonEditMode { handleClick(event: ClickEvent, props: ModeProps) { const {modeConfig, data, onEdit} = props; const {centerTooltipsOnLine = false} = modeConfig || {}; + const coordSys = getCoordinateSystem(props.coordinateSystem); // restart measuring session if (this._isMeasuringSessionFinished) { @@ -83,14 +83,15 @@ export class MeasureDistanceMode extends GeoJsonEditMode { this._currentDistance += this._calculateDistanceForTooltip({ positionA: clickSequence[clickSequence.length - 2], positionB: clickSequence[clickSequence.length - 1], - modeConfig + modeConfig, + coordinateSystem: coordSys }); const tooltipPosition = centerTooltipsOnLine - ? turfMidpoint( + ? coordSys.midpoint( clickSequence[clickSequence.length - 2], clickSequence[clickSequence.length - 1] - ).geometry.coordinates + ) : event.mapCoords; this._currentTooltips.push({ @@ -192,17 +193,18 @@ export class MeasureDistanceMode extends GeoJsonEditMode { const {lastPointerMoveEvent, modeConfig} = props; const {centerTooltipsOnLine = false} = modeConfig || {}; const positions = this.getClickSequence(); + const coordSys = getCoordinateSystem(props.coordinateSystem); if (positions.length > 0 && lastPointerMoveEvent && !this._isMeasuringSessionFinished) { const distance = this._calculateDistanceForTooltip({ positionA: positions[positions.length - 1], positionB: lastPointerMoveEvent.mapCoords, - modeConfig: props.modeConfig + modeConfig: props.modeConfig, + coordinateSystem: coordSys }); const tooltipPosition = centerTooltipsOnLine - ? turfMidpoint(positions[positions.length - 1], lastPointerMoveEvent.mapCoords).geometry - .coordinates + ? coordSys.midpoint(positions[positions.length - 1], lastPointerMoveEvent.mapCoords) : lastPointerMoveEvent.mapCoords; return [ diff --git a/modules/editable-layers/src/edit-modes/translate-mode.ts b/modules/editable-layers/src/edit-modes/translate-mode.ts index 4bb4f6258..bae9728a8 100644 --- a/modules/editable-layers/src/edit-modes/translate-mode.ts +++ b/modules/editable-layers/src/edit-modes/translate-mode.ts @@ -2,10 +2,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -import turfBearing from '@turf/bearing'; -import turfDistance from '@turf/distance'; import clone from '@turf/clone'; -import {point} from '@turf/helpers'; import {WebMercatorViewport} from '@math.gl/web-mercator'; import { FeatureCollection, @@ -24,6 +21,7 @@ import {mapCoords} from './utils'; import {translateFromCenter} from '../utils/translate-from-center'; import {GeoJsonEditMode, GeoJsonEditAction} from './geojson-edit-mode'; import {ImmutableFeatureCollection} from './immutable-feature-collection'; +import {getCoordinateSystem} from './coordinate-system'; export class TranslateMode extends GeoJsonEditMode { _geometryBeforeTranslate: SimpleFeatureCollection | null | undefined; @@ -142,14 +140,13 @@ export class TranslateMode extends GeoJsonEditMode { } } } else { - const p1 = point(startDragPoint); - const p2 = point(currentPoint); + const coordinateSystem = getCoordinateSystem(props.coordinateSystem); - const distanceMoved = turfDistance(p1, p2); - const direction = turfBearing(p1, p2); + const distanceMoved = coordinateSystem.distance(startDragPoint, currentPoint); + const direction = coordinateSystem.bearing(startDragPoint, currentPoint); const movedFeatures = this._geometryBeforeTranslate.features.map((feature) => - translateFromCenter(clone(feature), distanceMoved, direction) + translateFromCenter(clone(feature), distanceMoved, direction, coordinateSystem) ); for (let i = 0; i < selectedIndexes.length; i++) { diff --git a/modules/editable-layers/src/edit-modes/types.ts b/modules/editable-layers/src/edit-modes/types.ts index afb587b40..491cd24a4 100644 --- a/modules/editable-layers/src/edit-modes/types.ts +++ b/modules/editable-layers/src/edit-modes/types.ts @@ -3,6 +3,7 @@ // Copyright (c) vis.gl contributors import {Position, Point, SimpleGeometry, Feature} from '../utils/geojson-types'; +import type {CoordinateSystem} from './coordinate-system'; export type ScreenCoordinates = [number, number]; @@ -135,6 +136,9 @@ export type ModeProps = { // The last pointer move event that occurred lastPointerMoveEvent: PointerMoveEvent; + // Coordinate system to use for geometric calculations (defaults to GeoCoordinateSystem) + coordinateSystem?: CoordinateSystem; + // Callback used to notify applications of an edit action onEdit: (editAction: EditAction) => void; diff --git a/modules/editable-layers/src/index.ts b/modules/editable-layers/src/index.ts index 65ad3c82f..334ea5e5a 100644 --- a/modules/editable-layers/src/index.ts +++ b/modules/editable-layers/src/index.ts @@ -40,6 +40,14 @@ export type {EditMode} from './edit-modes/edit-mode'; export type {GeoJsonEditModeType} from './edit-modes/geojson-edit-mode'; export type {GeoJsonEditModeConstructor} from './edit-modes/geojson-edit-mode'; +export type {CoordinateSystem} from './edit-modes/coordinate-system'; +export {GeoCoordinateSystem, CartesianCoordinateSystem} from './edit-modes/coordinate-system'; +export { + geoCoordinateSystem, + cartesianCoordinateSystem, + getCoordinateSystem +} from './edit-modes/coordinate-system'; + export type {EditableGeoJsonLayerProps} from './editable-layers/editable-geojson-layer'; export type {SelectionLayerProps} from './editable-layers/selection-layer'; diff --git a/modules/editable-layers/src/utils/translate-from-center.ts b/modules/editable-layers/src/utils/translate-from-center.ts index f3e1ab1d0..8edd0c526 100644 --- a/modules/editable-layers/src/utils/translate-from-center.ts +++ b/modules/editable-layers/src/utils/translate-from-center.ts @@ -3,29 +3,27 @@ // Copyright (c) vis.gl contributors import turfCenter from '@turf/center'; -import turfRhumbBearing from '@turf/rhumb-bearing'; -import turfRhumbDistance from '@turf/rhumb-distance'; -import turfRhumbDestination from '@turf/rhumb-destination'; import {mapCoords} from '../edit-modes/utils'; +import {geoCoordinateSystem} from '../edit-modes/coordinate-system'; +import type {CoordinateSystem} from '../edit-modes/coordinate-system'; import type {SimpleFeature} from './geojson-types'; // This function takes feature's center, moves it, // and builds new feature around it keeping the proportions -export function translateFromCenter(feature: SimpleFeature, distance: number, direction: number) { - const initialCenterPoint = turfCenter(feature); +export function translateFromCenter( + feature: SimpleFeature, + distance: number, + direction: number, + coordinateSystem: CoordinateSystem = geoCoordinateSystem +) { + const initialCenter = turfCenter(feature).geometry.coordinates; - const movedCenterPoint = turfRhumbDestination(initialCenterPoint, distance, direction); + const movedCenter = coordinateSystem.destination(initialCenter, distance, direction); const movedCoordinates = mapCoords(feature.geometry.coordinates, (coordinate) => { - const rhumbDistance = turfRhumbDistance(initialCenterPoint.geometry.coordinates, coordinate); - const rhumbDirection = turfRhumbBearing(initialCenterPoint.geometry.coordinates, coordinate); - - const movedPosition = turfRhumbDestination( - movedCenterPoint.geometry.coordinates, - rhumbDistance, - rhumbDirection - ).geometry.coordinates; - return movedPosition; + const dist = coordinateSystem.distance(initialCenter, coordinate); + const dir = coordinateSystem.bearing(initialCenter, coordinate); + return coordinateSystem.destination(movedCenter, dist, dir); }); feature.geometry.coordinates = movedCoordinates; diff --git a/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts b/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts new file mode 100644 index 000000000..bfe6f8fb8 --- /dev/null +++ b/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts @@ -0,0 +1,147 @@ +// deck.gl-community +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe, test, expect} from 'vitest'; +import { + GeoCoordinateSystem, + CartesianCoordinateSystem, + geoCoordinateSystem, + cartesianCoordinateSystem +} from '../../src/edit-modes/coordinate-system'; + +// Tolerance for floating-point comparisons +const CART_TOLERANCE = 1e-6; + +describe('GeoCoordinateSystem', () => { + const cs = new GeoCoordinateSystem(); + + test('distance: same point returns 0', () => { + expect(cs.distance([0, 0], [0, 0])).toBeCloseTo(0, 5); + }); + + test('distance: known distance along equator (~111km per degree)', () => { + const d = cs.distance([0, 0], [1, 0]); + expect(d).toBeGreaterThan(110); + expect(d).toBeLessThan(112); + }); + + test('bearing: due east is ~90°', () => { + expect(cs.bearing([0, 0], [1, 0])).toBeCloseTo(90, 0); + }); + + test('bearing: due north is 0°', () => { + expect(cs.bearing([0, 0], [0, 1])).toBeCloseTo(0, 0); + }); + + test('bearing: due west is ~-90°', () => { + expect(cs.bearing([0, 0], [-1, 0])).toBeCloseTo(-90, 0); + }); + + test('destination: round-trip with bearing/distance', () => { + const start: [number, number] = [10, 20]; + const dist = cs.distance(start, [11, 20]); + const brng = cs.bearing(start, [11, 20]); + const dest = cs.destination(start, dist, brng); + expect(dest[0]).toBeCloseTo(11, 3); + expect(dest[1]).toBeCloseTo(20, 3); + }); + + test('midpoint: midpoint between two positions', () => { + const mid = cs.midpoint([0, 0], [2, 0]); + expect(mid[0]).toBeCloseTo(1, 3); + expect(mid[1]).toBeCloseTo(0, 3); + }); + + test('singleton geoCoordinateSystem is a GeoCoordinateSystem', () => { + expect(geoCoordinateSystem).toBeInstanceOf(GeoCoordinateSystem); + }); +}); + +describe('CartesianCoordinateSystem', () => { + const cs = new CartesianCoordinateSystem(); + + test('distance: same point returns 0', () => { + expect(cs.distance([0, 0], [0, 0])).toBeCloseTo(0, 5); + }); + + test('distance: Pythagorean 3-4-5 triangle', () => { + expect(cs.distance([0, 0], [3, 4])).toBeCloseTo(5, 5); + }); + + test('distance: horizontal segment', () => { + expect(cs.distance([0, 0], [100, 0])).toBeCloseTo(100, 5); + }); + + test('distance: vertical segment', () => { + expect(cs.distance([0, 0], [0, 50])).toBeCloseTo(50, 5); + }); + + test('bearing: due east (positive X) is 90°', () => { + expect(cs.bearing([0, 0], [1, 0])).toBeCloseTo(90, 5); + }); + + test('bearing: due north (positive Y) is 0°', () => { + expect(cs.bearing([0, 0], [0, 1])).toBeCloseTo(0, 5); + }); + + test('bearing: due south (negative Y) is 180°', () => { + expect(Math.abs(cs.bearing([0, 0], [0, -1]))).toBeCloseTo(180, 5); + }); + + test('bearing: due west (negative X) is -90°', () => { + expect(cs.bearing([0, 0], [-1, 0])).toBeCloseTo(-90, 5); + }); + + test('destination: move east by 100 units', () => { + const dest = cs.destination([0, 0], 100, 90); + expect(dest[0]).toBeCloseTo(100, CART_TOLERANCE); + expect(dest[1]).toBeCloseTo(0, CART_TOLERANCE); + }); + + test('destination: move north by 50 units', () => { + const dest = cs.destination([0, 0], 50, 0); + expect(dest[0]).toBeCloseTo(0, CART_TOLERANCE); + expect(dest[1]).toBeCloseTo(50, CART_TOLERANCE); + }); + + test('destination: round-trip with bearing/distance', () => { + const start: [number, number] = [100, 200]; + const end: [number, number] = [400, 600]; + const dist = cs.distance(start, end); + const brng = cs.bearing(start, end); + const dest = cs.destination(start, dist, brng); + expect(dest[0]).toBeCloseTo(end[0], 5); + expect(dest[1]).toBeCloseTo(end[1], 5); + }); + + test('midpoint: midpoint between two positions', () => { + const mid = cs.midpoint([0, 0], [200, 100]); + expect(mid[0]).toBeCloseTo(100, 5); + expect(mid[1]).toBeCloseTo(50, 5); + }); + + test('singleton cartesianCoordinateSystem is a CartesianCoordinateSystem', () => { + expect(cartesianCoordinateSystem).toBeInstanceOf(CartesianCoordinateSystem); + }); +}); + +describe('CartesianCoordinateSystem bearing/destination consistency', () => { + const cs = new CartesianCoordinateSystem(); + + test('translate feature: destination is consistent with distance and bearing', () => { + // Simulate translateFromCenter logic: center at origin, coord at [100, 0] + // Move center by [50, 50] + const center: [number, number] = [0, 0]; + const coord: [number, number] = [100, 0]; + const newCenter: [number, number] = [50, 50]; + + const dist = cs.distance(center, coord); + const brng = cs.bearing(center, coord); + const newCoord = cs.destination(newCenter, dist, brng); + + // newCoord should be center moved by [100, 0], i.e. [150, 50] + expect(newCoord[0]).toBeCloseTo(150, 4); + expect(newCoord[1]).toBeCloseTo(50, 4); + }); +}); diff --git a/modules/editable-layers/test/edit-modes/lib/__snapshots__/translate-mode.spec.ts.snap b/modules/editable-layers/test/edit-modes/lib/__snapshots__/translate-mode.spec.ts.snap index d761a5c0a..a2c7c3448 100644 --- a/modules/editable-layers/test/edit-modes/lib/__snapshots__/translate-mode.spec.ts.snap +++ b/modules/editable-layers/test/edit-modes/lib/__snapshots__/translate-mode.spec.ts.snap @@ -6,46 +6,46 @@ exports[`Selected polygon feature can be translated 1`] = ` "coordinates": [ [ [ - 1.9989310451833262, - 3.000457327224949, + 2.0019840207172366, + 2.998018884239055, ], [ - 4.002593287805212, - 3.000457327224949, + 4.004420305407299, + 2.998018884239055, ], [ - 4.003816956249125, - 5.0004573272249475, + 4.006866450247027, + 4.998017395757558, ], [ - 1.9977073767394131, - 5.0004573272249475, + 1.9995378758775089, + 4.998017395757558, ], [ - 1.9989310451833262, - 3.000457327224949, + 2.0019840207172366, + 2.998018884239055, ], ], [ [ - 2.4996938251848633, - 3.500457327224949, + 2.502287685182008, + 3.498475731588087, ], [ - 2.4993879257185654, - 4.500457327224948, + 2.5016761722609595, + 4.498475545527953, ], [ - 3.5021364072699726, - 4.500457327224948, + 3.5047281538635766, + 4.498475545527953, ], [ - 3.5018305078036747, - 3.500457327224949, + 3.5041166409425277, + 3.498475731588087, ], [ - 2.4996938251848633, - 3.500457327224949, + 2.502287685182008, + 3.498475731588087, ], ], ], From 2a10d9d12190d5caf483200e059ee274f44d79a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:06:57 +0000 Subject: [PATCH 3/4] feat(editable-layers): Align EditModeCoordinateSystem with deck.gl COORDINATE_SYSTEM constants Co-authored-by: charlieforward9 <62311337+charlieforward9@users.noreply.github.com> --- .../src/edit-modes/coordinate-system.ts | 46 +++++++++++++++++-- .../src/edit-modes/measure-distance-mode.ts | 8 ++-- .../src/edit-modes/translate-mode.ts | 4 +- .../editable-layers/src/edit-modes/types.ts | 4 +- .../editable-layers/editable-geojson-layer.ts | 5 ++ modules/editable-layers/src/index.ts | 5 +- .../src/utils/translate-from-center.ts | 4 +- .../test/edit-modes/coordinate-system.spec.ts | 36 ++++++++++++++- 8 files changed, 95 insertions(+), 17 deletions(-) diff --git a/modules/editable-layers/src/edit-modes/coordinate-system.ts b/modules/editable-layers/src/edit-modes/coordinate-system.ts index 3f4f38ef1..840a78b76 100644 --- a/modules/editable-layers/src/edit-modes/coordinate-system.ts +++ b/modules/editable-layers/src/edit-modes/coordinate-system.ts @@ -7,6 +7,7 @@ import turfDistance from '@turf/distance'; import turfDestination from '@turf/destination'; import turfMidpoint from '@turf/midpoint'; import {point} from '@turf/helpers'; +import {COORDINATE_SYSTEM} from '@deck.gl/core'; import {Position} from '../utils/geojson-types'; @@ -16,10 +17,15 @@ import {Position} from '../utils/geojson-types'; * Allows edit modes to be used with geographic (WGS84) or Cartesian (screen/local) * coordinate systems without changing the mode logic. * + * Naming note: this interface is intentionally distinct from deck.gl's own + * `CoordinateSystem` type (a numeric enum from `@deck.gl/core`), which describes how + * positions are projected for *rendering*. `EditModeCoordinateSystem` describes how edit + * modes should perform *geometric math* on those positions. + * * - GeoCoordinateSystem: wraps turf.js — uses geodesic math, assumes WGS84 lon/lat. * - CartesianCoordinateSystem: uses Euclidean math — suitable for OrthographicView or pixel space. */ -export interface CoordinateSystem { +export interface EditModeCoordinateSystem { /** * Returns the distance between two positions. * For GeoCoordinateSystem the unit is kilometers; for CartesianCoordinateSystem it is the @@ -49,7 +55,7 @@ export interface CoordinateSystem { * Geographic coordinate system using turf.js (WGS84 lon/lat, geodesic math). * This is the default and preserves the existing behavior of all edit modes. */ -export class GeoCoordinateSystem implements CoordinateSystem { +export class GeoCoordinateSystem implements EditModeCoordinateSystem { distance(a: Position, b: Position): number { return turfDistance(point(a), point(b)); } @@ -75,7 +81,7 @@ export class GeoCoordinateSystem implements CoordinateSystem { * destination() and bearing() are consistent with each other: * 0° = +Y axis, 90° = +X axis (clockwise from north/up). */ -export class CartesianCoordinateSystem implements CoordinateSystem { +export class CartesianCoordinateSystem implements EditModeCoordinateSystem { distance(a: Position, b: Position): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; @@ -120,6 +126,38 @@ export const cartesianCoordinateSystem = new CartesianCoordinateSystem(); * Returns the provided coordinate system, or `geoCoordinateSystem` as the default. * Use this helper inside edit modes to avoid null checks scattered throughout the code. */ -export function getCoordinateSystem(coordinateSystem?: CoordinateSystem): CoordinateSystem { +export function getEditModeCoordinateSystem( + coordinateSystem?: EditModeCoordinateSystem +): EditModeCoordinateSystem { return coordinateSystem ?? geoCoordinateSystem; } + +/** + * Maps a deck.gl `COORDINATE_SYSTEM` constant to the appropriate `EditModeCoordinateSystem` + * implementation for edit-mode geometric math. + * + * This allows the `EditableGeoJsonLayer` to automatically derive the correct math from + * its own `coordinateSystem` prop without requiring consumers to configure it separately. + * + * | deck.gl constant | Math used | + * |------------------------------------|-----------------------| + * | `COORDINATE_SYSTEM.LNGLAT` | GeoCoordinateSystem | + * | `COORDINATE_SYSTEM.DEFAULT` | GeoCoordinateSystem | + * | `COORDINATE_SYSTEM.CARTESIAN` | CartesianCoordinateSystem | + * | `COORDINATE_SYSTEM.METER_OFFSETS` | CartesianCoordinateSystem | + * | `COORDINATE_SYSTEM.LNGLAT_OFFSETS` | GeoCoordinateSystem | + */ +export function fromDeckCoordinateSystem( + deckCoordSystem: number | undefined +): EditModeCoordinateSystem { + switch (deckCoordSystem) { + case COORDINATE_SYSTEM.CARTESIAN: + case COORDINATE_SYSTEM.METER_OFFSETS: + return cartesianCoordinateSystem; + case COORDINATE_SYSTEM.LNGLAT: + case COORDINATE_SYSTEM.LNGLAT_OFFSETS: + case COORDINATE_SYSTEM.DEFAULT: + default: + return geoCoordinateSystem; + } +} diff --git a/modules/editable-layers/src/edit-modes/measure-distance-mode.ts b/modules/editable-layers/src/edit-modes/measure-distance-mode.ts index c62ea87dd..f660b3878 100644 --- a/modules/editable-layers/src/edit-modes/measure-distance-mode.ts +++ b/modules/editable-layers/src/edit-modes/measure-distance-mode.ts @@ -13,7 +13,7 @@ import { } from './types'; import {getPickedEditHandle} from './utils'; import {GeoJsonEditMode} from './geojson-edit-mode'; -import {getCoordinateSystem} from './coordinate-system'; +import {getEditModeCoordinateSystem} from './coordinate-system'; export class MeasureDistanceMode extends GeoJsonEditMode { _isMeasuringSessionFinished = false; @@ -22,7 +22,7 @@ export class MeasureDistanceMode extends GeoJsonEditMode { _calculateDistanceForTooltip = ({positionA, positionB, modeConfig, coordinateSystem}) => { const {measurementCallback} = modeConfig || {}; - const distance = getCoordinateSystem(coordinateSystem).distance(positionA, positionB); + const distance = getEditModeCoordinateSystem(coordinateSystem).distance(positionA, positionB); if (measurementCallback) { measurementCallback(distance); @@ -49,7 +49,7 @@ export class MeasureDistanceMode extends GeoJsonEditMode { handleClick(event: ClickEvent, props: ModeProps) { const {modeConfig, data, onEdit} = props; const {centerTooltipsOnLine = false} = modeConfig || {}; - const coordSys = getCoordinateSystem(props.coordinateSystem); + const coordSys = getEditModeCoordinateSystem(props.coordinateSystem); // restart measuring session if (this._isMeasuringSessionFinished) { @@ -193,7 +193,7 @@ export class MeasureDistanceMode extends GeoJsonEditMode { const {lastPointerMoveEvent, modeConfig} = props; const {centerTooltipsOnLine = false} = modeConfig || {}; const positions = this.getClickSequence(); - const coordSys = getCoordinateSystem(props.coordinateSystem); + const coordSys = getEditModeCoordinateSystem(props.coordinateSystem); if (positions.length > 0 && lastPointerMoveEvent && !this._isMeasuringSessionFinished) { const distance = this._calculateDistanceForTooltip({ diff --git a/modules/editable-layers/src/edit-modes/translate-mode.ts b/modules/editable-layers/src/edit-modes/translate-mode.ts index bae9728a8..4ff3f684b 100644 --- a/modules/editable-layers/src/edit-modes/translate-mode.ts +++ b/modules/editable-layers/src/edit-modes/translate-mode.ts @@ -21,7 +21,7 @@ import {mapCoords} from './utils'; import {translateFromCenter} from '../utils/translate-from-center'; import {GeoJsonEditMode, GeoJsonEditAction} from './geojson-edit-mode'; import {ImmutableFeatureCollection} from './immutable-feature-collection'; -import {getCoordinateSystem} from './coordinate-system'; +import {getEditModeCoordinateSystem} from './coordinate-system'; export class TranslateMode extends GeoJsonEditMode { _geometryBeforeTranslate: SimpleFeatureCollection | null | undefined; @@ -140,7 +140,7 @@ export class TranslateMode extends GeoJsonEditMode { } } } else { - const coordinateSystem = getCoordinateSystem(props.coordinateSystem); + const coordinateSystem = getEditModeCoordinateSystem(props.coordinateSystem); const distanceMoved = coordinateSystem.distance(startDragPoint, currentPoint); const direction = coordinateSystem.bearing(startDragPoint, currentPoint); diff --git a/modules/editable-layers/src/edit-modes/types.ts b/modules/editable-layers/src/edit-modes/types.ts index 491cd24a4..fe6f12fa8 100644 --- a/modules/editable-layers/src/edit-modes/types.ts +++ b/modules/editable-layers/src/edit-modes/types.ts @@ -3,7 +3,7 @@ // Copyright (c) vis.gl contributors import {Position, Point, SimpleGeometry, Feature} from '../utils/geojson-types'; -import type {CoordinateSystem} from './coordinate-system'; +import type {EditModeCoordinateSystem} from './coordinate-system'; export type ScreenCoordinates = [number, number]; @@ -137,7 +137,7 @@ export type ModeProps = { lastPointerMoveEvent: PointerMoveEvent; // Coordinate system to use for geometric calculations (defaults to GeoCoordinateSystem) - coordinateSystem?: CoordinateSystem; + coordinateSystem?: EditModeCoordinateSystem; // Callback used to notify applications of an edit action onEdit: (editAction: EditAction) => void; diff --git a/modules/editable-layers/src/editable-layers/editable-geojson-layer.ts b/modules/editable-layers/src/editable-layers/editable-geojson-layer.ts index b3cdea528..882f8391b 100644 --- a/modules/editable-layers/src/editable-layers/editable-geojson-layer.ts +++ b/modules/editable-layers/src/editable-layers/editable-geojson-layer.ts @@ -16,6 +16,7 @@ import { DoubleClickEvent, ModeProps } from '../edit-modes/types'; +import {fromDeckCoordinateSystem} from '../edit-modes/coordinate-system'; import {ViewMode} from '../edit-modes/view-mode'; import {TranslateMode} from '../edit-modes/translate-mode'; @@ -438,6 +439,10 @@ export class EditableGeoJsonLayer extends EditableLayer< selectedIndexes: props.selectedFeatureIndexes, lastPointerMoveEvent: this.state.lastPointerMoveEvent, cursor: this.state.cursor, + // Derive edit-mode math from deck.gl's coordinateSystem layer prop. + // This ensures that when the layer is configured for Cartesian or other + // non-geographic rendering, edit modes automatically use the correct geometry math. + coordinateSystem: fromDeckCoordinateSystem(this.props.coordinateSystem), onEdit: (editAction) => { // Force a re-render // This supports double-click where we need to ensure that there's a re-render between the two clicks diff --git a/modules/editable-layers/src/index.ts b/modules/editable-layers/src/index.ts index 334ea5e5a..4bc7a5c69 100644 --- a/modules/editable-layers/src/index.ts +++ b/modules/editable-layers/src/index.ts @@ -40,12 +40,13 @@ export type {EditMode} from './edit-modes/edit-mode'; export type {GeoJsonEditModeType} from './edit-modes/geojson-edit-mode'; export type {GeoJsonEditModeConstructor} from './edit-modes/geojson-edit-mode'; -export type {CoordinateSystem} from './edit-modes/coordinate-system'; +export type {EditModeCoordinateSystem} from './edit-modes/coordinate-system'; export {GeoCoordinateSystem, CartesianCoordinateSystem} from './edit-modes/coordinate-system'; export { geoCoordinateSystem, cartesianCoordinateSystem, - getCoordinateSystem + getEditModeCoordinateSystem, + fromDeckCoordinateSystem } from './edit-modes/coordinate-system'; export type {EditableGeoJsonLayerProps} from './editable-layers/editable-geojson-layer'; diff --git a/modules/editable-layers/src/utils/translate-from-center.ts b/modules/editable-layers/src/utils/translate-from-center.ts index 8edd0c526..101dffedd 100644 --- a/modules/editable-layers/src/utils/translate-from-center.ts +++ b/modules/editable-layers/src/utils/translate-from-center.ts @@ -5,7 +5,7 @@ import turfCenter from '@turf/center'; import {mapCoords} from '../edit-modes/utils'; import {geoCoordinateSystem} from '../edit-modes/coordinate-system'; -import type {CoordinateSystem} from '../edit-modes/coordinate-system'; +import type {EditModeCoordinateSystem} from '../edit-modes/coordinate-system'; import type {SimpleFeature} from './geojson-types'; // This function takes feature's center, moves it, @@ -14,7 +14,7 @@ export function translateFromCenter( feature: SimpleFeature, distance: number, direction: number, - coordinateSystem: CoordinateSystem = geoCoordinateSystem + coordinateSystem: EditModeCoordinateSystem = geoCoordinateSystem ) { const initialCenter = turfCenter(feature).geometry.coordinates; diff --git a/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts b/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts index bfe6f8fb8..a6f09a54f 100644 --- a/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts +++ b/modules/editable-layers/test/edit-modes/coordinate-system.spec.ts @@ -3,11 +3,13 @@ // Copyright (c) vis.gl contributors import {describe, test, expect} from 'vitest'; +import {COORDINATE_SYSTEM} from '@deck.gl/core'; import { GeoCoordinateSystem, CartesianCoordinateSystem, geoCoordinateSystem, - cartesianCoordinateSystem + cartesianCoordinateSystem, + fromDeckCoordinateSystem } from '../../src/edit-modes/coordinate-system'; // Tolerance for floating-point comparisons @@ -145,3 +147,35 @@ describe('CartesianCoordinateSystem bearing/destination consistency', () => { expect(newCoord[1]).toBeCloseTo(50, 4); }); }); + +describe('fromDeckCoordinateSystem', () => { + test('COORDINATE_SYSTEM.LNGLAT returns GeoCoordinateSystem', () => { + expect(fromDeckCoordinateSystem(COORDINATE_SYSTEM.LNGLAT)).toBeInstanceOf(GeoCoordinateSystem); + }); + + test('COORDINATE_SYSTEM.DEFAULT returns GeoCoordinateSystem', () => { + expect(fromDeckCoordinateSystem(COORDINATE_SYSTEM.DEFAULT)).toBeInstanceOf(GeoCoordinateSystem); + }); + + test('COORDINATE_SYSTEM.LNGLAT_OFFSETS returns GeoCoordinateSystem', () => { + expect(fromDeckCoordinateSystem(COORDINATE_SYSTEM.LNGLAT_OFFSETS)).toBeInstanceOf( + GeoCoordinateSystem + ); + }); + + test('COORDINATE_SYSTEM.CARTESIAN returns CartesianCoordinateSystem', () => { + expect(fromDeckCoordinateSystem(COORDINATE_SYSTEM.CARTESIAN)).toBeInstanceOf( + CartesianCoordinateSystem + ); + }); + + test('COORDINATE_SYSTEM.METER_OFFSETS returns CartesianCoordinateSystem', () => { + expect(fromDeckCoordinateSystem(COORDINATE_SYSTEM.METER_OFFSETS)).toBeInstanceOf( + CartesianCoordinateSystem + ); + }); + + test('undefined returns GeoCoordinateSystem as default', () => { + expect(fromDeckCoordinateSystem(undefined)).toBeInstanceOf(GeoCoordinateSystem); + }); +}); From 358301a727625987e40fbe98b52f6a5f28a487c5 Mon Sep 17 00:00:00 2001 From: charlieforward9 Date: Sun, 8 Mar 2026 00:11:46 -0500 Subject: [PATCH 4/4] 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 8777232b2..4c65a1190 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"