Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ module.exports = getESLintConfig({
{
files: ['modules/*/src/**/*.{ts,tsx}', 'modules/*/test/**/*.{ts,tsx}'],
rules: {
// Workspace packages are resolved via tsconfig paths; the node import resolver
// cannot follow workspace symlinks to unbuilt src. TypeScript and vitest both
// resolve these correctly, so ignore false positives for @deck.gl-community/* imports.
'import/no-unresolved': ['error', {ignore: ['^@deck.gl-community/']}],
// We definitely don't want to enable these rules
'no-use-before-define': 0,
// TODO: Gradually enable, at least for non-test code.
Expand Down
6 changes: 4 additions & 2 deletions modules/editable-layers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"src"
],
"dependencies": {
"@deck.gl-community/json": "^9.3.1",
"@math.gl/core": "^4.0.0",
"@math.gl/web-mercator": ">=4.0.1",
"@turf/along": "^7.2.0",
Expand Down Expand Up @@ -74,7 +75,8 @@
"@turf/union": "^7.2.0",
"@types/geojson": "^7946.0.16",
"lodash.throttle": "^4.1.1",
"preact": "^10.17.0"
"preact": "^10.17.0",
"zod": "^3.22.0"
},
"peerDependencies": {
"@deck.gl-community/layers": "^9.3.0",
Expand All @@ -88,5 +90,5 @@
"@luma.gl/engine": "~9.3.2",
"h3-js": "^4.2.1"
},
"gitHead": "8374ab0ac62a52ae8a6b14276694cabced43de35"
"gitHead": "9bc327e5ca881aa2e7098412d117ca6a93ae9849"
}
61 changes: 61 additions & 0 deletions modules/editable-layers/src/ai-tools/create-edit-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import type {EditToolsConfig} from './types';
import {makeDrawPoint} from './tools/draw-point';
import {makeDrawPolygon} from './tools/draw-polygon';
import {makeDeleteFeature} from './tools/delete-feature';
import {makeTranslateFeature} from './tools/translate-feature';
import {makeDrawLineString} from './tools/draw-line-string';
import {makeDrawRectangle} from './tools/draw-rectangle';
import {makeModifyFeature} from './tools/modify-feature';
import {makeRotateFeature} from './tools/rotate-feature';
import {makeScaleFeature} from './tools/scale-feature';
import {makeSplitPolygon} from './tools/split-polygon';
import {makeDuplicateFeature} from './tools/duplicate-feature';

/**
* createEditTools — AI-forward tool factory for editable-layers.
*
* Returns a vocabulary of Vercel AI SDK v4-shaped tools (structural match,
* no runtime `ai` dep required). Each tool has:
* - description: string — used by LLMs to select the right tool
* - parameters: ZodSchema — validated args
* - execute(args): Promise<EditResult> — direct geometry execution via turf
*
* Every execute() call is immutable: it reads the FeatureCollection via
* config.getFeatureCollection(), computes a new FC, calls
* config.onFeatureCollectionChange(newFc), and returns an EditResult.
*
* Usage:
* ```ts
* const tools = createEditTools({
* getFeatureCollection: () => featureCollectionState,
* onFeatureCollectionChange: setFeatureCollectionState,
* });
*
* // In LLM tool call:
* const result = await tools.draw_point.execute({ position: [-73.985, 40.748] });
*
* // In signal handler (thor → editable-layers bridge in USER code):
* thor.on('fist', () => tools.delete_feature.execute({ featureIndex: hoveredIndex }));
* ```
*/
export function createEditTools(config: EditToolsConfig) {
return {
draw_point: makeDrawPoint(config),
draw_polygon: makeDrawPolygon(config),
delete_feature: makeDeleteFeature(config),
translate_feature: makeTranslateFeature(config),
draw_line_string: makeDrawLineString(config),
draw_rectangle: makeDrawRectangle(config),
modify_feature: makeModifyFeature(config),
rotate_feature: makeRotateFeature(config),
scale_feature: makeScaleFeature(config),
split_polygon: makeSplitPolygon(config),
duplicate_feature: makeDuplicateFeature(config)
};
}

export type {EditToolsConfig};
6 changes: 6 additions & 0 deletions modules/editable-layers/src/ai-tools/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

export {createEditTools} from './create-edit-tools';
export type {EditResult, EditToolsConfig, AiTool, EditTools} from './types';
44 changes: 44 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/delete-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import type {FeatureCollection} from 'geojson';
import type {AiTool, EditToolsConfig} from '../types';

const schema = z.object({
/** Zero-based index of the feature to delete. */
featureIndex: z.number().int().nonnegative()
});

export function makeDeleteFeature(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Delete a feature from the FeatureCollection by its zero-based index. ' +
'Returns feature_not_found if the index is out of range.',
parameters: schema,

async execute({featureIndex}) {
const fc = config.getFeatureCollection();

if (featureIndex >= fc.features.length || featureIndex < 0) {
return {ok: false as const, reason: 'feature_not_found' as const};
}

const newFeatures = fc.features.filter((_, i) => i !== featureIndex);
const newFc: FeatureCollection = {
...fc,
features: newFeatures
};

config.onFeatureCollectionChange(newFc);

return {
ok: true as const,
// Return the index of the first feature after deletion, clamped to valid range
featureIndex: Math.min(featureIndex, newFc.features.length - 1),
featureCollection: newFc
};
}
};
}
27 changes: 27 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/draw-line-string.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import {PositionSchema} from '@deck.gl-community/json';
import type {AiTool, EditToolsConfig} from '../types';

const schema = z.object({
/** Array of [longitude, latitude] (or 3D) pairs defining the line. At least 2 points required. */
coordinates: z.array(PositionSchema),
properties: z.record(z.string(), z.unknown()).optional()
});

export function makeDrawLineString(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Add a GeoJSON LineString feature connecting the given sequence of ' +
'[longitude, latitude] coordinates. At least 2 points required.',
parameters: schema,

async execute(_args) {
// SCAFFOLD: full type contract defined; execution not yet implemented.
return {ok: false as const, reason: 'not_implemented' as const};
}
};
}
49 changes: 49 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/draw-point.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import {PositionSchema} from '@deck.gl-community/json';
import type {FeatureCollection, Feature, Point, Position} from 'geojson';
import type {AiTool, EditToolsConfig} from '../types';

