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
10 changes: 10 additions & 0 deletions .changeset/raster-layers-ephemeral.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
35 changes: 35 additions & 0 deletions src/lib/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,41 @@ export function method<TKey extends keyof MethodSpec>(
};
}

/**
* 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<TKey extends keyof MethodSpec>(
feltWindow: Pick<Window, "postMessage">,
type: TKey,
getTransferables: (params: OneMethod<TKey>) => Transferable[],
): FeltMethod<TKey> {
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<string, Function> = {};

export function methodWithListeners<
Expand Down
6 changes: 6 additions & 0 deletions src/modules/main/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +40,7 @@ export function makeController(
...viewportController(feltWindow),
...uiController(feltWindow),
...layersController(feltWindow),
...rasterLayersController(feltWindow),
...elementsController(feltWindow),
...selectionController(feltWindow),
...interactionsController(feltWindow),
Expand Down Expand Up @@ -65,6 +70,7 @@ export interface FeltController
extends ViewportController,
UiController,
LayersController,
RasterLayersController,
ElementsController,
SelectionController,
InteractionsController,
Expand Down
6 changes: 6 additions & 0 deletions src/modules/main/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +19,7 @@ export const allModules = [
uiSchema,
viewportSchema,
layersSchema,
rasterLayersSchema,
elementsSchema,
selectionSchema,
interactionsSchema,
Expand All @@ -27,6 +32,7 @@ export type AllModules =
| UiSchema
| ViewportSchema
| LayersSchema
| RasterLayersSchema
| ElementsSchema
| SelectionSchema
| InteractionsSchema
Expand Down
105 changes: 105 additions & 0 deletions src/modules/rasterLayers/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { method, methodWithTransfer } from "~/lib/interface";
import type {
CreateEphemeralRasterLayerParams,
EphemeralRasterLayer,
SetEphemeralRasterLayerCoordinatesParams,
UpdateEphemeralRasterLayerParams,
} from "./types";

/**
* @ignore
*/
export const rasterLayersController = (
feltWindow: Pick<Window, "postMessage">,
): 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<EphemeralRasterLayer>;

/**
* 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<void>;

/**
* Moves an existing ephemeral raster layer's quad without recreating it.
*/
setEphemeralRasterLayerCoordinates(
params: SetEphemeralRasterLayerCoordinatesParams,
): Promise<void>;

/**
* Removes an ephemeral raster layer and frees its GPU resources.
*/
deleteEphemeralRasterLayer(id: string): Promise<void>;
}
19 changes: 19 additions & 0 deletions src/modules/rasterLayers/index.ts
Original file line number Diff line number Diff line change
@@ -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";
59 changes: 59 additions & 0 deletions src/modules/rasterLayers/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CreateEphemeralRasterLayerMessage>,
EphemeralRasterLayer
>;
updateEphemeralRasterLayer: Method<
zInfer<typeof UpdateEphemeralRasterLayerMessage>
>;
setEphemeralRasterLayerCoordinates: Method<
zInfer<typeof SetEphemeralRasterLayerCoordinatesMessage>
>;
deleteEphemeralRasterLayer: Method<
zInfer<typeof DeleteEphemeralRasterLayerMessage>
>;
};
listeners: {};
};
74 changes: 74 additions & 0 deletions src/modules/rasterLayers/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof RasterLayerCoordinatesSchema>;

Check failure on line 19 in src/modules/rasterLayers/types.ts

View workflow job for this annotation

GitHub Actions / ci

Type 'ZodUnion<[ZodTuple<[ZodNumber, ZodNumber, ZodNumber, ZodNumber], null>, ZodTuple<[ZodTuple<[ZodNumber, ZodNumber], null>, ZodTuple<...>, ZodTuple<...>, ZodTuple<...>], null>]>' does not satisfy the constraint '{ shape: ZodRawShape; }'.

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;
}
Loading