Skip to content
Open
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
51 changes: 51 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"input-otp": "^1.4.2",
"lucide-react": "^1.8.0",
"mammoth": "^1.12.0",
"maplibre-gl": "^5.24.0",
"marked": "^18.0.0",
"posthog-js": "^1.288.1",
"react": "^19.2.1",
Expand Down
6 changes: 6 additions & 0 deletions src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import * as citation from './citation'
import * as connectIntegration from './connect-integration'
import * as documentResult from './document-result'
import * as linkPreview from './link-preview'
import * as map from './map'
import * as weatherForecast from './weather-forecast'

// Re-export components for easy importing
export { CitationBadge } from './citation'
export { ConnectIntegrationWidget } from './connect-integration'
export { DocumentResultWidget } from './document-result'
export { LinkPreview, LinkPreviewSkeleton, LinkPreviewWidget } from './link-preview'
export { MapWidget } from './map'
export { WeatherForecastWidget } from './weather-forecast'

/**
Expand Down Expand Up @@ -56,6 +58,10 @@ export const widgetRegistry = [
name: 'link-preview' as const,
module: linkPreview,
},
{
name: 'map' as const,
module: map,
},
] as const

/**
Expand Down
132 changes: 132 additions & 0 deletions src/widgets/map/geojson.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, test } from 'bun:test'
import { featureBounds, featureLabel, parseFeatureCollection } from './geojson'

const point = (lng: number, lat: number, properties: Record<string, unknown> | null = null) => ({
type: 'Feature' as const,
geometry: { type: 'Point' as const, coordinates: [lng, lat] },
properties,
})

const collection = (features: unknown[]) => JSON.stringify({ type: 'FeatureCollection', features })

describe('parseFeatureCollection', () => {
test('parses a valid Point FeatureCollection', () => {
const parsed = parseFeatureCollection(collection([point(-122.33, 47.61, { label: 'Seattle' })]))
expect(parsed?.features).toHaveLength(1)
expect(parsed?.features[0].geometry.type).toBe('Point')
})

test('parses LineString and Polygon geometries', () => {
const parsed = parseFeatureCollection(
collection([
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[0, 0],
[1, 1],
],
},
properties: null,
},
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
},
properties: null,
},
]),
)
expect(parsed?.features.map((f) => f.geometry.type)).toEqual(['LineString', 'Polygon'])
})

test('keeps unknown (domain-specific) properties via passthrough', () => {
const parsed = parseFeatureCollection(collection([point(8.68, 50.11, { label: 'X', target_priority: 'high' })]))
expect(parsed).not.toBeNull()
// passthrough preserves the raw value, but the widget never reads it.
expect((parsed?.features[0].properties as Record<string, unknown>).target_priority).toBe('high')
})

test('returns null for invalid JSON', () => {
expect(parseFeatureCollection("{'type': 'FeatureCollection'}")).toBeNull() // single quotes = not JSON
expect(parseFeatureCollection('not json')).toBeNull()
})

test('returns null for non-FeatureCollection or empty features', () => {
expect(parseFeatureCollection(JSON.stringify({ type: 'Feature', geometry: null }))).toBeNull()
expect(parseFeatureCollection(collection([]))).toBeNull() // min(1)
})

test('returns null for malformed coordinates', () => {
expect(
parseFeatureCollection(
collection([{ type: 'Feature', geometry: { type: 'Point', coordinates: ['a', 'b'] }, properties: null }]),
),
).toBeNull()
})
})

describe('featureLabel', () => {
test('prefers label, then name, then title', () => {
expect(featureLabel({ label: 'L', name: 'N', title: 'T' })).toBe('L')
expect(featureLabel({ name: 'N', title: 'T' })).toBe('N')
expect(featureLabel({ title: 'T' })).toBe('T')
})

test('ignores non-string and domain-specific fields, returns null when absent', () => {
expect(featureLabel({ description: 'd', target_priority: 'high' })).toBeNull()
expect(featureLabel({ label: 42 })).toBeNull()
expect(featureLabel(null)).toBeNull()
})
})

describe('featureBounds', () => {
test('computes [[w,s],[e,n]] across points', () => {
const parsed = parseFeatureCollection(collection([point(-122, 47), point(8, 50)]))
expect(featureBounds(parsed!)).toEqual([
[-122, 47],
[8, 50],
])
})

test('walks nested polygon coordinates', () => {
const parsed = parseFeatureCollection(
collection([
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[0, 0],
[4, 0],
[4, 3],
[0, 3],
[0, 0],
],
],
},
properties: null,
},
]),
)
expect(featureBounds(parsed!)).toEqual([
[0, 0],
[4, 3],
])
})
})
114 changes: 114 additions & 0 deletions src/widgets/map/geojson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { z } from 'zod'

/**
* Minimal, neutral GeoJSON schema for the map widget. We validate the standard
* geometry shapes and read only generic display fields off `properties`
* (`label` / `name` / `title` / `description`). Any other properties pass
* through validation but are intentionally never rendered — the widget is a
* generic location renderer, not a domain-specific visualizer.
*/

/** `[longitude, latitude]` with an optional trailing altitude. */
const position = z.tuple([z.number(), z.number()]).rest(z.number())

