From c1b7945f442f40e63adc9946d864cba5fcb3642d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 14:55:06 +0000 Subject: [PATCH] Add rasterLayers module for ephemeral buffer-backed raster layers Adds a new `rasterLayers` controller so extensions can create client-only raster layers backed by a typed array (RGBA8) buffer and update the pixels as close to realtime as possible: - createEphemeralRasterLayer / updateEphemeralRasterLayer / setEphemeralRasterLayerCoordinates / deleteEphemeralRasterLayer - schema (zod message definitions) + controller + types, wired into the main schema/controller aggregation and the client barrel export - methodWithTransfer helper in lib/interface so the pixel ArrayBuffer is transferred (zero-copy) rather than structured-cloned Companion to felt/felt's ephemeral buffer-raster layer support. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01L1J3UqXizsTiiYRa2Ja4Tx --- .changeset/raster-layers-ephemeral.md | 10 +++ src/client.ts | 1 + src/lib/interface.ts | 35 +++++++++ src/modules/main/controller.ts | 6 ++ src/modules/main/schema.ts | 6 ++ src/modules/rasterLayers/controller.ts | 105 +++++++++++++++++++++++++ src/modules/rasterLayers/index.ts | 19 +++++ src/modules/rasterLayers/schema.ts | 59 ++++++++++++++ src/modules/rasterLayers/types.ts | 74 +++++++++++++++++ 9 files changed, 315 insertions(+) create mode 100644 .changeset/raster-layers-ephemeral.md create mode 100644 src/modules/rasterLayers/controller.ts create mode 100644 src/modules/rasterLayers/index.ts create mode 100644 src/modules/rasterLayers/schema.ts create mode 100644 src/modules/rasterLayers/types.ts diff --git a/.changeset/raster-layers-ephemeral.md b/.changeset/raster-layers-ephemeral.md new file mode 100644 index 00000000..314658f0 --- /dev/null +++ b/.changeset/raster-layers-ephemeral.md @@ -0,0 +1,10 @@ +--- +"@feltmaps/js-sdk": minor +--- + +Add a `rasterLayers` controller so extensions can emit their own ephemeral, +client-only raster layers backed by a typed array buffer. Includes +`createEphemeralRasterLayer`, `updateEphemeralRasterLayer`, +`setEphemeralRasterLayerCoordinates`, and `deleteEphemeralRasterLayer`. The +RGBA8 pixel buffer is transferred to the map for zero-copy, near-realtime +updates. diff --git a/src/client.ts b/src/client.ts index 61d8d65e..3136ef20 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,6 +4,7 @@ export * from "./modules/interactions"; export * from "./modules/layers"; export * from "./modules/main"; export * from "./modules/misc"; +export * from "./modules/rasterLayers"; export * from "./modules/selection"; export * from "./modules/shared"; export * from "./modules/tools"; diff --git a/src/lib/interface.ts b/src/lib/interface.ts index b58e0aac..73d6d163 100644 --- a/src/lib/interface.ts +++ b/src/lib/interface.ts @@ -173,6 +173,41 @@ export function method( }; } +/** + * Like {@link method}, but transfers the {@link Transferable | Transferables} + * returned by `getTransferables` (e.g. an `ArrayBuffer` of raster pixels) to + * the map instead of structured-cloning them. This is zero-copy, but detaches + * the transferred objects in the calling context. + */ +export function methodWithTransfer( + feltWindow: Pick, + type: TKey, + getTransferables: (params: OneMethod) => Transferable[], +): FeltMethod { + return async (params) => { + const messageChannel = new MessageChannel(); + + const transferables = params ? getTransferables(params) : []; + + feltWindow.postMessage({ type, params }, "*", [ + messageChannel.port2, + ...transferables, + ]); + + return new Promise((resolve, reject) => { + messageChannel.port1.onmessage = (event) => { + if (isErrorMessage(event)) { + reject(new Error(event.data.__error__)); + } else { + resolve(event.data); + } + messageChannel.port1.close(); + messageChannel.port2.close(); + }; + }); + }; +} + const eventIdToFunction: Record = {}; export function methodWithListeners< diff --git a/src/modules/main/controller.ts b/src/modules/main/controller.ts index 85563a0a..7e70026f 100644 --- a/src/modules/main/controller.ts +++ b/src/modules/main/controller.ts @@ -12,6 +12,10 @@ import { } from "../interactions/controller"; import { layersController, type LayersController } from "../layers/controller"; import { miscController, type MiscController } from "../misc/controller"; +import { + rasterLayersController, + type RasterLayersController, +} from "../rasterLayers/controller"; import { selectionController, type SelectionController, @@ -36,6 +40,7 @@ export function makeController( ...viewportController(feltWindow), ...uiController(feltWindow), ...layersController(feltWindow), + ...rasterLayersController(feltWindow), ...elementsController(feltWindow), ...selectionController(feltWindow), ...interactionsController(feltWindow), @@ -65,6 +70,7 @@ export interface FeltController extends ViewportController, UiController, LayersController, + RasterLayersController, ElementsController, SelectionController, InteractionsController, diff --git a/src/modules/main/schema.ts b/src/modules/main/schema.ts index d7a1040d..af9000c9 100644 --- a/src/modules/main/schema.ts +++ b/src/modules/main/schema.ts @@ -6,6 +6,10 @@ import { } from "../interactions/schema"; import { layersSchema, type LayersSchema } from "../layers/schema"; import { miscSchema, type MiscSchema } from "../misc/schema"; +import { + rasterLayersSchema, + type RasterLayersSchema, +} from "../rasterLayers/schema"; import { selectionSchema, type SelectionSchema } from "../selection/schema"; import { toolsSchema, type ToolsSchema } from "../tools/schema"; import { uiSchema, type UiSchema } from "../ui/schema"; @@ -15,6 +19,7 @@ export const allModules = [ uiSchema, viewportSchema, layersSchema, + rasterLayersSchema, elementsSchema, selectionSchema, interactionsSchema, @@ -27,6 +32,7 @@ export type AllModules = | UiSchema | ViewportSchema | LayersSchema + | RasterLayersSchema | ElementsSchema | SelectionSchema | InteractionsSchema diff --git a/src/modules/rasterLayers/controller.ts b/src/modules/rasterLayers/controller.ts new file mode 100644 index 00000000..2f2fb6c6 --- /dev/null +++ b/src/modules/rasterLayers/controller.ts @@ -0,0 +1,105 @@ +import { method, methodWithTransfer } from "~/lib/interface"; +import type { + CreateEphemeralRasterLayerParams, + EphemeralRasterLayer, + SetEphemeralRasterLayerCoordinatesParams, + UpdateEphemeralRasterLayerParams, +} from "./types"; + +/** + * @ignore + */ +export const rasterLayersController = ( + feltWindow: Pick, +): RasterLayersController => ({ + createEphemeralRasterLayer: methodWithTransfer( + feltWindow, + "createEphemeralRasterLayer", + (params) => (params.data ? [params.data] : []), + ), + updateEphemeralRasterLayer: methodWithTransfer( + feltWindow, + "updateEphemeralRasterLayer", + (params) => [params.data], + ), + setEphemeralRasterLayerCoordinates: method( + feltWindow, + "setEphemeralRasterLayerCoordinates", + ), + deleteEphemeralRasterLayer: method(feltWindow, "deleteEphemeralRasterLayer"), +}); + +/** + * The raster layers controller lets extensions create their own ephemeral, + * client-only raster layers backed by a typed array buffer, and update the + * pixels as close to realtime as possible. + * + * These layers never round-trip to the server: the pixels live entirely as a + * GPU texture on the map. This makes them well-suited to live imagery such as + * heatmaps, sensor fields, decoded video frames, or simulation grids. + * + * @group Controller + * @public + */ +export interface RasterLayersController { + /** + * Creates an ephemeral raster layer backed by an RGBA8 buffer, placed on the + * map at the given coordinates. + * + * The optional `data` buffer must contain exactly `width * height * 4` bytes, + * laid out row-major with row 0 at the northern edge. When provided, the + * buffer is transferred to the map and detached in the calling context. + * + * @returns A promise resolving to the created layer's `{ id }`. + * + * @example + * ```typescript + * const pixels = new Uint8Array(256 * 256 * 4); + * // ...fill pixels with RGBA values... + * const { id } = await felt.createEphemeralRasterLayer({ + * width: 256, + * height: 256, + * coordinates: [-122.5, 37.7, -122.3, 37.9], + * data: pixels.buffer, + * }); + * ``` + */ + createEphemeralRasterLayer( + params: CreateEphemeralRasterLayerParams, + ): Promise; + + /** + * Replaces the pixels of an existing ephemeral raster layer and re-renders it. + * + * The `data` buffer must contain exactly `width * height * 4` bytes matching + * the layer's dimensions. It is transferred to the map and detached in the + * calling context, so an animation loop should allocate (or reuse a pool of) + * fresh buffers per frame. + * + * @example + * ```typescript + * function frame() { + * const pixels = new Uint8Array(256 * 256 * 4); + * // ...paint the next frame... + * felt.updateEphemeralRasterLayer({ id, data: pixels.buffer }); + * requestAnimationFrame(frame); + * } + * requestAnimationFrame(frame); + * ``` + */ + updateEphemeralRasterLayer( + params: UpdateEphemeralRasterLayerParams, + ): Promise; + + /** + * Moves an existing ephemeral raster layer's quad without recreating it. + */ + setEphemeralRasterLayerCoordinates( + params: SetEphemeralRasterLayerCoordinatesParams, + ): Promise; + + /** + * Removes an ephemeral raster layer and frees its GPU resources. + */ + deleteEphemeralRasterLayer(id: string): Promise; +} diff --git a/src/modules/rasterLayers/index.ts b/src/modules/rasterLayers/index.ts new file mode 100644 index 00000000..b585d7f8 --- /dev/null +++ b/src/modules/rasterLayers/index.ts @@ -0,0 +1,19 @@ +/** + * The Raster Layers module lets extensions emit their own ephemeral, + * client-only raster layers backed by a typed array buffer, and update the + * pixels as close to realtime as possible. + * + * This is useful for rendering live imagery on the map, such as heatmaps, + * sensor fields, decoded video frames, or simulation grids, without any + * server round-trip. + * + * @module RasterLayers + */ +export type { RasterLayersController } from "./controller"; +export type { + CreateEphemeralRasterLayerParams, + EphemeralRasterLayer, + RasterLayerCoordinates, + SetEphemeralRasterLayerCoordinatesParams, + UpdateEphemeralRasterLayerParams, +} from "./types"; diff --git a/src/modules/rasterLayers/schema.ts b/src/modules/rasterLayers/schema.ts new file mode 100644 index 00000000..c2530bcc --- /dev/null +++ b/src/modules/rasterLayers/schema.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; +import { type Method, methodMessage } from "~/lib/builders"; +import type { ModuleSchema } from "~/lib/ModuleSchema"; +import type { zInfer } from "~/lib/utils"; +import { + CreateEphemeralRasterLayerSchema, + type EphemeralRasterLayer, + SetEphemeralRasterLayerCoordinatesSchema, + UpdateEphemeralRasterLayerSchema, +} from "./types"; + +const CreateEphemeralRasterLayerMessage = methodMessage( + "createEphemeralRasterLayer", + CreateEphemeralRasterLayerSchema, +); + +const UpdateEphemeralRasterLayerMessage = methodMessage( + "updateEphemeralRasterLayer", + UpdateEphemeralRasterLayerSchema, +); + +const SetEphemeralRasterLayerCoordinatesMessage = methodMessage( + "setEphemeralRasterLayerCoordinates", + SetEphemeralRasterLayerCoordinatesSchema, +); + +const DeleteEphemeralRasterLayerMessage = methodMessage( + "deleteEphemeralRasterLayer", + z.string(), +); + +export const rasterLayersSchema = { + methods: [ + CreateEphemeralRasterLayerMessage, + UpdateEphemeralRasterLayerMessage, + SetEphemeralRasterLayerCoordinatesMessage, + DeleteEphemeralRasterLayerMessage, + ], + listeners: [], +} satisfies ModuleSchema; + +export type RasterLayersSchema = { + methods: { + createEphemeralRasterLayer: Method< + zInfer, + EphemeralRasterLayer + >; + updateEphemeralRasterLayer: Method< + zInfer + >; + setEphemeralRasterLayerCoordinates: Method< + zInfer + >; + deleteEphemeralRasterLayer: Method< + zInfer + >; + }; + listeners: {}; +}; diff --git a/src/modules/rasterLayers/types.ts b/src/modules/rasterLayers/types.ts new file mode 100644 index 00000000..55511afa --- /dev/null +++ b/src/modules/rasterLayers/types.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import type { zInfer } from "~/lib/utils"; + +/** + * A single `[longitude, latitude]` coordinate pair. + */ +export const LngLatSchema = z.tuple([z.number(), z.number()]); + +/** + * The geographic anchor for an ephemeral raster layer. Either an axis-aligned + * bounding box `[west, south, east, north]`, or four explicit corner + * `[lng, lat]` pairs in the order `[topLeft, topRight, bottomRight, bottomLeft]`. + */ +export const RasterLayerCoordinatesSchema = z.union([ + z.tuple([z.number(), z.number(), z.number(), z.number()]), + z.tuple([LngLatSchema, LngLatSchema, LngLatSchema, LngLatSchema]), +]); + +export type RasterLayerCoordinates = zInfer; + +export const CreateEphemeralRasterLayerSchema = z.object({ + /** The pixel width of the raster's backing buffer. */ + width: z.number(), + /** The pixel height of the raster's backing buffer. */ + height: z.number(), + /** Where to place the raster on the map. */ + coordinates: RasterLayerCoordinatesSchema, + /** + * Initial RGBA8 pixels (`width * height * 4` bytes), row-major with row 0 at + * the northern edge. When omitted the raster starts fully transparent. The + * buffer is transferred to the map, detaching it in the calling context. + */ + data: z.instanceof(ArrayBuffer).optional(), + /** Layer opacity in `[0, 1]`. Defaults to `1`. */ + opacity: z.number().optional(), +}); + +export type CreateEphemeralRasterLayerParams = zInfer< + typeof CreateEphemeralRasterLayerSchema +>; + +export const UpdateEphemeralRasterLayerSchema = z.object({ + /** The id returned by `createEphemeralRasterLayer`. */ + id: z.string(), + /** + * New RGBA8 pixels (`width * height * 4` bytes) matching the layer's + * dimensions. The buffer is transferred to the map, detaching it in the + * calling context. + */ + data: z.instanceof(ArrayBuffer), +}); + +export type UpdateEphemeralRasterLayerParams = zInfer< + typeof UpdateEphemeralRasterLayerSchema +>; + +export const SetEphemeralRasterLayerCoordinatesSchema = z.object({ + /** The id returned by `createEphemeralRasterLayer`. */ + id: z.string(), + /** The new geographic anchor for the raster. */ + coordinates: RasterLayerCoordinatesSchema, +}); + +export type SetEphemeralRasterLayerCoordinatesParams = zInfer< + typeof SetEphemeralRasterLayerCoordinatesSchema +>; + +/** + * The result of creating an ephemeral raster layer. + */ +export interface EphemeralRasterLayer { + /** The id of the created layer, used to update or delete it later. */ + id: string; +}