const schema = z.object({
/** [longitude, latitude] or [longitude, latitude, altitude] */
position: PositionSchema,
properties: z.record(z.string(), z.unknown()).optional()
});

export function makeDrawPoint(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Add a GeoJSON Point feature at the given [longitude, latitude] position. ' +
'Returns the updated FeatureCollection and the index of the new point.',
parameters: schema,

async execute({position, properties = {}}) {
const fc = config.getFeatureCollection();

const feature: Feature<Point> = {
type: 'Feature',
properties,
geometry: {
type: 'Point',
coordinates: position as Position
}
};

const newFc: FeatureCollection = {
...fc,
features: [...fc.features, feature]
};

config.onFeatureCollectionChange(newFc);

return {
ok: true as const,
featureIndex: newFc.features.length - 1,
featureCollection: newFc
};
}
};
}
81 changes: 81 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/draw-polygon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import {PositionSchema} from '@deck.gl-community/json';
import kinks from '@turf/kinks';
import {polygon as turfPolygon} from '@turf/helpers';
import type {FeatureCollection, Feature, Polygon} from 'geojson';
import type {AiTool, EditToolsConfig, EditResult} from '../types';

const schema = z.object({
/**
* Polygon ring coordinates: outer ring first, then optional holes.
* Each ring is an array of [longitude, latitude] (or 3D) pairs.
* Rings do NOT need to be explicitly closed — this tool closes them automatically.
*/
coordinates: z.array(z.array(PositionSchema)),
properties: z.record(z.string(), z.unknown()).optional()
});

export function makeDrawPolygon(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Add a GeoJSON Polygon feature. Provide coordinates as an array of rings: ' +
'first ring is the outer boundary, subsequent rings are holes. ' +
'Coordinates are [longitude, latitude] pairs; rings are auto-closed. ' +
'Returns an error if the polygon self-intersects.',
parameters: schema,

async execute({coordinates, properties = {}}) {
if (!coordinates || coordinates.length === 0) {
return {ok: false as const, reason: 'invalid_geometry' as const};
}

// Close each ring if needed
const closedCoords = coordinates.map((ring) => {
if (ring.length < 3) return ring;
const first = ring[0];
const last = ring[ring.length - 1];
if (first[0] === last[0] && first[1] === last[1]) return ring;
return [...ring, first];
});

// Self-intersection check using turf/kinks
try {
const turfPoly = turfPolygon(closedCoords as [number, number][][]);
const selfIntersections = kinks(turfPoly);
if (selfIntersections.features.length > 0) {
return {ok: false as const, reason: 'self_intersecting' as const};
}
} catch {
return {ok: false as const, reason: 'invalid_geometry' as const};
}

const fc = config.getFeatureCollection();

const feature: Feature<Polygon> = {
type: 'Feature',
properties,
geometry: {
type: 'Polygon',
coordinates: closedCoords as [number, number][][]
}
};

const newFc: FeatureCollection = {
...fc,
features: [...fc.features, feature]
};

config.onFeatureCollectionChange(newFc);

return {
ok: true as const,
featureIndex: newFc.features.length - 1,
featureCollection: newFc
} satisfies EditResult;
}
};
}
29 changes: 29 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/draw-rectangle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import type {AiTool, EditToolsConfig} from '../types';

const schema = z.object({
/**
* Bounding box as [minLon, minLat, maxLon, maxLat].
* The resulting polygon will be an axis-aligned rectangle in geographic space.
*/
bbox: z.tuple([z.number(), z.number(), z.number(), z.number()]),
properties: z.record(z.string(), z.unknown()).optional()
});

export function makeDrawRectangle(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Add a rectangular GeoJSON Polygon derived from a bounding box ' +
'[minLon, minLat, maxLon, maxLat]. Equivalent to turf.bboxPolygon.',
parameters: schema,

async execute(_args) {
// SCAFFOLD: full type contract defined; execution not yet implemented.
return {ok: false as const, reason: 'not_implemented' as const};
}
};
}
30 changes: 30 additions & 0 deletions modules/editable-layers/src/ai-tools/tools/duplicate-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {z} from 'zod';
import type {AiTool, EditToolsConfig} from '../types';

const schema = z.object({
featureIndex: z.number().int().nonnegative(),
/**
* Optional [dx, dy] offset in meters applied to the duplicate.
* Defaults to [50, 50] (50m east, 50m north) so it's visible.
* Note: uses a plain 2-number tuple (not a GeoJSON position) — it's a vector delta, not a coordinate.
*/
offsetMeters: z.tuple([z.number(), z.number()]).optional()
});

export function makeDuplicateFeature(config: EditToolsConfig): AiTool<typeof schema> {
return {
description:
'Duplicate a feature and optionally offset the copy by [dx, dy] in meters. ' +
'The original is unchanged. Returns the index of the new duplicate.',
parameters: schema,

async execute(_args) {
// SCAFFOLD: full type contract defined; execution not yet implemented.
return {ok: false as const, reason: 'not_implemented' as const};
}
};
}
Loading
Loading