const geometry = z.discriminatedUnion('type', [
z.object({ type: z.literal('Point'), coordinates: position }),
z.object({ type: z.literal('MultiPoint'), coordinates: z.array(position) }),
z.object({ type: z.literal('LineString'), coordinates: z.array(position) }),
z.object({ type: z.literal('MultiLineString'), coordinates: z.array(z.array(position)) }),
z.object({ type: z.literal('Polygon'), coordinates: z.array(z.array(position)) }),
z.object({ type: z.literal('MultiPolygon'), coordinates: z.array(z.array(z.array(position))) }),
])

/** Only generic display fields are typed; `.passthrough()` tolerates (but the
* widget ignores) any domain-specific properties on a feature. */
const featureProperties = z
.object({
label: z.string().optional(),
name: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
})
.passthrough()
.nullable()

const feature = z.object({
type: z.literal('Feature'),
geometry,
properties: featureProperties,
})

export const featureCollectionSchema = z.object({
type: z.literal('FeatureCollection'),
features: z.array(feature).min(1),
})

export type MapFeatureCollection = z.infer<typeof featureCollectionSchema>
export type MapFeature = z.infer<typeof feature>
export type MapFeatureProperties = z.infer<typeof featureProperties>

/** Parse + validate a raw JSON string into a FeatureCollection, or null. */
export const parseFeatureCollection = (raw: string): MapFeatureCollection | null => {
let json: unknown
try {
json = JSON.parse(raw)
} catch {
return null
}
const result = featureCollectionSchema.safeParse(json)
return result.success ? result.data : null
}

/**
* The neutral display label for a feature: first of `label` / `name` / `title`,
* else null. Deliberately limited to generic fields — domain-specific
* properties are never surfaced. Accepts a loose record so it works for both
* the zod-parsed properties and MapLibre's runtime feature properties.
*/
export const featureLabel = (properties: Record<string, unknown> | null | undefined): string | null => {
const pick = (key: string): string | null =>
typeof properties?.[key] === 'string' ? (properties[key] as string) : null
return pick('label') ?? pick('name') ?? pick('title')
}

/**
* Bounding box `[[west, south], [east, north]]` over every coordinate in the
* collection, or null if it has none. Walks nested coordinate arrays so it
* handles points, lines, and polygons uniformly.
*/
export const featureBounds = (collection: MapFeatureCollection): [[number, number], [number, number]] | null => {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let found = false

const walk = (node: unknown): void => {
if (Array.isArray(node) && typeof node[0] === 'number' && typeof node[1] === 'number') {
const [x, y] = node as [number, number]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
found = true
return
}
if (Array.isArray(node)) {
node.forEach(walk)
}
}

for (const f of collection.features) {
walk(f.geometry.coordinates)
}
return found
? [
[minX, minY],
[maxX, maxY],
]
: null
}
15 changes: 15 additions & 0 deletions src/widgets/map/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

export {
featureBounds,
featureCollectionSchema,
featureLabel,
parseFeatureCollection,
type MapFeature,
type MapFeatureCollection,
} from './geojson'
export { instructions } from './instructions'
export { parse, schema, type MapWidget as MapWidgetType } from './schema'
export { MapSkeleton, MapWidget, MapWidget as Component } from './widget'
14 changes: 14 additions & 0 deletions src/widgets/map/instructions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
* AI instructions for the map widget. Added to the widget system prompt so any
* built-in agent can render locations on a map. (ACP pipelines that want to use
* it emit the same tag from their own prompt.)
*/
export const instructions = `## Map
<widget:map data='<GeoJSON FeatureCollection as valid JSON>' title="Optional title" />
Renders locations on an interactive map. \`data\` MUST be a **valid GeoJSON FeatureCollection** — strict JSON with double-quoted keys and strings, wrapped in single quotes. Supports Point, LineString, and Polygon geometries (and their Multi* variants). Each feature's \`properties\` may include \`label\`, \`name\`, \`title\`, or \`description\`, which are shown in the popup.
Optional per-feature styling follows the simplestyle-spec: \`marker-color\`, \`marker-size\` (small | medium | large), \`stroke\`, \`stroke-width\`, \`fill\`, \`fill-opacity\`.
Example: <widget:map data='{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[-122.3321,47.6062]},"properties":{"label":"Seattle","description":"Home office"}}]}' />`
30 changes: 30 additions & 0 deletions src/widgets/map/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { createParser } from '@/lib/create-parser'
import { z } from 'zod'
import { parseFeatureCollection } from './geojson'

/**
* Zod schema for the map widget. `data` carries a GeoJSON FeatureCollection as
* a JSON string (kept as a string in args — the same pattern as the citation
* widget's `sources`); the `.refine` rejects anything that isn't a valid
* FeatureCollection so a malformed tag never renders an empty map. Parsing into
* the typed collection happens in the component.
*/
export const schema = z.object({
widget: z.literal('map'),
args: z.object({
data: z
.string()
.min(1, 'GeoJSON data is required')
.refine((value) => parseFeatureCollection(value) !== null, 'Invalid GeoJSON: must be a valid FeatureCollection'),
title: z.string().optional(),
}),
})

export type MapWidget = z.infer<typeof schema>

/** Parse function — auto-generated from schema. */
export const parse = createParser(schema)
Loading
Loading