diff --git a/.changeset/llm-api-reference.md b/.changeset/llm-api-reference.md new file mode 100644 index 00000000..4718ef92 --- /dev/null +++ b/.changeset/llm-api-reference.md @@ -0,0 +1,5 @@ +--- +"@feltmaps/js-sdk": patch +--- + +Ship `llms-full.txt`, a single-file API reference for LLM consumption, in the package. It is generated from the TypeScript definitions on every build. diff --git a/CLAUDE.md b/CLAUDE.md index e41f8611..48e47e38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Public JS SDK for embedding and controlling Felt maps. Two entry points: `client ## Commands - `npm run check` — full validation (bundle, format, API, types, tests, docs) -- `npm run build` — compile + generate docs +- `npm run build` — compile + generate `docs/` and `llms-full.txt` (commit both; `npm run check` fails on drift) - `npm run update-api` — regenerate `etc/js-sdk.api.md` after API changes (stage before `npm run check`) ## Key constraints @@ -13,3 +13,16 @@ Public JS SDK for embedding and controlling Felt maps. Two entry points: `client - Every change needs a changeset (`npm run changeset`). - Never edit `version` in `package.json` by hand; the release flow (`changeset version`) owns it and syncs `package-lock.json`. If `npm run check:lockfile` fails, run `npm install --package-lock-only`. - See `DEVELOPING.md` for module structure and `RELEASING.md` for the branching/release model. + +## TSDoc conventions + +`llms-full.txt` is generated from the TSDoc and is what AI agents read when using the SDK, so the +comment on every exported type, property and method is part of the public API. + +- Summary: one sentence. Only the first sentence is rendered. +- `@defaultValue` for defaults. Rendered as `default: ...`; not rendered for `undefined`. +- `@remarks` for anything an agent needs that the types don't show: what each enum value means, + pagination rules, caveats, which methods a value can be passed to. Rendered in full; bulleted and + numbered lists keep their lines. +- `@example` is only rendered in the TypeDoc site, not in `llms-full.txt`, so don't rely on examples + to carry information that isn't also in the summary or `@remarks`. diff --git a/DEVELOPING.md b/DEVELOPING.md index fc828d9b..c9f1f090 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -70,6 +70,12 @@ Once you have added your module: 1. Run `npm run build` to ensure everything compiles and the docs are generated. It's possible for the docs to fail to build if some types are not exported, but you will be warned in the console about this. + This also regenerates `llms-full.txt`, a single-file API reference for LLM consumption that ships in + the npm package and is used by the Felt app as agent context. It is derived from the TypeScript + definitions and TSDoc; the first sentence of each summary, `@defaultValue` and the full text of + `@remarks` are rendered there (`@example` blocks are not), so keep summaries to one sentence, put + defaults in `@defaultValue`, and put anything an agent needs that the types don't show under + `@remarks`. 2. Run `npm run update-api` to run api-extractor which updates the "api spec" file, which allows reviewers to understand the changes made to the API. 3. Stage or commit your changes. This is important, because the next step will fail if you have @@ -86,6 +92,7 @@ There are various checks in the `check` script to ensure that: - the bundle is correctly built with the correct types - everything is used (i.e. you didn't forget to add your module to the main module list) - the docs build and all necessary types are included +- the committed `docs/` and `llms-full.txt` output matches what the build generates - `zod` isn't accidentally included in the client bundle - the `handler` correctly receives every message that the client can send diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 00000000..777e4cba --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,1942 @@ +# Felt SDK API Reference + +Generated from the SDK's TypeScript definitions. Sections follow the SDK's module structure; every type and every `FeltController` member appears exactly once. `felt` is the `FeltController` returned by `Felt.embed(...)` or `Felt.connect(...)`. + +Methods on `felt`, by section: + +- Layers: getLayer, getLayers, setLayerVisibility, setLayerStyle, setLayerLegendVisibility, createLayersFromGeoJson, updateLayer, deleteLayer, duplicateLayer, getLayerGroup, getLayerGroups, setLayerGroupVisibility, setLayerGroupLegendVisibility, getLegendItem, getLegendItems, setLegendItemVisibility, getLayerFilters, setLayerFilters, getLayerBoundaries, setLayerBoundary, getRenderedFeatures, getFeature, getFeatures, getGeoJsonFeature, getCategoryData, getHistogramData, getAggregates, getPrecomputedAggregates, getRasterAggregates, getRasterHistogramData, getRasterCategoryData, getRasterProfile, getLayerSchema; events: onLayerChange, onLayerGroupChange, onLegendItemChange, onLayerFiltersChange, onLayerBoundariesChange +- Elements: getElement, getElementGeometry, getElements, getElementGroup, getElementGroups, setElementGroupVisibility, createElement, updateElement, deleteElement; events: onElementCreate, onElementCreateEnd, onElementChange, onElementDelete, onElementGroupChange +- Selection: getSelection, selectFeature, clearSelection; events: onSelectionChange +- Interactions: events: onPointerClick, onPointerMove +- Basemaps: getCurrentBasemap, getBasemaps, chooseBasemap, addCustomBasemap, removeBasemap; events: onBasemapChange +- Tools: setTool, getTool, setToolSettings, getToolSettings; events: onToolChange, onToolSettingsChange +- UI: createActionTrigger, updateActionTrigger, deleteActionTrigger, createFeatureAction, updateFeatureAction, deleteFeatureAction, createPanelId, createOrUpdatePanel, deletePanel, createPanelElements, updatePanelElements, deletePanelElements, updateUiControls, setOnMapInteractionsUi, showLayerDataTable, hideLayerDataTable +- Viewport: getViewport, setViewport, getViewportConstraints, setViewportConstraints, fitViewportToBounds; events: onViewportMove, onViewportMoveEnd, onMapIdle +- Map Details: getMapDetails + +## Entry Point + +```typescript +type FeltEmbedOptions = { + uiControls?: UiControlsOptions; + initialViewport?: ViewportCenterZoom; + // A short-lived (15 minutes) authentication token to use for showing embeds that are configured to be private. + token?: string; +}; + +// The Felt SDK is a library for embedding Felt maps into your website. +const Felt: { + embed: (container: HTMLElement, mapId: string, options?: FeltEmbedOptions) => Promise; + connect: (feltWindow: Pick) => Promise; +}; +``` + +### Properties + +```typescript +// The iframe element containing the Felt map, if it is an embedded map. +felt.iframe: HTMLIFrameElement | null; +``` + +## Core Types + +```typescript +// A tuple representing a longitude and latitude coordinate. +type LngLatTuple = [longitude: number, latitude: number]; + +// The zoom level of the map. +type FeltZoom = number; + +// The edges of the map in the form of a bounding box. +type FeltBoundary = [number, number, number, number]; + +// Specifies the direction to sort data in +type SortDirection = "asc" | "desc"; + +// Represents a point in world coordinates. +type LatLng = { latitude: number; longitude: number }; + +// A GeoJSON feature object, compliant with: https://datatracker.ietf.org/doc/html/rfc7946#section-3.2 +type GeoJsonFeature = { + type: "Feature"; + geometry: GeoJsonGeometry; + properties: GeoJsonProperties; + // The bounding box of the feature in [west, south, east, north] order. + bbox?: FeltBoundary; + // A value that uniquely identifies this feature in a https://tools.ietf.org/html/rfc7946#section-3.2. + id?: string | number; +}; + +type PointGeometry = { type: "Point"; coordinates: LngLatTuple }; + +// A GeoJSON multi-point geometry. +// You shouldn't expect this to come from Felt - it is here for completeness of the GeoJSON spec. +type MultiPointGeometry = { type: "MultiPoint"; coordinates: LngLatTuple[] }; + +type PolygonGeometry = { type: "Polygon"; coordinates: LngLatTuple[][] }; + +// A GeoJSON multi-polygon geometry. +type MultiPolygonGeometry = { type: "MultiPolygon"; coordinates: LngLatTuple[][][] }; + +// A GeoJSON line string geometry. +type LineStringGeometry = { type: "LineString"; coordinates: LngLatTuple[] }; + +// A GeoJSON multi-line string geometry. +type MultiLineStringGeometry = { type: "MultiLineString"; coordinates: LngLatTuple[][] }; + +// The parameters for the methods that change the visibility of entities. +type SetVisibilityRequest = { + // The ids of the entities you want to change the visibility of. + show?: string[]; + hide?: string[]; +}; + +// Configuration for sorting data by a specific attribute +type SortConfig = { direction: "asc" | "desc"; attribute: string }; + +// A GeoJSON properties object. +type GeoJsonProperties = Record; + +// A GeoJSON geometry of any type +type GeoJsonGeometry = PointGeometry | PolygonGeometry | LineStringGeometry | MultiLineStringGeometry | MultiPolygonGeometry | MultiPointGeometry; +``` + +## Layers + +```typescript +// This describes the processing status of a layer. +type LayerProcessingStatus = "processing" | "completed" | "failed" | "incomplete"; + +// Describes how the layer is displayed in the legend. +type LegendDisplay = "default" | "nameOnly"; + +// A LayerFeature is a single geographical item in a layer. +type LayerFeature = { + id: string | number; // The identifier of the feature, unique within the layer. + // If the id is deterministic, it means that the id can be used to reference the feature in the layer + // and therefore in all the SDK feature-related methods. + // When the id is not deterministic, the feature cannot be referenced on SDK methods like + // FeltController.getFeature or SelectionController.selectFeature. + // For layers created and processed on Felt servers, the feature IDs are deterministic because Felt + // ensures every feature is correctly identified in vector tiles. This cannot be guaranteed for layers + // created using a GeoJSON source on the SDK (see LayersController.createLayersFromGeoJson) where the + // ID will only be deterministic if GeoJSON features have an `id` property. + isDeterministicId: boolean; + layerId: string; + // Because LayerFeatures can be read from tiled features, it's possible that this `geometryType` won't + // match the `geometry.type` of the GeoJsonFeature returned by FeltController.getGeoJsonFeature. + // For example, this may return `LineString` but the full feature is a `MultiLineString`, or, similarly + // `Polygon` here may be a `MultiPolygon` in the full feature. + // As a result, you should treat this property as being indicative only. + geometryType: "Polygon" | "MultiPolygon" | "LineString" | "MultiLineString" | "Point" | "MultiPoint" | string & {}; + // Because LayerFeatures can be read from tiled features and considering that feature geometry can go + // through multiple tiles, it's possible that this is not the complete bounding box of the feature. + bbox: FeltBoundary | undefined; + properties: GeoJsonProperties; +}; + +// A raster pixel value for a specific layer. +type RasterValue = { + value: number; + layerId: string; + categoryName: string | null; + color: { r: number; g: number; b: number; a: number } | null; +}; + +// The common properties for all layers. +type LayerCommon = { + id: string; + groupId: string | null; + name: string; // The name of the layer can be displayed in the Legend. + caption: string | null; + description: string | null; // The layer description forms part of the layer's metadata. + visible: boolean; + shownInLegend: boolean; + legendDisplay: LegendDisplay; + style: object; + status: LayerProcessingStatus; + // The bounding box of the layer in [west, south, east, north] order + bounds: FeltBoundary | null; +}; + +// A raster layer is a layer that contains raster data that can be rendered on the map +type RasterLayer = LayerCommon & { geometryType: "Raster"; source: RasterLayerSource }; + +// The source of a raster layer's data. +type RasterLayerSource = { + imageTileTemplateUrl: string; + // A URL template for fetching encoded tiles for the raster, or `null` for TileService layers. + encodedTileTemplateUrl: string | null; + bands: RasterBand[]; +}; + +// The RasterBand interface describes one band of a raster: how to identify it when asking for statistics. +type RasterBand = { + id: string; + displayName: string; // The name of the band, such as "Red" or "Elevation". + base: number; // Encoding base value as a floating point number + interval: number; // Encoding interval as a floating point number + bandIndex: number; +}; + +// A vector layer is a layer that contains vector data that can be rendered on the map +type VectorLayer = LayerCommon & { + geometryType: "Polygon" | "Point" | "Line"; + source: GeoJsonUrlVectorSource | FeltTiledVectorSource | Omit; +}; + +// A GeoJSON URL source is a layer that is populated from a GeoJSON file at a remote URL. +type GeoJsonUrlVectorSource = { + type: "geoJsonUrl"; + // The remote URL of the GeoJSON file used to populate the layer. + url: string; + // The interval in milliseconds between automatic refreshes of the GeoJSON. + refreshInterval?: number | null; +}; + +// A GeoJSON data source is a layer that is populated from GeoJSON data, such as from a local file. +type GeoJsonDataVectorSource = { type: "geoJsonData"; data: object }; + +// A GeoJSON file source is a layer that is populated from a GeoJSON file on your local machine. +type GeoJsonFileVectorSource = { type: "geoJsonFile"; file: File }; + +// The value you need to pass to LayersController.updateLayer +type UpdateLayerParams = { + id: string; + shownInLegend?: boolean; + legendDisplay?: LegendDisplay; + name?: string; + caption?: string; + description?: string; + bounds?: FeltBoundary; + style?: object; + source?: GeoJsonUrlVectorSource | GeoJsonDataVectorSource | GeoJsonFileVectorSource; +}; + +// A data-only layer doesn't have any geometry, but can be used to join with other layers +type DataOnlyLayer = LayerCommon & { geometryType: null; bounds: null }; + +type LayerGroup = { + id: string; + name: string; + caption: string | null; + // You can use these ids to get the full layer objects via the `getLayers` method. + layerIds: string[]; + visible: boolean; + shownInLegend: boolean; + // The bounding box of the layer group in [west, south, east, north] order. + bounds: FeltBoundary | null; +}; + +// The constraints to apply when getting layers. +type GetLayersConstraint = { ids?: string[] }; + +// The constraints to apply when getting layer groups. +type GetLayerGroupsConstraint = { ids?: string[] }; + +// The parameters for the `onLayerChange` listener. +type LayerChangeCallbackParams = { + layer: Layer | null; // The new data for the layer or null if the layer was removed. +}; + +// The parameters for the `onLayerGroupChange` listener. +type LayerGroupChangeCallbackParams = { layerGroup: LayerGroup | null }; + +// A legend item, which often represents a sub-class of features in a layer in the case of categorical or... +type LegendItem = LegendItemIdentifier & { + title: string | string[]; + titleDependsOnZoom: boolean; // Whether the title depends on the zoom level or not. + visible: boolean; +}; + +// The identifier for a legend item. +type LegendItemIdentifier = { id: string; layerId: string }; + +// Constraints for legend items. +type LegendItemsConstraint = { ids?: LegendItemIdentifier[]; layerIds?: string[] }; + +// The parameters for the `onLegendItemChange` listener. +type LegendItemChangeCallbackParams = { + // The new data for the legend item or null if the legend item was removed. + legendItem: LegendItem | null; +}; + +// Constraints for the `getRenderedFeatures` method. +type GetRenderedFeaturesConstraint = { + areaQuery?: { coordinates: LatLng } | { boundary: FeltBoundary }; + layerIds?: string[]; +}; + +// The schema that describes the structure of the features in a layer. +// This can be useful to build generic UIs that need to know the structure of the data in a layer, such +// as a dropdown to choose an attribute. +type LayerSchema = { + featureCount: number; + // Array of attribute schemas describing the properties available on features in this layer. + attributes: LayerSchemaAttribute[]; +}; + +// The common schema for all attributes. +type LayerSchemaCommonAttribute = { + id: string; + displayName: string; + // The specific data type of this attribute, providing more detail than the basic type. + detailedType: string; + // The number of distinct values present for this attribute across all features. + distinctCount: number; +}; + +// The schema for a numeric attribute on a layer. +type LayerSchemaNumericAttribute = LayerSchemaCommonAttribute & { + type: "numeric"; + // A small sample of values for this attribute and their frequency. + sampleValues: { value: number; count: number }[]; + // The minimum value present for this attribute across all features. + min: number; + // The maximum value present for this attribute across all features. + max: number; +}; + +// The schema for a text attribute on a layer. +type LayerSchemaTextAttribute = LayerSchemaCommonAttribute & { + type: "text"; + // A small sample of string values for this attribute and their frequency. + sampleValues: { value: string; count: number }[]; +}; + +// The schema for a boolean attribute on a layer. +type LayerSchemaBooleanAttribute = LayerSchemaCommonAttribute & { + type: "boolean"; + // A representative sample of boolean values for this attribute and their frequency. + sampleValues: { value: boolean; count: number }[]; +}; + +// The schema for a date attribute on a layer. +type LayerSchemaDateAttribute = LayerSchemaCommonAttribute & { + type: "date"; + // The earliest date present for this attribute in truncated ISO8601 format (YYYY-MM-DD). + min: string; + // The latest date present for this attribute in truncated ISO8601 format (YYYY-MM-DD). + max: string; + // A representative sample of date values for this attribute and their frequency. + sampleValues: { value: string; count: number }[]; +}; + +// The schema for a datetime attribute on a layer. +type LayerSchemaDateTimeAttribute = LayerSchemaCommonAttribute & { + type: "datetime"; + // The earliest datetime present for this attribute in ISO8601 format. + min: string; + // The latest datetime present for this attribute in ISO8601 format. + max: string; + // A representative sample of datetime values for this attribute and their frequency. + sampleValues: { value: string; count: number }[]; +}; + +// The parameters for the LayersController.createLayersFromGeoJson method. +type CreateLayersFromGeoJsonParams = { + name: string; + source: GeoJsonUrlVectorSource | GeoJsonDataVectorSource | GeoJsonFileVectorSource; + bounds?: FeltBoundary; + caption?: string; + description?: string; + geometryStyles?: { Point?: object; Line?: object; Polygon?: object }; +}; + +// A tiled vector source is a layer that is populated from data the has been uploaded to Felt. +type FeltTiledVectorSource = { type: "felt"; tileTemplateUrl: string }; + +type Layer = RasterLayer | VectorLayer | DataOnlyLayer; + +// A single attribute from the layer schema. +// Each feature in a layer has a set of attributes, and these types describe the structure of a single +// attribute, including things like id, display name, type, and sample values. +type LayerSchemaAttribute = LayerSchemaNumericAttribute | LayerSchemaTextAttribute | LayerSchemaBooleanAttribute | LayerSchemaDateAttribute | LayerSchemaDateTimeAttribute; +``` + +### Filters + +```typescript +type FilterLogicGate = "and" | "or"; + +type FilterExpression = [string | null, "in" | "ni", (string | number | boolean | null)[] | null] | [string | null, "lt" | "gt" | "le" | "ge" | "eq" | "ne" | "cn" | "nc" | "is" | "isnt", string | number | boolean | null]; + +// A `FilterTernary` is a tree structure for combining expressions with logical operators. +type FilterTernary = [Filters, FilterLogicGate, Filters]; + +// The filters that are currently set on a layer. +type LayerFilters = { + style: Filters; + // Filters that are set in the layer's components, which are interactive elements in the legend. + components: Filters; + // Filters that are set ephemerally by viewers in their own session. + ephemeral: Filters; + combined: Filters; +}; + +// All the different sources for boundaries for a layer, including their combined result. +type LayerBoundaries = { + spatialFilters: MultiPolygonGeometry | null; + // Boundaries that are set ephemerally by viewers in their own session. + ephemeral: GeometryFilter | null; + combined: MultiPolygonGeometry | null; +}; + +// Filters can be used to change which features in a layer are rendered. +// The possible operators are: +// - `lt`: Less than +// - `gt`: Greater than +// - `le`: Less than or equal to +// - `ge`: Greater than or equal to +// - `eq`: Equal to +// - `ne`: Not equal to +// - `cn`: Contains +// - `nc`: Does not contain +// - `is`: Is +// - `isnt`: Is not +// - `in`: In +// - `ni`: Not in +// The allowed boolean operators are: +// - `and`: Logical AND +// - `or`: Logical OR +type Filters = FilterTernary | FilterExpression | boolean | null; + +// The common type for filtering data by a spatial boundary. +type GeometryFilter = FeltBoundary | PolygonGeometry | MultiPolygonGeometry | LngLatTuple[]; +``` + +### Raster + +```typescript +// A statistic that can be calculated for a raster band. +type RasterAggregationMethod = AggregationMethod | "stddev" | "majority" | "area"; + +// The parameters for calculating statistics for a raster band. +type GetRasterAggregatesParams = { + layerId: string; + // The ID of the band to calculate statistics for, read from the `bands` on a raster layer's source. + bandId: string; + aggregation: { + methods: T[]; // The statistics to calculate for the band. + // Percentile ranks between 0 and 100 to calculate, such as `[10, 50, 90]`. + percentiles?: number[]; + }; + boundary?: GeometryFilter; // The spatial boundary for the pixels to include. + // Filters on band values for the pixels to include, such as `["band:1", "gt", 100]`. + filters?: Filters; +}; + +// One percentile from the response to the LayersController.getRasterAggregates method. +type RasterPercentile = { rank: number; value: number | null }; + +// The response from the LayersController.getRasterAggregates method. +type GetRasterAggregatesResult = { + stats: Record; // The value calculated for each requested statistic. + percentiles?: RasterPercentile[]; // The requested percentiles, in the order they were requested. +}; + +// The parameters for requesting a histogram of a raster band's values. +type GetRasterHistogramParams = { + layerId: string; + // The ID of the band to bin, read from the `bands` on a raster layer's source. + bandId: string; + // How to divide the band's values into bins, either as a number of equal intervals or as the bin edges... + steps: number[] | { type: "equal-intervals"; count: number }; + boundary?: GeometryFilter; // The spatial boundary for the pixels to include. + filters?: Filters; // Filters on band values for the pixels to include. +}; + +// One bin from the response to the LayersController.getRasterHistogramData method. +type RasterHistogramBin = { min: number; max: number; value: number }; + +// The response from the LayersController.getRasterHistogramData method. +type GetRasterHistogramResult = { bins: RasterHistogramBin[] }; + +// The parameters for counting the distinct values of a raster band. +type GetRasterCategoriesParams = { + layerId: string; + // The ID of the band to count values for, read from the `bands` on a raster layer's source. + bandId: string; + boundary?: GeometryFilter; // The spatial boundary for the pixels to include. + filters?: Filters; // Filters on band values for the pixels to include. + limit?: number; +}; + +// One category from the response to the LayersController.getRasterCategoryData method. +type RasterCategory = { + key: number; + value: number; + areaM2?: number; // The ground area of this category's pixels, in square metres. +}; + +// The response from the LayersController.getRasterCategoryData method. +type GetRasterCategoriesResult = { + categories: RasterCategory[]; // The categories, most frequent first. + total: number; // How many distinct values the band holds within the scope. +}; + +// The parameters for sampling a raster band along a line. +type GetRasterProfileParams = { + layerId: string; + // The ID of the band to sample, read from the `bands` on a raster layer's source. + bandId: string; + boundary: LineStringGeometry | MultiLineStringGeometry; // The line to sample the band along. +}; + +// One sample from the response to the LayersController.getRasterProfile method. +type RasterProfileSample = { + // How far along the line this sample was taken, in ground metres from the line's start. + distanceM: number; + // The band's value at this sample, or `null` where the raster holds no data or the line runs outside the raster. + value: number | null; +}; + +// The response from the LayersController.getRasterProfile method. +type GetRasterProfileResult = { + samples: RasterProfileSample[]; // The samples, ordered from the line's start. +}; +``` + +### Stats + +```typescript +// The method to use for the aggregation. +type AggregationMethod = "min" | "max" | "avg" | "sum" | "median"; + +// The method to use for the precomputed aggregation. +type PrecomputedAggregationMethod = "min" | "max" | "avg" | "sum" | "count"; + +// The type of grid to use for precomputed aggregate values. +type GridType = "h3"; + +// Defines how to aggregate a value across features in a layer. +type AggregationConfig = { attribute: string; method: AggregationMethod }; + +// Defines how to aggregate a value across features in a layer with multiple aggregations returned at once. +type MultiAggregationConfig = { + // The operations to use on the values from the features in the layer + methods: T[]; + // The attribute ID to use for the aggregation when aggregations other than "count" are used. + attribute?: string; +}; + +// Configuration for filtering and aggregating values across features. +type ValueConfiguration = { + boundary?: GeometryFilter; // The spatial boundary for what to count or aggregate. + // Attribute filters to determine what gets counted or aggregated. + filters?: Filters; + // Specifies how to aggregate values within each category or bin. + aggregation?: AggregationConfig; +}; + +// The parameters for getting categories from a layer, passed to the LayersController.getCategoryData method. +type GetLayerCategoriesParams = { + layerId: string; + attribute: string; + limit?: number; + // Attribute filters for the features to include when calculating the categories. + filters?: Filters; + // The spatial boundary for the features to include when calculating the categories. + boundary?: GeometryFilter; + // Configuration for filtering and aggregating values while preserving the full set of categories in the results. + values?: ValueConfiguration; +}; + +// A single category from the response from the LayersController.getCategoryData method. +type GetLayerCategoriesGroup = { + key: string | number | boolean; // The category for which the value was calculated. + // The value calculated for the category, whether a count, sum, average, etc. + value: number | null; +}; + +// The params used to request a histogram of values from a layer. +type GetLayerHistogramParams = { + layerId: string; + attribute: string; + steps: number[] | { type: "equal-intervals"; count: number } | { + type: "time-interval"; + interval: "hour" | "day" | "week" | "month" | "year"; + }; + // Configuration for filtering and aggregating values while preserving the full set of bin ranges in the results. + values?: ValueConfiguration; + // Attribute filters for the features to include when calculating the histogram bins. + filters?: Filters; + // The spatial boundary for the features to include when calculating the histogram bins. + boundary?: GeometryFilter; +}; + +// One bin from the response from the LayersController.getHistogramData method. +type GetLayerHistogramBin = { min: number; max: number; value: number }; + +// The parameters for calculating a single aggregate value for a layer. +type GetLayerCalculationParams = { + layerId: string; + // Specifies how to aggregate values within each category or bin. + aggregation: MultiAggregationConfig; + // Attribute filters for the features to include when calculating the aggregate value. + filters?: Filters; + // The spatial boundary for the features to include when calculating the aggregate value. + boundary?: GeometryFilter; +}; + +// The grid configuration for a count-based precomputed aggregate value. +type CountGridConfig = { + resolution: number; + type: "h3"; + // The method to use for the precomputed calculation, which in this case is always "count". + method: "count"; +}; + +// The grid configuration for an aggregated precomputed aggregate value. +type AggregatedGridConfig = { + resolution: number; + attribute: string; + type: "h3"; + method: "min" | "max" | "avg" | "sum"; +}; + +// The parameters for calculating a single aggregate value for a layer. +type GetLayerPrecomputedCalculationParams = { + layerId: string; + gridConfig: GridConfig; + // Attribute filters for the features to include when calculating the aggregate value. + filters?: Filters; + // The spatial boundary for the features to include when calculating the aggregate value. + boundary?: GeometryFilter; +}; + +// Describes the type of grid to use for precomputed aggregate values. +type GridConfig = CountGridConfig | AggregatedGridConfig; +``` + +### Methods + +```typescript +// Get a single layer from the map by its id. +felt.getLayer(id: string): Promise; + +// Gets layers from the map, according to the constraints supplied. +// The layers in the map, ordered by the order specified in Felt. This is not necessarily the order +// that they are drawn in, as Felt draws points above lines and lines above polygons, for instance. +felt.getLayers(constraint?: GetLayersConstraint): Promise<(Layer | null)[]>; + +// Hide or show layers with the given ids. +felt.setLayerVisibility(visibility: SetVisibilityRequest): Promise; + +// Set the style for a layer using FSL, the Felt Style Language. +felt.setLayerStyle(params: { id: string; style: object }): Promise; + +// Hide or show layers with the given ids from the legend. +felt.setLayerLegendVisibility(params: SetVisibilityRequest): Promise; + +// Adds layers to the map from file or URL sources. +// This allows you to add temporary layers to the map that don't depend on any processing by Felt. This +// is useful for viewing data from external sources or remote files. +felt.createLayersFromGeoJson(params: CreateLayersFromGeoJsonParams): Promise<{ + // The layer group that was created containing the created layers. + layerGroup: LayerGroup; + layers: Layer[]; // The layers that were created from the source. +} | null>; + +// Update a layer by passing a subset of the layer's properties. +felt.updateLayer(params: UpdateLayerParams): Promise; + +// Delete a layer from the map by its id. +// This only works for layers created via the SDK `createLayersFromGeoJson` method, not layers added +// via the Felt UI. +felt.deleteLayer(id: string): Promise; + +// Duplicate a layer from the map by its id. +// This will create an ephemeral copy of the layer, just for the duration of the session. The +// duplicated layer will not be persisted to the map. +felt.duplicateLayer(id: string): Promise; + +// Get a layer group from the map by its id. +felt.getLayerGroup(id: string): Promise; + +// Gets layer groups from the map, according to the constraints supplied. +felt.getLayerGroups(constraint?: GetLayerGroupsConstraint): Promise<(LayerGroup | null)[]>; + +// Hide or show layer groups with the given ids. +felt.setLayerGroupVisibility(visibility: SetVisibilityRequest): Promise; + +// Hide or show layer groups with the given ids from the legend. +felt.setLayerGroupLegendVisibility(params: SetVisibilityRequest): Promise; + +// Allows you to get the state of a single legend item. +felt.getLegendItem(id: LegendItemIdentifier): Promise; + +// Allows you to obtain the state of several legend items. +felt.getLegendItems(constraint?: LegendItemsConstraint): Promise<(LegendItem | null)[]>; + +// Hide or show legend items with the given identifiers. +felt.setLegendItemVisibility(visibility: { show?: LegendItemIdentifier[]; hide?: LegendItemIdentifier[] }): Promise; + +// Get the filters for a layer. +// The return type gives you the filters split up into the various sources that make up the overall +// filters for a layer. +felt.getLayerFilters(layerId: string): Promise; + +// Sets the **ephemeral** filters for a layer. +felt.setLayerFilters(params: { + layerId: string; + filters: Filters; + // A note to display on the layer legend when this filter is applied. + note?: string; +}): Promise; + +// Get the spatial boundaries that are filtering a layer. +// The return type gives you the boundaries split up into the various sources that make up the overall +// boundary for a layer. +// The combined boundary is the intersection of the other sources of boundaries. +felt.getLayerBoundaries(layerId: string): Promise; + +// Set the `ephemeral` boundary for one or more layers. +felt.setLayerBoundary(params: { layerIds: string[]; boundary: GeometryFilter | null }): Promise; + +// Get the features that are currently **rendered** on the map in the viewport. +felt.getRenderedFeatures(params?: GetRenderedFeaturesConstraint): Promise; + +// Get a feature from the map by its ID and layer ID. +felt.getFeature(params: { id: string | number; layerId: string }): Promise; + +// Get a list of layer features. +// This list is paginated in sets of 20 features for each page. In order to paginate between pages, the +// response includes `previousPage` and `nextPage` that are tokens that should be sent in the +// `pagination` params for requesting sibling pages. +// Text search is case-insensitive and looks for matches across all feature properties. +felt.getFeatures(params: { + layerId: string; + filters?: Filters; + sorting?: SortConfig; + boundary?: GeometryFilter; + search?: string; + pagination?: string | null; + pageSize?: number; // The number of features to return per page. + select?: string[]; // The attributes to select from the features. +}): Promise<{ + features: LayerFeature[]; // The list of features returned from the query. + count: number; // The total number of features that match the query. + previousPage: string | null; // The pagination token to get the previous page of features. + nextPage: string | null; // The pagination token to get the next page of features. +}>; + +// Get a feature in GeoJSON format from the map by its ID and layer ID. +felt.getGeoJsonFeature(params: { id: string | number; layerId: string }): Promise; + +// Gets values from a layer grouped by a given attribute. +// Groups features in your layer by unique values in the specified attribute and calculates a value for +// each group. By default, this value is the count of features in each group. +// You can apply filters in two ways: +// 1. At the top level (using `boundary` and `filters`), which affects both what categories are +// included and how values are calculated +// 2. In the `values` configuration, which only affects the values but keeps all categories +// This two-level filtering is particularly useful when you want to compare subsets of data while +// maintaining consistent categories. For example, you might want to show the distribution of all +// building types in a city, but only count buildings built after 2000 in each category. +felt.getCategoryData(params: GetLayerCategoriesParams): Promise; + +// Gets a histogram of values from a layer for a given attribute. +// Creates bins (ranges) for numeric data and counts how many features fall into each bin, or returns +// aggregated values for each bin. +// You can control how the bins are created using the `steps` parameter, choosing from several methods +// like equal intervals, quantiles, or natural breaks (Jenks), or passing in the step values directly +// if you know how you want to bin the data. +// Like getCategoryData, you can apply filters in two ways: +// 1. At the top level (using `boundary` and `filters`), which affects both how the bins are calculated +// and what features are counted in each bin +// 2. In the `values` configuration, which only affects what gets counted but keeps the bin ranges the +// same +// This is particularly useful when you want to compare distributions while keeping consistent bin +// ranges. For example, you might want to compare the distribution of building heights in different +// years while using the same height ranges. +felt.getHistogramData(params: GetLayerHistogramParams): Promise; + +// Calculates a single aggregate value for a layer based on the provided configuration. +// Performs statistical calculations on your data, like counting features or computing averages, sums, +// etc. You can focus your calculation on specific areas or subsets of your data using boundaries and +// filters. +// When you request an aggregation other than count, you must specify an attribute to aggregate on. +felt.getAggregates(params: GetLayerCalculationParams): Promise>; + +// Calculates aggregates for spatial cells of a layer. +// Performs statistical calculations on spatial cells of a layer, returning min, max, avg, sum, and +// count. You can focus your calculation on specific areas or subsets of your data using boundaries and +// filters. When using the count method, an attribute is not required. +felt.getPrecomputedAggregates(params: GetLayerPrecomputedCalculationParams): Promise<{ + avg: number | null; + max: number | null; + min: number | null; + sum: number | null; + count: number | null; +}>; + +// Calculates statistics for a raster band. +// Summarizes the pixel values of one band of a raster layer. Omit the boundary to summarize the whole +// raster, or pass a bounding box or polygon to summarize part of it. Filters restrict the summary to +// pixels whose band values match a condition. +felt.getRasterAggregates(params: GetRasterAggregatesParams): Promise>; + +// Calculates a histogram of a raster band's values. +// Divides a band's values into bins and counts the pixels falling into each one. You can pass a number +// of equal intervals, or the bin edges themselves when you already know how you want the values +// divided. +felt.getRasterHistogramData(params: GetRasterHistogramParams): Promise; + +// Counts the pixels holding each distinct value of a raster band. +// This suits a band whose values are class codes, such as a land cover raster. Each category reports +// how many pixels hold the value and how much ground they cover. A band whose values are not whole +// numbers cannot be counted this way, and throws. +felt.getRasterCategoryData(params: GetRasterCategoriesParams): Promise; + +// Samples a raster band's values along a line. +// Returns the values themselves rather than a summary of them, each paired with how far along the line +// it was taken. This is what an elevation profile along a route is drawn from. +felt.getRasterProfile(params: GetRasterProfileParams): Promise; + +// Get the schema for a layer. +// The schema describes the structure of the data in a layer, including the attributes that are +// available on the features in the layer. +// This can be useful to build generic UIs that need to know the structure of the data in a layer, such +// as a dropdown to choose an attribute. +felt.getLayerSchema(layerId: string): Promise; +``` + +### Events + +```typescript +// Adds a listener for when a layer changes. +felt.onLayerChange(args: { + options: { + id: string; // The id of the layer to listen for changes to. + }; + handler: (change: LayerChangeCallbackParams) => void; +}): VoidFunction; + +// Adds a listener for when a layer group changes. +felt.onLayerGroupChange(args: { + options: { id: string }; + handler: (change: LayerGroupChangeCallbackParams) => void; +}): VoidFunction; + +// Adds a listener for when a legend item changes. +felt.onLegendItemChange(args: { + options: LegendItemIdentifier; + handler: (change: LegendItemChangeCallbackParams) => void; +}): VoidFunction; + +// Adds a listener for when a layer's filters change. +// This event fires whenever any type of filter changes on the layer, including ephemeral filters set +// via the SDK, style-based filters, or filters set through the Felt UI via Components. +felt.onLayerFiltersChange(params: { options: { layerId: string }; handler: (change: LayerFilters) => void }): VoidFunction; + +// Adds a listener for when a layer's spatial boundaries change. +// This event fires whenever any type of spatial boundary changes on the layer, including ephemeral +// boundaries set via the SDK or boundaries set through the Felt UI via Spatial filter components. +felt.onLayerBoundariesChange(params: { + options: { + layerId: string; // The id of the layer to listen for boundary changes on. + }; + handler: (boundaries: LayerBoundaries | null) => void; // A function that is called when the boundaries change. +}): VoidFunction; +``` + +## Elements + +```typescript +// Shared by every ElementCreate variant. Documentation shorthand, not an exported type. +type ElementCreateCommon = { + groupId?: string | null; + // Text describing the element, which is shown in an element's popup when it is selected. + description?: string | null; + // A set of key-value pairs that can be used to store arbitrary data about the element. + attributes?: Record; + interaction?: "default" | "locked"; // default: "default" +}; + +type PlaceElementCreate = ElementCreateCommon & { + type: "Place"; + coordinates: LngLatTuple; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + imageUrl?: string | null; // The URL of an image that has been added to the element. + symbol?: string; + frame?: PlaceFrame; // The frame that is rendered around the Place's symbol. + hideLabel?: boolean; // Whether the element's label is hidden on the map. (default: false) +}; + +type PlaceElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type PlaceElementUpdate = { id: string; coordinates?: LngLatTuple } & Omit; + +type PathElementCreate = ElementCreateCommon & { + type: "Path"; + coordinates: LngLatTuple[][]; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + imageUrl?: string | null; // The URL of an image that has been added to the element. + strokeOpacity?: number; // default: 1 + strokeWidth?: number; // default: 2 + strokeStyle?: "solid" | "dashed" | "dotted"; // default: "solid" + // Whether a distance marker is shown at the midpoint of the path. + distanceMarker?: boolean; // default: false + // Whether this represents a route, and if so, what mode of transport is used. + routingMode?: "driving" | "cycling" | "walking" | "flying" | null; // default: null + endCaps?: boolean; // Whether or not to show Start and End caps on the path. (default: false) +}; + +type PathElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type PathElementUpdate = { id: string; coordinates?: LngLatTuple[][] } & Omit; + +type PolygonElementCreate = ElementCreateCommon & { + type: "Polygon"; + coordinates: LngLatTuple[][]; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + imageUrl?: string | null; // The URL of an image that has been added to the element. + strokeOpacity?: number; // default: 1 + strokeWidth?: number; // default: 2 + strokeStyle?: "solid" | "dashed" | "dotted"; // default: "solid" + fillOpacity?: number; // default: 0.25 + areaMarker?: boolean; // default: false +}; + +type PolygonElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type PolygonElementUpdate = { id: string; coordinates?: LngLatTuple[][] } & Omit; + +type CircleElementCreate = ElementCreateCommon & { + type: "Circle"; + radius: number; + center: LngLatTuple; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + imageUrl?: string | null; // The URL of an image that has been added to the element. + strokeOpacity?: number; // default: 1 + strokeWidth?: number; // default: 2 + strokeStyle?: "solid" | "dashed" | "dotted"; // default: "solid" + // Whether to show a marker on the circle that indicates the radius + radiusMarker?: boolean; // default: false + // The angle at which the control point for setting the radius is displayed, in degrees. + radiusDisplayAngle?: number; // default: 90 + radiusDisplayUnit?: "meter" | "kilometer" | "foot" | "mile" | null; // default: null + fillOpacity?: number; // default: 0.25 +}; + +type CircleElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type CircleElementUpdate = { id: string; type: "Circle" } & Partial>; + +type MarkerElementCreate = ElementCreateCommon & { + type: "Marker"; + coordinates: LngLatTuple[][]; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + opacity?: number; // default: 1 + // The size of the marker, used in conjunction with the `zoom` to determine the actual size of the marker. + size?: number; // default: 10 + zoom?: number; // The zoom level at which the marker was created. +}; + +type MarkerElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type MarkerElementUpdate = { id: string; coordinates?: LngLatTuple[][] } & Omit; + +type HighlighterElementCreate = ElementCreateCommon & { + type: "Highlighter"; + coordinates: LngLatTuple[][][]; // A multipolygon describing the area that is highlighted. + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + name?: string | null; + renderHoles?: boolean; // Whether to render the holes of the highlighted area. (default: false) + opacity?: number; // default: 0.5 +}; + +type HighlighterElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type HighlighterElementUpdate = { + id: string; + coordinates?: LngLatTuple[][][]; // A multipolygon describing the area that is highlighted. +} & Omit; + +type TextElementCreate = ElementCreateCommon & { + type: "Text"; + text: string; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + rotation?: number; // default: 0 + scale?: number; // The relative scale of the element from the default size. (default: 1) + zoom?: number; // The zoom level at which the element was created. + align?: "center" | "left" | "right"; // default: "center" + style?: "light" | "italic" | "regular" | "caps"; // default: "regular" + position?: LngLatTuple; +}; + +type TextElementRead = { + id: string; + // The text shown in the element, which is identical to the `text` property. + // This is added for consistency with other elements that have a `name` property. + name: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type TextElementUpdate = { id: string; text?: string } & Omit; + +type NoteElementCreate = ElementCreateCommon & { + type: "Note"; + text: string; + color?: string; // The color of the element in some CSS-like format. (default: "#C93535") + rotation?: number; // default: 0 + scale?: number; // The relative scale of the element from the default size. (default: 1) + zoom?: number; // The zoom level at which the element was created. + // The alignment of the text, either `left`, `center` or `right`. + align?: "center" | "left" | "right"; // default: "center" + style?: "light" | "italic" | "regular" | "caps"; // default: "regular" + widthScale?: number; + position?: LngLatTuple; +}; + +type NoteElementRead = { + id: string; + // The text shown in the element, which is identical to the `text` property. + // This is added for consistency with other elements that have a `name` property. + name: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type NoteElementUpdate = { id: string; text?: string } & Omit; + +type ImageElementCreate = ElementCreateCommon & { + type: "Image"; + coordinates: LngLatTuple[][]; + imageUrl: string; + name?: string | null; + opacity?: number; // default: 1 +}; + +type ImageElementRead = { + id: string; + interaction?: "default" | "locked"; // default: "default" +} & Required>; + +type ImageElementUpdate = { id: string; type: "Image" } & Partial>; + +type LinkElementRead = { + id: string; + groupId: string | null; + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + name: string | null; + // Text describing the element, which is shown in an element's popup when it is selected. + description: string | null; + // A set of key-value pairs that can be used to store arbitrary data about the element. + attributes: Record; + type: "Link"; + url: string; + interaction?: "default" | "locked"; // default: "default" +}; + +type ElementGroup = { + id: string; + name: string; + caption: string | null; + // You can use these ids to get the full element objects via the `getElements` method. + elementIds: string[]; + visible: boolean; + shownInLegend: boolean; +}; + +// The constraints to apply when getting elements. +type GetElementsConstraint = { ids?: string[] }; + +// The constraints to apply when getting element groups. +type GetElementGroupsConstraint = { ids?: string[] }; + +// The parameters for the `onElementChange` and the `onElementCreate` listeners. +type ElementChangeCallbackParams = { + // The new data for the element or null if the element was removed. + element: Element | null; + // Whether or not this element is still being created by a drawing tool. + isBeingCreated: boolean; +}; + +// The parameters for the `onElementGroupChange` listener. +type ElementGroupChangeCallbackParams = { elementGroup: ElementGroup | null }; + +type ElementCreate = PlaceElementCreate | PathElementCreate | PolygonElementCreate | CircleElementCreate | MarkerElementCreate | HighlighterElementCreate | ImageElementCreate | TextElementCreate | NoteElementCreate; + +type ElementUpdate = PlaceElementUpdate | PathElementUpdate | PolygonElementUpdate | CircleElementUpdate | MarkerElementUpdate | HighlighterElementUpdate | TextElementUpdate | NoteElementUpdate | ImageElementUpdate; + +type Element = PlaceElementRead | PathElementRead | PolygonElementRead | CircleElementRead | MarkerElementRead | HighlighterElementRead | TextElementRead | NoteElementRead | ImageElementRead | LinkElementRead; +``` + +### Methods + +```typescript +// Get a single element from the map by its id. +felt.getElement(id: string): Promise; + +// Get the geometry of an element in GeoJSON geometry format. +felt.getElementGeometry(id: string): Promise; + +// Gets elements from the map, according to the constraints supplied. +// The elements in the map, ordered by the order specified in Felt. This is not necessarily the order +// that they are drawn in, as Felt draws points above lines and lines above polygons, for instance. +felt.getElements(constraint?: GetElementsConstraint): Promise<(Element | null)[]>; + +// Get an element group from the map by its id. +felt.getElementGroup(id: string): Promise; + +// Gets element groups from the map, according to the filters supplied. +felt.getElementGroups(constraint?: GetElementGroupsConstraint): Promise<(ElementGroup | null)[]>; + +// Hide or show element groups with the given ids. +felt.setElementGroupVisibility(visibility: SetVisibilityRequest): Promise; + +// Create a new element on the map. +felt.createElement(element: ElementCreate): Promise; + +// Update an element on the map. +felt.updateElement(element: ElementUpdate): Promise; + +// Delete an element from the map. +felt.deleteElement(id: string): Promise; +``` + +### Events + +```typescript +// Adds a listener for when an element is created. +felt.onElementCreate(args: { handler: (change: ElementChangeCallbackParams) => void }): VoidFunction; + +// Listens for when a new element is finished being created by a drawing tool. +felt.onElementCreateEnd(args: { + handler: (params: { element: Element }) => void; // The handler to call whenever this event fires. +}): VoidFunction; + +// Adds a listener for when an element changes. +felt.onElementChange(args: { + options: { id: string }; + handler: (change: ElementChangeCallbackParams) => void; +}): VoidFunction; + +// Adds a listener for when an element is deleted. +felt.onElementDelete(args: { options: { id: string }; handler: () => void }): VoidFunction; + +// Adds a listener for when an element group changes. +felt.onElementGroupChange(args: { + options: { id: string }; + handler: (change: ElementGroupChangeCallbackParams) => void; +}): VoidFunction; +``` + +## Selection + +```typescript +// References an element on the map. +type ElementNode = { type: "element"; entity: Element }; + +// References an element group. +type ElementGroupNode = { type: "elementGroup"; entity: ElementGroup }; + +// References a layer on the map. +type LayerNode = { type: "layer"; entity: Layer }; + +// References a layer group on the map. +type LayerGroupNode = { type: "layerGroup"; entity: LayerGroup }; + +// References a feature on the map. +type FeatureNode = { type: "feature"; entity: LayerFeature }; + +// The options for selecting a feature in a layer. +type FeatureSelection = { + id: string | number; + layerId: string; + // Whether to show the feature's popup, if it is configured in the layer's style. + showPopup?: boolean; // default: true + // Whether to center the view on the feature after selecting it. + fitViewport?: boolean | { maxZoom: number }; // default: true +}; + +// A reference to any kind of entity in the map. +// EntityNodes are used when you have some collection of entities and you need to +type EntityNode = ElementNode | ElementGroupNode | LayerNode | LayerGroupNode | FeatureNode; +``` + +### Methods + +```typescript +// Gets the current selection as a list of entity identifiers. +felt.getSelection(): Promise; + +// Selects a feature on a layer. +felt.selectFeature(params: FeatureSelection): Promise; + +// Clears the current selection (elements, features or both). +felt.clearSelection(params?: { + features?: boolean; // Whether to clear the features from the selection. + elements?: boolean; // Whether to clear the elements from the selection. +}): Promise; +``` + +### Events + +```typescript +// Adds a listener for when the selection changes. +felt.onSelectionChange(params: { handler: (change: { selection: EntityNode[] }) => void }): VoidFunction; +``` + +## Interactions + +```typescript +// The event object passed to the interaction listeners. +type MapInteractionEvent = { + coordinate: LatLng; // The cursor position in world coordinates. + // The pixel coordinates of the mouse cursor, relative to the map and measured from the top left corner. + point: { x: number; y: number }; + features: LayerFeature[]; // The vector features that are under the cursor. + rasterValues: RasterValue[]; // The raster pixel values that are under the cursor. +}; +``` + +### Events + +```typescript +// Allows you to be notified when the user clicks on the map. +felt.onPointerClick(params: { handler: (event: MapInteractionEvent) => void }): VoidFunction; + +// Allows you to be notified when the user moves the mouse over the map. +felt.onPointerMove(params: { handler: (event: MapInteractionEvent) => void }): VoidFunction; +``` + +## Basemaps + +```typescript +// Shared by every Basemap variant. Documentation shorthand, not an exported type. +type BasemapCommon = { + // Do not rely on the stability of this ID for Felt basemaps, as they are subject to change. + id: string; + name: string; + uiColorScheme: "light" | "dark"; + attribution?: string; +}; + +type FeltBasemap = BasemapCommon & { + type: "felt"; + theme: "color_light" | "monochrome_dark" | "monochrome_light" | "satellite"; +}; + +type ColorBasemap = BasemapCommon & { type: "color"; color: string }; + +type CustomTileBasemap = BasemapCommon & { type: "xyz_tile"; tileUrl: string }; + +type ColorBasemapInput = Omit; + +type CustomTileBasemapInput = Omit; + +type Basemap = FeltBasemap | ColorBasemap | CustomTileBasemap; +``` + +### Methods + +```typescript +// Gets the currently active basemap. +felt.getCurrentBasemap(): Promise; + +// Gets all basemaps available on the map. +felt.getBasemaps(): Promise; + +// Chooses the basemap to use for the map. +felt.chooseBasemap(id: string): void; + +// Adds a custom basemap to the map. +felt.addCustomBasemap(args: { + basemap: ColorBasemapInput | CustomTileBasemapInput; + select?: boolean; // Whether to select the basemap after adding it. +}): Promise; + +// Removes a basemap from the list of available basemaps. +felt.removeBasemap(id: string): Promise; +``` + +### Events + +```typescript +// Adds a listener for when the basemap changes. +felt.onBasemapChange(args: { handler: (basemap: Basemap) => void }): VoidFunction; +``` + +## Tools + +```typescript +type ToolType = "circle" | "highlighter" | "line" | "link" | "marker" | "note" | "pin" | "polygon" | "route" | "text"; + +type PlaceFrame = "frame-circle" | "frame-square" | null; + +type PinToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + frame: PlaceFrame; // The frame that is rendered around the Place's symbol. + showInspector: boolean; // default: false + symbol: PlaceSymbol; + afterCreation: "select" | "enter name" | "add another"; // What to do after creating the Place element. (default: "enter name") +}; + +type LineToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + // A value between 0 and 1 that describes the opacity of the element's stroke. + strokeOpacity: number; // default: 1 + strokeWidth: number; // default: 2 + strokeStyle: "solid" | "dashed" | "dotted"; // default: "solid" + // Whether a distance marker is shown at the midpoint of the path. + distanceMarker: boolean; // default: false + showInspector: boolean; // default: false +}; + +type RouteToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + // A value between 0 and 1 that describes the opacity of the element's stroke. + strokeOpacity: number; // default: 1 + strokeWidth: number; // default: 2 + strokeStyle: "solid" | "dashed" | "dotted"; // default: "solid" + // Whether a distance marker is shown at the midpoint of the path. + distanceMarker: boolean; // default: false + // Whether this represents a route, and if so, what mode of transport is used. + routingMode: "driving" | "cycling" | "walking" | "flying" | null; // default: null + endCaps: boolean; // Whether or not to show Start and End caps on the path. (default: false) + showInspector: boolean; // default: false +}; + +type PolygonToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + // A value between 0 and 1 that describes the opacity of the element's stroke. + strokeOpacity: number; // default: 1 + strokeWidth: number; // default: 2 + strokeStyle: "solid" | "dashed" | "dotted"; // default: "solid" + fillOpacity: number; // default: 0.25 + areaMarker: boolean; // default: false + showInspector: boolean; // default: false +}; + +type CircleToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + // A value between 0 and 1 that describes the opacity of the element's stroke. + strokeOpacity: number; // default: 1 + strokeWidth: number; // default: 2 + strokeStyle: "solid" | "dashed" | "dotted"; // default: "solid" + // Whether to show a marker on the circle that indicates the radius + radiusMarker: boolean; // default: false + fillOpacity: number; // default: 0.25 + showInspector: boolean; // default: false +}; + +type MarkerToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + opacity: number; // default: 1 + // The size of the marker, used in conjunction with the `zoom` to determine the actual size of the marker. + size: number; // default: 10 + showInspector: boolean; // default: false +}; + +type HighlighterToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + renderHoles: boolean; // Whether to render the holes of the highlighted area. (default: false) + opacity: number; // default: 0.5 + showInspector: boolean; // default: false + size: number; +}; + +type TextToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + align: "center" | "left" | "right"; // default: "center" + style: "light" | "italic" | "regular" | "caps"; // default: "regular" + showInspector: boolean; // default: false +}; + +type NoteToolSettings = { + color: string; // The color of the element in some CSS-like format. (default: "#C93535") + // The alignment of the text, either `left`, `center` or `right`. + align: "center" | "left" | "right"; // default: "center" + style: "light" | "italic" | "regular" | "caps"; // default: "regular" + showInspector: boolean; // default: false +}; + +type ConfigurableToolType = Exclude; + +type ToolSettingsMap = { + pin: PinToolSettings; + line: LineToolSettings; + route: RouteToolSettings; + polygon: PolygonToolSettings; + circle: CircleToolSettings; + marker: MarkerToolSettings; + highlighter: HighlighterToolSettings; + text: TextToolSettings; + note: NoteToolSettings; +}; + +// The parameters for changing the settings of each tool. +type InputToolSettings = { tool: "pin" } & Partial | { tool: "line" } & Partial | { tool: "route" } & Partial | { tool: "polygon" } & Partial | { tool: "circle" } & Partial | { tool: "marker" } & Partial | { tool: "highlighter" } & Partial | { tool: "text" } & Partial | { tool: "note" } & Partial; + +// The result of listening for changes to the settings of each tool. +type ToolSettingsChangeEvent = { tool: "pin" } & PinToolSettings | { tool: "line" } & LineToolSettings | { tool: "route" } & RouteToolSettings | { tool: "polygon" } & PolygonToolSettings | { tool: "circle" } & CircleToolSettings | { tool: "marker" } & MarkerToolSettings | { tool: "highlighter" } & HighlighterToolSettings | { tool: "text" } & TextToolSettings | { tool: "note" } & NoteToolSettings; + +type PlaceSymbol = "dot" | "square" | "diamond" | "triangle" | "x" | "plus" | "circle-line" | "circle-slash" | "star" | "heart" | "hexagon" | "octagon" | "pedestrian" | "bicycle" | "wheelchair" | "airport" | "car" | "bus" | "train" | "truck" | "ferry" | "sailboat" | "electric-service" | "gas-service" | "blood-clinic" | "badge" | "traffic-light" | "traffic-cone" | "road-sign-caution" | "person" | "restroom" | "house" | "work" | "letter" | "hotel" | "factory" | "hospital" | "religious-facility" | "school" | "government" | "university" | "bank" | "landmark" | "museum" | "clothing" | "shopping" | "store" | "bar" | "pub" | "cafe" | "food" | "park" | "amusement-park" | "camping-tent" | "cabin" | "picnic" | "water-refill" | "trailhead" | "guidepost" | "viewpoint" | "camera" | "us-football" | "football" | "tennis" | "binoculars" | "swimming" | "zap" | "battery-full" | "battery-half" | "battery-low" | "boom" | "radar" | "wind-turbine" | "solar-panel" | "antenna" | "telephone-pole" | "oil-well" | "oil-barrel" | "railroad-track" | "bridge" | "lighthouse" | "lock-closed" | "lock-open" | "wifi" | "trash" | "recycle" | "tree" | "flower" | "leaf" | "fire" | "mountain" | "snowy-mountain" | "volcano" | "island" | "wave" | "hot-springs" | "water" | "lake" | "ocean" | "animal" | "bird" | "duck" | "dog" | "fish" | "beach" | "wetland" | "sun" | "moon" | "cloud" | "partial-sun" | "rain" | "lightning" | "snowflake" | "wind" | "snow" | "fog" | "sleet" | "hurricane" | "warning" | "parking" | "info" | "circle-exclamation" | "circle-triangle" | "circle-x" | "circle-plus" | `:${string}:` & {}; +``` + +### Methods + +```typescript +// Sets the tool to use for drawing elements on the map. +felt.setTool(tool: ToolType | null): void; + +// Gets the current tool, if any is in use. +felt.getTool(): Promise; + +// Sets the settings for the current tool. +felt.setToolSettings(settings: InputToolSettings): void; + +// Gets the settings for the chosen tool. +felt.getToolSettings(tool: T): Promise; +``` + +### Events + +```typescript +// Listens for changes to the current tool. +felt.onToolChange(args: { + // This callback is called with the current tool whenever the tool changes. + handler: (tool: ToolType | null) => void; +}): VoidFunction; + +// Listens for changes to the settings on all tools. +felt.onToolSettingsChange(args: { handler: (settings: ToolSettingsChangeEvent) => void }): VoidFunction; +``` + +## UI + +```typescript +type CreateActionTriggerParams = { actionTrigger: UIActionTriggerCreate; placement?: PlacementForUIElement }; + +type UpdateActionTriggerParams = { + id: string; + label?: string; + disabled?: boolean; + type?: undefined; + onTrigger?: (args: { id: string }) => void; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +type CreateFeatureActionParams = { action: UIFeatureActionCreate; placement?: PlacementForUIElement }; + +type UpdateFeatureActionParams = { + id: string; + label?: string; + layerIds?: string[]; + geometryTypes?: ("Polygon" | "Point" | "Line" | "Raster")[]; // The geometry type of the features to add the action to. + type?: undefined; + onTrigger?: (args: { feature: LayerFeature }) => void; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +// The parameters for creating or updating a panel by using UiController.createOrUpdatePanel. +type CreateOrUpdatePanelParams = { + panel: UIPanelCreateOrUpdate; + placement?: PlacementForUIElement; + initialPlacement?: PlacementForUIElement; // The placement of the panel on the right sidebar stack. (default: { at: "end" }) +}; + +type CreatePanelElementsParams = { + panelId: string; + elements: { + element: UIPanelElementCreate; + container?: "footer" | "body" | { id: string }; // default: "body" + // The placement of the element in the target container (based on the `container` property). + placement?: PlacementForUIElement; // default: { at: "end" } + }[]; +}; + +type UpdatePanelElementsParams = { panelId: string; elements: { element: UIPanelElementUpdate }[] }; + +type DeletePanelElementsParams = { panelId: string; elements: string[] }; + +type UiControlsOptions = { + showLegend?: boolean; // Whether or not the legend is shown. (default: true) + // When co-operative gestures are enabled, the pan and zoom gestures are adjusted to work better when the map is... + // On mobile devices, enabling co-operative gestures will allow the user to pan past the embedded map + // with a single finger drag. To pan the map, they must use two fingers. + // On desktop devices, enabling co-operative gestures allows the user to scroll past the embedded map + // using their scroll wheel or trackpad. To zoom the map, they must hold the Ctrl (Windows) or Command + // key (Mac) while scrolling. + cooperativeGestures?: boolean; // default: true + // Whether or not the full screen button is shown in an embedded map. + // When clicked, this will open the map in a new tab or window. + fullScreenButton?: boolean; // default: true + // Whether or not the geolocation button is shown in an embedded map. + // The geolocation feature will plot your position on the map. If you click the button again, it will + // start tracking your position. + geolocation?: boolean; // default: false + // Whether or not the zoom controls are shown in an embedded map. + // This does not affect whether or not the map can be zoomed, just the display of the zoom controls in + // the bottom right corner of the map. + zoomControls?: boolean; // default: true + scaleBar?: boolean; // Whether or not the scale bar is shown in an embedded map. (default: true) + // Whether or not the map can be rotated. + // This affects all rotation interactions — dragging with the right mouse button or Ctrl held, + // two-finger touch rotation, and keyboard shortcuts — as well as the compass control, which is hidden + // when rotation is disabled. + rotation?: boolean; // default: true + // Whether or not the right-click context menu is available on the map. + // When disabled, right-clicking the map opens neither Felt's context menu nor the browser's own. + contextMenu?: boolean; // default: true +}; + +// The options for which parts of the Felt UI can be shown when interacting with features and elements on the... +type OnMapInteractionsOptions = { + // Set this to `false` to prevent the panel that shows information about a selected feature from being shown. + featureSelectPanel?: boolean; + // Set this to `false` to prevent the panel that shows information about a hovered feature from being shown. + featureHoverPanel?: boolean; + // Set this to `false` to prevent the panel that shows information about a selected element from being shown. + elementSelectPanel?: boolean; + // Set this to `false` to prevent clicking on a map link element from opening that link in a new tab or window. + linkClickOpen?: boolean; + // Set this to `false` to prevent clicking on an image element from opening the image in a lightbox. + imageLightboxOpen?: boolean; +}; + +// Represents an action trigger. +type UIActionTriggerCreate = { + label: string; + onTrigger: (args: { id: string }) => void; // The function to call when the action trigger is triggered. + id?: string; + disabled?: boolean; // Whether the action trigger is disabled or not. + type?: undefined; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +// Shared by every UIPanelElementCreate variant. Documentation shorthand, not an exported type. +type UIPanelElementCreateCommon = { + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; + // If not provided, the element will be assigned a random ID, but it is recommended to provide it to + // perform further updates on the element. + // If provided, it must be unique within the UI. + id?: string; +}; + +// The parameters for creating a button element. +// `id` is optional but recommended if you want to be able to perform updates. +type UIButtonElementCreate = UIPanelElementCreateCommon & { + type: "Button"; + label: string; + onClick: (args: { id: string }) => void; // The action to perform when the button is clicked. + variant?: "filled" | "transparent" | "outlined"; // default: "filled" + tint?: "default" | "primary" | "accent" | "danger"; // default: "default" + disabled?: boolean; // default: false +}; + +// Represents a button element in a panel. +type UIButtonElement = { id: string } & Omit; + +// The parameters for updating a button element. +// `id` and `type` are required to identify the element to update. +type UIButtonElementUpdate = { type: "Button"; id: string } & Partial>; + +// The parameters for creating a button row element. +type UIButtonRowElementCreate = UIPanelElementCreateCommon & { + type: "ButtonRow"; + items: UIButtonElementCreate[]; + align?: "start" | "end"; // default: "start" + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// Represents a row of buttons. +type UIButtonRowElement = { items: UIButtonElement[]; id: string } & Omit; + +// The parameters for updating a button row element. +type UIButtonRowElementUpdate = { id: string; items?: UIButtonElementCreate[] } & Omit; + +// The parameters for creating a checkbox group element. +type UICheckboxGroupElementCreate = UIPanelElementCreateCommon & { + type: "CheckboxGroup"; + value: string[]; // default: [] + options: UIControlElementOption[]; + // The function to call when the value of the checkbox group changes. + onChange: (args: { value: string[]; id: string }) => void; + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// The parameters for creating a checkbox group element. +type UICheckboxGroupElement = { id: string } & Omit; + +// The parameters for updating a checkbox group element. +type UICheckboxGroupElementUpdate = { type: "CheckboxGroup"; id: string } & Partial>; + +// The parameters for creating a divider element. +// `id` is optional but recommended if you want to be able to delete the element. +type UIDividerElementCreate = UIPanelElementCreateCommon & { type: "Divider" }; + +// Represents a divider element in a panel. +type UIDividerElement = { id: string } & Omit; + +// The parameters for updating a divider element. +// `id` and `type` are required to identify the element to update. +type UIDividerElementUpdate = { id: string } & Omit; + +// Represents a feature action for creation. +type UIFeatureActionCreate = { + label: string; + onTrigger: (args: { feature: LayerFeature }) => void; + layerIds?: string[]; + geometryTypes?: ("Polygon" | "Point" | "Line" | "Raster")[]; // The geometry type of the features to add the action to. + type?: undefined; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +// Represents a feature action after creation (with generated id). +type UIFeatureAction = UIFeatureActionCreate & { id: string }; + +// The parameters for creating a flexible space element. +// `id` is optional but recommended if you want to be able to perform updates. +type UIFlexibleSpaceElementCreate = UIPanelElementCreateCommon & { type: "FlexibleSpace" }; + +// Represents a flexible space element in a container. +type UIFlexibleSpaceElement = { id: string } & Omit; + +// The parameters for updating a flexible space element. +// `id` and `type` are required to identify the element to update. +type UIFlexibleSpaceElementUpdate = { id: string } & Omit; + +// The parameters for creating a grid container element. +type UIGridContainerElementCreate = UIPanelElementCreateCommon & { + type: "Grid"; + items: (UIButtonElementCreate | UITextElementCreate | UIDividerElementCreate | UITextInputElementCreate | UISelectElementCreate | UIFlexibleSpaceElementCreate | UIButtonRowElementCreate | UICheckboxGroupElementCreate | UIRadioGroupElementCreate | UIToggleGroupElementCreate | UIIframeElementCreate)[]; + grid?: string; + verticalAlignment?: "center" | "top" | "bottom"; // default: "top" + horizontalDistribution?: "center" | "start" | "end" | "space-between" | "space-around" | "space-evenly"; // default: "start" +}; + +// Represents a container with a grid layout in a panel. +type UIGridContainerElement = { + items: (UIButtonElement | UITextElement | UIDividerElement | UITextInputElement | UISelectElement | UIFlexibleSpaceElement | UIButtonRowElement | UICheckboxGroupElement | UIRadioGroupElement | UIToggleGroupElement | UIIframeElement)[]; + id: string; +} & Omit; + +// The parameters for updating a grid container element. +// `id` and `type` are required to identify the element to update. +type UIGridContainerElementUpdate = { + id: string; + items?: (UIButtonElementCreate | UITextElementCreate | UIDividerElementCreate | UITextInputElementCreate | UISelectElementCreate | UIFlexibleSpaceElementCreate | UIButtonRowElementCreate | UICheckboxGroupElementCreate | UIRadioGroupElementCreate | UIToggleGroupElementCreate | UIIframeElementCreate)[]; +} & Omit; + +// The parameters for creating an iframe element. +// `id` is optional but recommended if you want to be able to perform updates. +type UIIframeElementCreate = UIPanelElementCreateCommon & { type: "Iframe"; url: string; height?: string | number }; + +// Represents an iframe element in a panel. +type UIIframeElement = { id: string } & Omit; + +// The parameters for updating an iframe element. +// `id` and `type` are required to identify the element to update. +type UIIframeElementUpdate = { id: string; url?: string } & Omit; + +// A UI panel that can be added to the map using UiController.createOrUpdatePanel. +type UIPanel = { + type: "Panel"; + // The ID of the panel obtained from UiController.createPanelId. + // Custom IDs are not supported. + id: string; + title?: string; // The title to display in the panel header. + body?: UIPanelElement[]; + footer?: UIPanelElement[]; + onClickClose?: (args: { id: string }) => void; // A function to call when panel's close button is clicked. + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +// The parameters for creating a panel by using UiController.createOrUpdatePanel. +type UIPanelCreateOrUpdate = { + // The ID of the panel obtained from UiController.createPanelId. + // Custom IDs are not supported. + id: string; + title?: string; // The title to display in the panel header. + onClickClose?: (args: { id: string }) => void; // A function to call when panel's close button is clicked. + type?: "Panel"; + body?: UIPanelElementCreate[]; + footer?: UIPanelElementCreate[]; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; // A function to call when the element is destroyed. +}; + +// The parameters for creating a radio group element. +type UIRadioGroupElementCreate = UIPanelElementCreateCommon & { + type: "RadioGroup"; + options: UIControlElementOption[]; + // The function to call when the value of the radio group changes. + onChange: (args: { value: string | undefined; id: string }) => void; + value?: string; + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// The parameters for creating a radio group element. +type UIRadioGroupElement = { id: string } & Omit; + +// The parameters for updating a radio group element. +type UIRadioGroupElementUpdate = { type: "RadioGroup"; id: string } & Partial>; + +// The parameters for creating a select element. +// `id` is optional but recommended if you want to be able to perform updates. +type UISelectElementCreate = UIPanelElementCreateCommon & { + type: "Select"; + options: UIControlElementOption[]; + onChange: (args: { value: string; id: string }) => void; // The function to call when the value of the select changes. + value?: string; + placeholder?: string; // The placeholder text to display in the select. + // Whether the select should allow searching through the options. + search?: boolean; // default: false + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// Represents a select element in a panel. +// `options` property is required. `label` property is displayed above the select and used for screen +// readers. `value` property is optional, for empty value use `undefined`. `placeholder` property is +// displayed in the select when no value is selected. `search` property is used to enable searching +// through the options. `onChange` property is used to handle the value change event. +type UISelectElement = { id: string } & Omit; + +// The parameters for updating a select element. +// `id` and `type` are required to identify the element to update. +type UISelectElementUpdate = { type: "Select"; id: string } & Partial>; + +// The parameters for creating a text element. +// `id` is optional but recommended if you want to be able to perform updates. +type UITextElementCreate = UIPanelElementCreateCommon & { type: "Text"; content: string }; + +// Represents a text element in a panel. +type UITextElement = { id: string } & Omit; + +// The parameters for updating a text element. +// `id` and `type` are required to identify the element to update. +type UITextElementUpdate = { + type: "Text"; + id: string; + content?: string; + onCreate?: (args: { id: string }) => void; // A function to call when the element is created. + onDestroy?: (args: { id: string }) => void; +}; + +// The parameters for creating a text input element. +// `id` is optional but recommended if you want to be able to perform updates. +type UITextInputElementCreate = UIPanelElementCreateCommon & { + type: "TextInput"; + value: string; + placeholder?: string; + onChange?: (args: { value: string; id: string }) => void; // The function to call when the value of the input changes. + onBlur?: (args: { value: string; id: string }) => void; // The function to call when the input is blurred. + onFocus?: (args: { value: string; id: string }) => void; // The function to call when the input is focused. + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// Represents a text input element in a panel. +// `value` property is required, for empty value use `""`. `label` property is displayed above the +// input and used for screen readers. +type UITextInputElement = { id: string } & Omit; + +// The parameters for updating a text input element. +// `id` and `type` are required to identify the element to update. +type UITextInputElementUpdate = { id: string; value?: string } & Omit; + +// The parameters for creating a toggle group element. +type UIToggleGroupElementCreate = UIPanelElementCreateCommon & { + type: "ToggleGroup"; + value: string[]; // default: [] + options: UIControlElementOption[]; + // The function to call when the value of the toggle group changes. + onChange: (args: { value: string[]; id: string }) => void; + alignment?: "start" | "end"; // default: "start" + // Label text to display above the element and used for screen readers. + label?: string; +}; + +// The parameters for creating a toggle group element. +type UIToggleGroupElement = { id: string } & Omit; + +// The parameters for updating a toggle group element. +type UIToggleGroupElementUpdate = { type: "ToggleGroup"; id: string } & Partial>; + +// An option to display in a control element. +type UIControlElementOption = { label: string; value: string; disabled?: boolean }; + +// Used in UiController.createOrUpdatePanel to specify the position of a panel in the stack and in... +type PlacementForUIElement = { after: string } | { before: string } | { at: "start" | "end" }; + +type UIPanelElement = UIButtonElement | UITextElement | UIDividerElement | UITextInputElement | UISelectElement | UIFlexibleSpaceElement | UIButtonRowElement | UICheckboxGroupElement | UIRadioGroupElement | UIToggleGroupElement | UIIframeElement | UIGridContainerElement; + +// This is a union of all the possible elements that can be created inside panel's body or footer. +// For the sake of convenience, `id` is optional but recommended if you want to be able to perform +// updates. +type UIPanelElementCreate = UIButtonElementCreate | UITextElementCreate | UIDividerElementCreate | UITextInputElementCreate | UISelectElementCreate | UIFlexibleSpaceElementCreate | UIButtonRowElementCreate | UICheckboxGroupElementCreate | UIRadioGroupElementCreate | UIToggleGroupElementCreate | UIIframeElementCreate | UIGridContainerElementCreate; + +// This is a union of all the possible elements that can be updated inside panel's body or footer. +// `id` and `type` are required to identify the element to update. +type UIPanelElementUpdate = UIButtonElementUpdate | UITextElementUpdate | UITextInputElementUpdate | UISelectElementUpdate | UIDividerElementUpdate | UIButtonRowElementUpdate | UICheckboxGroupElementUpdate | UIRadioGroupElementUpdate | UIToggleGroupElementUpdate | UIGridContainerElementUpdate | UIFlexibleSpaceElementUpdate | UIIframeElementUpdate; +``` + +### Methods + +```typescript +// Creates an action trigger. +felt.createActionTrigger(args: CreateActionTriggerParams): Promise; + +// Updates an action trigger. +// Properties provided will override the existing properties. +felt.updateActionTrigger(args: UpdateActionTriggerParams): Promise; + +// Deletes an action trigger. +felt.deleteActionTrigger(id: string): void; + +// Creates a feature contextual action. +felt.createFeatureAction(args: CreateFeatureActionParams): Promise; + +// Updates a feature contextual action. +// Properties provided will override the existing properties. +felt.updateFeatureAction(args: UpdateFeatureActionParams): Promise; + +// Deletes a feature contextual action. +felt.deleteFeatureAction(id: string): void; + +// Creates a panel ID. +felt.createPanelId(): Promise; + +// Creates or updates a panel. +felt.createOrUpdatePanel(args: CreateOrUpdatePanelParams): Promise; + +// Deletes a panel. +felt.deletePanel(id: string): void; + +// Creates elements in a panel. +felt.createPanelElements(args: CreatePanelElementsParams): Promise; + +// Updates an existing element in a panel. +felt.updatePanelElements(args: UpdatePanelElementsParams): Promise; + +// Deletes elements from a panel. +felt.deletePanelElements(args: DeletePanelElementsParams): void; + +// Updates the UI controls on the embedded map. +felt.updateUiControls(controls: UiControlsOptions): void; + +// Control the on-map UI shown when interacting with features and elements. +felt.setOnMapInteractionsUi(options: UiOnMapInteractionsOptions): void; + +// Shows a data table view for the specified layer, optionally sorted by a given attribute. +felt.showLayerDataTable(params?: { + layerId: string; + sorting?: SortConfig; // Optional sorting configuration for the table. +}): Promise; + +// Hides the data table. +felt.hideLayerDataTable(): Promise; +``` + +## Viewport + +```typescript +// The input type for setting the viewport to a particular center and zoom. +type ViewportCenterZoom = { center: LatLng; zoom: number }; + +// The current state of the viewport, including the derived bounds. +type ViewportState = { + center: LatLng; + zoom: number; + // The bounding box of the viewport in [west, south, east, north] order. + bounds: FeltBoundary; +}; + +// The parameters for the `setViewport` method. +type SetViewportCenterZoomParams = { center?: LatLng; zoom?: number }; + +// The constraints for the viewport. +type ViewportConstraints = { + minZoom: number | null; + maxZoom: number | null; + bounds: FeltBoundary | null; +}; + +// The parameters for the `fitViewportToBounds` method. +type ViewportFitBoundsParams = { bounds: FeltBoundary }; +``` + +### Methods + +```typescript +// Gets the current state of the viewport. +felt.getViewport(): Promise; + +// Moves the map to the specified location. +felt.setViewport(viewport: SetViewportCenterZoomParams): void; + +// Gets the current state of the viewport constraints. +felt.getViewportConstraints(): Promise; + +// Constrains the map viewport so it stays inside certain bounds and/or certain zoom levels. +felt.setViewportConstraints(constraints: Partial | null): void; + +// Fits the map to the specified bounds. +felt.fitViewportToBounds(bounds: ViewportFitBoundsParams): void; +``` + +### Events + +```typescript +// Adds a listener for when the viewport changes. +felt.onViewportMove(args: { + // This callback is called with the current viewport state whenever the viewport changes. + handler: (viewport: ViewportState) => void; +}): VoidFunction; + +// Adds a listener for when the viewport move ends, which is when the user stops dragging or zooming the map. +felt.onViewportMoveEnd(args: { handler: (viewport: ViewportState) => void }): VoidFunction; + +// Adds a listener for when the map is idle, which is defined as: - No transitions are in progress - The user is... +felt.onMapIdle(args: { handler: () => void }): VoidFunction; +``` + +## Map Details + +```typescript +// The details of a map. +type MapDetails = { id: string; title: string; description: string | null }; +``` + +### Methods + +```typescript +// Gets the details of the map. +felt.getMapDetails(): Promise; +``` diff --git a/package.json b/package.json index f2f9ac0c..7572d7e3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "license": "MIT", "files": [ "dist", + "llms-full.txt", "handler.js", "handler.d.ts", "types-*.ts" @@ -62,11 +63,12 @@ "scripts": { "build:compile": "tsup && ./scripts/dtsroll.sh", "build:docs": "typedoc", - "build": "npm run build:compile && npm run build:docs", + "build:docs-llm": "node scripts/build-llm-docs.mjs", + "build": "npm run build:compile && npm run build:docs && npm run build:docs-llm", "changeset": "changeset", "check:api": "npm run build:compile && attw --pack . --profile node16 && api-extractor run", "check:client-bundle": "npm run build:compile && node ./scripts/check-client-bundle-contents.js", - "check:docs": "npm run build:docs && bash ./scripts/check-api-docs.sh", + "check:docs": "npm run build:docs && npm run build:docs-llm && bash ./scripts/check-api-docs.sh", "check:format": "prettier --check .", "check:lockfile": "node ./scripts/check-lockfile.mjs", "check:test": "vitest run", diff --git a/scripts/build-llm-docs.mjs b/scripts/build-llm-docs.mjs new file mode 100644 index 00000000..00b0897d --- /dev/null +++ b/scripts/build-llm-docs.mjs @@ -0,0 +1,1199 @@ +/** + * Generates a single-page API reference sized for LLM consumption. + * + * Organisation is derived entirely from the TypeDoc model: types are grouped by + * the module they are declared in, and FeltController's members are grouped by + * the controller they are inherited from. Nothing here is a hand-maintained + * taxonomy, so a new type or method lands in the right section on its own. + * + * Output is kept compact by three structural mechanisms, each of which is + * verified against the model before it is used and falls back to the full + * rendering otherwise: + * + * - Named inheritance: `interface X extends ExportedBase` renders as + * `ExportedBase & { own members }` rather than repeating the base. + * - Derivation: `XUpdate` / `XRead` render relative to `XCreate` (for + * example `{ id: string } & Partial>`) when that is + * structurally exact. + * - Hoisting: members shared by every constituent of an exported union are + * emitted once as a documentation-only `Common` type. + * + * Inline structures that are identical to an exported alias are rendered by + * the alias name (`[number, number, number, number]` becomes `FeltBoundary`). + * + * Runs as part of `npm run build`; `check:docs` fails if the committed output + * is out of date. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { Application, ReflectionKind } from "typedoc"; + +/** + * Named per the llms.txt convention (https://llmstxt.org): `llms-full.txt` is + * the variant with all content inlined. Ships in the npm package via `files`. + */ +const OUT = "llms-full.txt"; + +/** The receiver that FeltController members are called on in user code. */ +const RECEIVER = "felt"; + +/** + * Display titles for modules whose directory name does not describe their + * contents. Everything else is title-cased from the directory name. + */ +const MODULE_TITLES = { + main: "Entry Point", + shared: "Core Types", + misc: "Map Details", + ui: "UI", +}; + +function moduleTitle(mod) { + return ( + MODULE_TITLES[mod] ?? + mod.replace( + /(^|[-_])(\w)/g, + (_, sep, c) => (sep ? " " : "") + c.toUpperCase(), + ) + ); +} + +/** + * Reading order for the reference: entry point and core types first, then the + * largest surface. Every module must be listed; the generator fails otherwise, + * so placing a new module is a deliberate choice rather than an accident. + */ +const MODULE_ORDER = [ + "main", + "shared", + "layers", + "elements", + "selection", + "interactions", + "basemaps", + "tools", + "ui", + "viewport", + "misc", +]; + +// ---------------------------------------------------------------- comments + +const COMMENT_MAX = 110; +const REMARKS_WRAP = 100; +const DEFAULT_MAX = 40; + +function textOf(parts) { + return (parts ?? []) + .map((p) => (p.kind === "inline-tag" ? linkText(p) : (p.text ?? ""))) + .join(""); +} + +/** `{@link A.b | c}` reads as `c`; `{@link A.b}` as `A.b`. */ +function linkText(p) { + const raw = (p.text ?? "").trim(); + const bar = raw.indexOf("|"); + return bar >= 0 ? raw.slice(bar + 1).trim() : raw; +} + +/** First sentence, where a paragraph break also ends a sentence. */ +function firstSentenceOf(text) { + const t = (text ?? "").trim().split(/\n\s*\n/)[0]; + if (!t) return ""; + const m = t.match(/^[\s\S]*?[.!?](?=\s|$)/); + return (m ? m[0] : t).replace(/\s+/g, " ").trim(); +} + +function firstSentence(parts) { + return firstSentenceOf(textOf(parts)); +} + +/** + * Long summaries cost a line's worth of tokens each time they are emitted. + * Cut at a clause boundary outside any brackets where one exists, otherwise + * at a word boundary. + */ +function capComment(s, max = COMMENT_MAX) { + if (s.length <= max) return s; + const head = s.slice(0, max); + let depth = 0; + let clause = -1; + for (let i = 0; i < head.length; i++) { + const ch = head[i]; + if (ch === "(" || ch === "[" || ch === "{") depth++; + else if (ch === ")" || ch === "]" || ch === "}") + depth = Math.max(0, depth - 1); + else if (depth === 0 && (ch === "," || ch === ";") && head[i + 1] === " ") { + clause = i; + } else if (depth === 0 && ch === " " && head[i + 1] === "(") clause = i; + } + if (clause > max * 0.4) { + return head.slice(0, clause).replace(/[,;:\s]+$/, "") + "."; + } + const space = head.lastIndexOf(" "); + return ( + head.slice(0, space > 0 ? space : max).replace(/[,;:\s]+$/, "") + "..." + ); +} + +function summaryOf(comment) { + return capComment(firstSentence(comment?.summary)); +} + +function tagText(comment, ...tags) { + const t = comment?.blockTags?.find((b) => tags.includes(b.tag)); + return t ? textOf(t.content).trim() : ""; +} + +/** `@defaultValue` (TSDoc) or `@default` (JSDoc), as a short bare value. */ +function defaultOf(comment) { + let v = tagText(comment, "@defaultValue", "@default"); + if (!v) return ""; + v = v + .replace(/^```[a-z]*\n?/i, "") + .replace(/```$/, "") + .replace(/`/g, "") + .replace(/\s+/g, " ") + .trim(); + // "default: undefined" says nothing an optional marker doesn't already say. + if (v === "undefined" || v.length > DEFAULT_MAX) return ""; + return v; +} + +function wrapWords(s, max) { + const lines = []; + let cur = ""; + for (const w of s.split(" ")) { + if (cur && cur.length + 1 + w.length > max) { + lines.push(cur); + cur = w; + } else cur = cur ? `${cur} ${w}` : w; + } + if (cur) lines.push(cur); + return lines; +} + +/** + * `@remarks` in full, as comment lines. Summaries are cut to one sentence, so + * this is where detail that is not evident from the types lives: operator + * meanings, pagination rules, caveats. Paragraphs are reflowed; list items + * keep their own lines. + */ +function remarksLines(comment, pad = "") { + const text = tagText(comment, "@remarks"); + if (!text) return []; + const lines = []; + for (const para of text.split(/\n\s*\n/)) { + for (const item of para.split(/\n(?=\s*(?:[-*]|\d+\.)\s)/)) { + const flat = item.replace(/\s+/g, " ").trim(); + if (flat) lines.push(...wrapWords(flat, REMARKS_WRAP)); + } + } + return lines.map((l) => `${pad}// ${l}`); +} +// ------------------------------------------------------ alias canonicalisation + +/** + * Exported aliases keyed by the canonical form of their target. An inline type + * with the same canonical form is rendered as the alias name instead. Built to + * a fixed point in `buildAliasMap`, since a key can depend on other aliases. + */ +let aliasByKey = new Map(); +/** Union aliases, for folding a sub-union that matches one of them. */ +let unionAliases = []; +/** Name of the declaration being rendered; it never substitutes itself. */ +let renderingDecl = null; + +const ALIASABLE = new Set(["union", "tuple", "reflection", "array"]); +const ALIAS_MIN_KEY = 16; + +function aliasFor(t) { + if (!t || !ALIASABLE.has(t.type)) return null; + if (t.type === "reflection" && t.declaration?.signatures?.length) return null; + const name = aliasByKey.get(rawKey(t)); + if (!name || name === renderingDecl) return null; + return name; +} + +/** Canonical form of a type: order-insensitive for unions and object members. */ +function typeKey(t) { + if (!t) return "unknown"; + return aliasFor(t) ?? rawKey(t); +} + +function rawKey(t) { + switch (t.type) { + case "union": + return resolveUnion(t) + .map((i) => i.key) + .sort() + .join(" | "); + case "intersection": + return t.types.map(typeKey).sort().join(" & "); + case "tuple": + return `[${(t.elements ?? []).map(typeKey).join(", ")}]`; + case "namedTupleMember": + return `${typeKey(t.element)}${t.isOptional ? "?" : ""}`; + case "array": + return `${typeKey(t.elementType)}[]`; + case "reflection": + return reflectionKey(t.declaration); + default: + return renderRaw(t, 0); + } +} + +function reflectionKey(decl) { + if (!decl) return "unknown"; + if (decl.signatures?.length) { + const s = decl.signatures[0]; + const params = (s.parameters ?? []) + .map((p) => { + const opt = + p.flags?.isOptional || p.defaultValue !== undefined ? "?" : ""; + return `${p.name}${opt}: ${typeKey(p.type)}`; + }) + .join(", "); + return `(${params}) => ${typeKey(s.type)}`; + } + return membersKey(decl.children ?? []); +} + +function membersKey(members) { + return `{ ${members.map(memberKey).sort().join("; ")} }`; +} + +function memberKey(m) { + const opt = m.flags?.isOptional ? "?" : ""; + const t = m.signatures?.length ? reflectionKey(m) : typeKey(m.type); + return `${m.name}${opt}: ${t}`; +} + +/** + * Union members with any sub-union that exactly matches an exported union alias + * folded into that alias. Used for both rendering and keys so they agree. + */ +function resolveUnion(t, indent = 0) { + let items = t.types.map((x) => ({ + key: typeKey(x), + str: renderType(x, indent), + })); + let changed = true; + while (changed) { + changed = false; + for (const ua of unionAliases) { + if (ua.name === renderingDecl || ua.keys.size >= items.length) continue; + const idx = [...ua.keys].map((k) => items.findIndex((i) => i.key === k)); + if (idx.some((i) => i === -1)) continue; + const first = Math.min(...idx); + items = items.filter((_, i) => !idx.includes(i)); + items.splice(first, 0, { key: ua.name, str: ua.name }); + changed = true; + break; + } + } + return items; +} + +function isAliasCandidate(refl) { + if (refl.typeParameters?.length) return false; + if (refl.kind === ReflectionKind.Interface) + return (refl.children?.length ?? 0) > 0; + if (refl.kind !== ReflectionKind.TypeAlias) return false; + const t = refl.type; + if (!t || !ALIASABLE.has(t.type)) return false; + if (t.type === "reflection" && t.declaration?.signatures?.length) + return false; + return true; +} + +function buildAliasMap(candidates) { + aliasByKey = new Map(); + unionAliases = []; + for (let iter = 0; iter < 8; iter++) { + const next = new Map(); + const nextUnions = []; + const collisions = new Set(); + for (const c of candidates) { + renderingDecl = c.name; + const key = + c.kind === ReflectionKind.Interface + ? membersKey(c.children) + : rawKey(c.type); + if (key.length < ALIAS_MIN_KEY || key.length <= c.name.length) continue; + if (next.has(key) && next.get(key) !== c.name) collisions.add(key); + next.set(key, c.name); + if (c.kind === ReflectionKind.TypeAlias && c.type.type === "union") { + nextUnions.push({ + name: c.name, + keys: new Set(resolveUnion(c.type).map((i) => i.key)), + }); + } + } + renderingDecl = null; + // Two aliases with the same shape make substitution ambiguous; skip both. + for (const k of collisions) next.delete(k); + const stable = + next.size === aliasByKey.size && + [...next].every(([k, v]) => aliasByKey.get(k) === v); + aliasByKey = next; + unionAliases = nextUnions.sort((a, b) => b.keys.size - a.keys.size); + if (stable) break; + } +} + +// ------------------------------------------------------------------- types + +function isComplex(t) { + return t && (t.type === "union" || t.type === "intersection"); +} + +function renderType(t, indent = 0) { + if (!t) return "unknown"; + return aliasFor(t) ?? renderRaw(t, indent); +} + +function renderRaw(t, indent) { + switch (t.type) { + case "intrinsic": + return t.name; + case "literal": + return typeof t.value === "string" + ? JSON.stringify(t.value) + : String(t.value); + case "reference": { + const args = t.typeArguments?.length + ? `<${t.typeArguments.map((a) => renderType(a, indent)).join(", ")}>` + : ""; + return `${t.name}${args}`; + } + case "union": { + // `Layer | null` reads more naturally than `null | Layer`. + const items = resolveUnion(t, indent); + const nullish = items.filter( + (i) => i.str === "null" || i.str === "undefined", + ); + const rest = items.filter((i) => !nullish.includes(i)); + return [...rest, ...nullish].map((i) => i.str).join(" | "); + } + case "intersection": + return t.types.map((x) => renderType(x, indent)).join(" & "); + case "array": { + const inner = renderType(t.elementType, indent); + return isComplex(t.elementType) && !aliasFor(t.elementType) + ? `(${inner})[]` + : `${inner}[]`; + } + case "tuple": + return `[${(t.elements ?? []).map((x) => renderType(x, indent)).join(", ")}]`; + case "namedTupleMember": + return `${t.name}${t.isOptional ? "?" : ""}: ${renderType(t.element, indent)}`; + case "optional": + return `${renderType(t.elementType, indent)}?`; + case "rest": + return `...${renderType(t.elementType, indent)}`; + case "reflection": + return renderReflectionType(t.declaration, indent); + case "indexedAccess": + return `${renderType(t.objectType, indent)}[${renderType(t.indexType, indent)}]`; + case "typeOperator": + return `${t.operator} ${renderType(t.target, indent)}`; + case "query": + return `typeof ${renderType(t.queryType, indent)}`; + case "predicate": + return t.targetType + ? `${t.name} is ${renderType(t.targetType, indent)}` + : String(t.name); + case "templateLiteral": { + const parts = (t.tail ?? []) + .map(([type, text]) => `\${${renderType(type, indent)}}${text}`) + .join(""); + return `\`${t.head}${parts}\``; + } + case "conditional": + return `${renderType(t.checkType, indent)} extends ${renderType( + t.extendsType, + indent, + )} ? ${renderType(t.trueType, indent)} : ${renderType(t.falseType, indent)}`; + case "mapped": + return `{ [${t.parameter} in ${renderType(t.parameterType, indent)}]: ${renderType( + t.templateType, + indent, + )} }`; + case "unknown": + return t.name ?? "unknown"; + default: + return t.name ?? "unknown"; + } +} + +/** Object literal or inline function type. */ +function renderReflectionType(decl, indent) { + if (!decl) return "unknown"; + if (decl.signatures?.length) { + const sig = decl.signatures[0]; + return `(${renderParams(sig, indent)}) => ${renderType(sig.type, indent)}`; + } + const members = decl.children ?? []; + if (!members.length) return "{}"; + return renderMemberBlock(members, indent); +} + +const LINE_BUDGET = 78; + +const STOPWORDS = new Set([ + "the", + "a", + "an", + "of", + "to", + "for", + "in", + "on", + "this", + "that", + "is", + "are", + "be", + "will", + "which", + "and", + "or", + "its", + "it", + "you", + "if", + "with", + "when", + "used", + "use", + "value", + "values", + "set", + "get", + "given", + "any", + "all", + "as", +]); + +function contentWords(s) { + return s + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .split(" ") + .filter((w) => w.length > 2 && !STOPWORDS.has(w)) + .map((w) => w.replace(/(ies|es|s|ed|ing)$/, "")); +} + +/** + * Most property docs just restate the property name — "The label to display in + * the button" on `label`. Those cost bytes without telling a reader anything. + * A comment is kept only when it introduces enough words that the name and type + * don't already supply. + */ +let enclosingDeclName = ""; + +function commentAddsInformation(propName, typeStr, doc) { + if (!doc) return false; + const known = new Set([ + ...contentWords(propName), + ...contentWords(typeStr), + ...contentWords(enclosingDeclName), + ]); + const novel = contentWords(doc).filter((w) => !known.has(w)); + return novel.length >= 3; +} + +function memberDoc(m, sigText) { + const raw = summaryOf(m.comment); + return commentAddsInformation(m.name, sigText, raw) ? raw : ""; +} + +function memberDefault(m) { + const d = defaultOf(m.comment); + return d ? `default: ${d}` : ""; +} + +function memberSignature(m, indent) { + const optional = m.flags?.isOptional ? "?" : ""; + const readonly = m.flags?.isReadonly ? "readonly " : ""; + let rendered; + if (m.signatures?.length) { + const sig = m.signatures[0]; + rendered = `(${renderParams(sig, indent)}) => ${renderType(sig.type, indent)}`; + } else { + rendered = renderType(m.type, indent); + } + return `${readonly}${m.name}${optional}: ${rendered}`; +} + +function renderMemberBlock(members, indent) { + // Collapse to a single line where it fits. Small helper shapes like + // `{ id: string }` dominate the callback signatures, and giving each of them + // three lines is most of the difference between a usable doc and a huge one. + // Only safe when no member carries a comment worth keeping, since the inline + // form has nowhere to put one. + const parts = members.map((m) => memberSignature(m, indent)); + const annotated = members.some( + (m, i) => + memberDoc(m, parts[i]) || + memberDefault(m) || + remarksLines(m.comment).length, + ); + const inline = `{ ${parts.join("; ")} }`; + if ( + !annotated && + !inline.includes("\n") && + inline.length + indent * 2 <= LINE_BUDGET + ) { + return inline; + } + + const pad = " ".repeat(indent + 1); + const closePad = " ".repeat(indent); + const lines = members.map((m) => { + const sigText = memberSignature(m, indent + 1); + const doc = memberDoc(m, sigText); + const def = memberDefault(m); + const remarks = remarksLines(m.comment, pad); + const decl = `${pad}${sigText};`; + const multiline = decl.includes("\n"); + if (!doc && !def && !remarks.length) return decl; + if (!doc && !remarks.length) return `${decl} // ${def}`; + // Short docs ride along on the same line; longer ones get their own. + if (doc.length <= 60 && !multiline && !remarks.length) { + return `${decl} // ${doc}${def ? ` (${def})` : ""}`; + } + const above = [...(doc ? [`${pad}// ${doc}`] : []), ...remarks]; + return `${above.join("\n")}\n${decl}${def ? ` // ${def}` : ""}`; + }); + return `{\n${lines.join("\n")}\n${closePad}}`; +} + +function renderParams(sig, indent = 0) { + return (sig.parameters ?? []) + .map((p) => { + const optional = + p.flags?.isOptional || p.defaultValue !== undefined ? "?" : ""; + return `${p.name}${optional}: ${renderType(p.type, indent)}`; + }) + .join(", "); +} + +function typeParamsOf(refl) { + const tp = refl.typeParameters; + if (!tp?.length) return ""; + return `<${tp + .map((p) => { + const c = p.type ? ` extends ${renderType(p.type, 0)}` : ""; + const d = p.default ? ` = ${renderType(p.default, 0)}` : ""; + return `${p.name}${c}${d}`; + }) + .join(", ")}>`; +} + +// ------------------------------------------------------- structural plans + +/** Members of an exported object-like type, or null if it is not one. */ +function objectMembers(refl) { + if (refl.typeParameters?.length) return null; + if (refl.kind === ReflectionKind.Interface) return refl.children ?? []; + if ( + refl.kind === ReflectionKind.TypeAlias && + refl.type?.type === "reflection" && + !refl.type.declaration?.signatures?.length + ) { + return refl.type.declaration?.children ?? []; + } + return null; +} + +function memberInfo(m) { + return { + name: m.name, + key: m.signatures?.length ? reflectionKey(m) : typeKey(m.type), + optional: !!m.flags?.isOptional, + refl: m, + }; +} + +function memberMap(members) { + return new Map(members.map((m) => [m.name, memberInfo(m)])); +} + +/** + * Bases that are themselves exported object types. `zInfer<...>`, `Omit<...>` + * and the like are not, so a type extending those is treated as having no + * nameable base. + */ +function namedBases(refl, exportedObjects) { + if (refl.kind !== ReflectionKind.Interface) return null; + const ext = refl.extendedTypes ?? []; + if (!ext.length) return null; + const names = []; + for (const e of ext) { + if (e.type !== "reference" || e.typeArguments?.length) return null; + if (!exportedObjects.has(e.name) || e.name === refl.name) return null; + names.push(e.name); + } + return names; +} + +/** + * Tries to express `target` as `{ explicit } & Wrapper>`. + * Only succeeds when the result is structurally exact and shorter than + * spelling the members out. + */ +function deriveFrom(target, targetMembers, base, baseMembers) { + const tm = memberMap(targetMembers); + const bm = memberMap(baseMembers); + let best = null; + for (const wrapper of [null, "Partial", "Required"]) { + const explicit = []; + const omit = []; + let covered = 0; + for (const [name, t] of tm) { + const b = bm.get(name); + const expectedOptional = + wrapper === "Partial" + ? true + : wrapper === "Required" + ? false + : b?.optional; + if (b && b.key === t.key && t.optional === expectedOptional) { + covered++; + } else { + explicit.push(t.refl); + if (b) omit.push(name); + } + } + const missing = [...bm.keys()].filter((name) => !tm.has(name)); + omit.push(...missing); + // Every member that has to be spelled out or named in Omit is a cost; + // the derivation is only worth it when most members come for free. + const cost = explicit.length + missing.length; + if (covered < 3 || cost * 2 > covered) continue; + if (!best || cost < best.cost) { + best = { kind: "derived", base, wrapper, explicit, omit, cost }; + } + } + return best; +} + +/** Base candidates for a derived type, by naming convention. */ +function baseCandidates(name) { + const stem = name.replace(/(Read|Update)$/, ""); + return [...new Set([`${stem}Create`, `${name}Create`])].filter( + (c) => c !== name, + ); +} + +// --------------------------------------------------------------- traversal + +function moduleOfSource(refl) { + const file = refl.sources?.[0]?.fileName ?? ""; + const m = file.match(/modules\/([^/]+)\//); + return m ? m[1] : "main"; +} + +/** Filenames that describe the file's role rather than its subject. */ +const GENERIC_FILENAMES = new Set([ + "types", + "schema", + "schemas", + "index", + "controller", + "base", +]); + +/** A sub-heading is only worth its cost when it groups this many declarations. */ +const MIN_SUBGROUP = 4; + +/** + * Derives a sub-heading from the declaring file: + * ui/uiElements/UIButtonElement.ts -> "UIButtonElement" + * layers/stats/types.ts -> "Stats" (filename is generic) + * layers/types.ts -> null (no useful subdivision) + */ +function subGroupOf(refl) { + const file = refl.sources?.[0]?.fileName ?? ""; + const m = file.match(/modules\/[^/]+\/(.+)\.ts$/); + if (!m) return null; + + const segments = m[1].split("/"); + const last = segments.pop(); + const name = GENERIC_FILENAMES.has(last) ? segments.pop() : last; + if (!name || GENERIC_FILENAMES.has(name)) return null; + + // Leave PascalCase names alone; tidy up camelCase ones. + if (/^[A-Z]/.test(name)) return name; + const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +function controllerToModule(controllerName) { + return controllerName.replace(/Controller$/, "").toLowerCase(); +} + +/** 0: simple aliases, 1: object types and values, 2: unions of objects. */ +function categoryOf(refl) { + if (refl.kind !== ReflectionKind.TypeAlias || !refl.type) return 1; + const t = refl.type; + const simple = new Set(["intrinsic", "literal", "tuple", "templateLiteral"]); + if (simple.has(t.type)) return 0; + if (t.type === "array" && simple.has(t.elementType?.type)) return 0; + if (t.type === "union" && t.types.every((x) => simple.has(x.type))) return 0; + if (t.type === "union" || t.type === "intersection") return 2; + return 1; +} + +// ------------------------------------------------------------------- build + +const app = await Application.bootstrapWithPlugins({ + entryPoints: ["src/client.ts"], + plugin: ["typedoc-plugin-zod"], + disableSources: false, + logLevel: "Error", +}); + +const project = await app.convert(); +if (!project) { + console.error("TypeDoc conversion failed"); + process.exit(1); +} + +const rootModule = project.children[0]; +const allExports = rootModule.children ?? []; + +let feltController = null; +for (const c of allExports) { + if (c.name === "FeltController" && c.kind === ReflectionKind.Interface) { + feltController = c; + } +} +if (!feltController) { + console.error("FeltController not found in the converted project"); + process.exit(1); +} + +// The per-module controller interfaces are excluded: every one of their members +// is already emitted under the module's Methods/Events, so documenting them as +// types is the duplication this generator exists to avoid. +const isController = (refl) => + refl.kind === ReflectionKind.Interface && /Controller$/.test(refl.name); + +const documentedTypes = allExports.filter( + (c) => c !== feltController && !isController(c), +); +const typesByName = new Map(documentedTypes.map((c) => [c.name, c])); + +buildAliasMap(documentedTypes.filter(isAliasCandidate)); + +// --- Structural plans ------------------------------------------------------ + +const exportedObjects = new Map(); +for (const c of documentedTypes) { + const members = objectMembers(c); + if (members) exportedObjects.set(c.name, members); +} + +/** name -> { kind: "namedBase" | "derived" | "hoisted", ... } */ +const plans = new Map(); +/** Documentation-only shared-member types, keyed by anchor constituent name. */ +const synthetics = new Map(); + +for (const [name, members] of exportedObjects) { + const bases = namedBases(typesByName.get(name), exportedObjects); + if (!bases) continue; + const own = members.filter( + (m) => + !m.inheritedFrom || + !bases.some((b) => String(m.inheritedFrom.name).startsWith(`${b}.`)), + ); + plans.set(name, { kind: "namedBase", bases, own }); +} + +for (const [name, members] of exportedObjects) { + if (plans.has(name) || /Create$/.test(name)) continue; + for (const baseName of baseCandidates(name)) { + const baseMembers = exportedObjects.get(baseName); + if (!baseMembers || plans.get(baseName)?.kind === "derived") continue; + const plan = deriveFrom( + name, + members, + typesByName.get(baseName), + baseMembers, + ); + if (plan) { + plans.set(name, plan); + break; + } + } +} + +for (const c of documentedTypes) { + if (c.kind !== ReflectionKind.TypeAlias || c.type?.type !== "union") continue; + const refs = c.type.types; + if (refs.length < 3 || !refs.every((r) => r.type === "reference")) continue; + const constituents = refs + .map((r) => r.name) + .filter((n) => exportedObjects.has(n) && !plans.has(n)); + if (constituents.length < 3) continue; + + const maps = constituents.map((n) => memberMap(exportedObjects.get(n))); + const common = [...maps[0].values()].filter((m) => + maps.every((mm) => { + const o = mm.get(m.name); + return o && o.key === m.key && o.optional === m.optional; + }), + ); + if (common.length < 3) continue; + + let syntheticName = `${c.name}Common`; + while (typesByName.has(syntheticName)) syntheticName += "Props"; + const commonNames = new Set(common.map((m) => m.name)); + for (const n of constituents) { + plans.set(n, { + kind: "hoisted", + common: syntheticName, + own: exportedObjects.get(n).filter((m) => !commonNames.has(m.name)), + }); + } + synthetics.set(constituents[0], { + name: syntheticName, + union: c.name, + members: common.map((m) => m.refl), + }); +} + +// --- Declaration rendering ------------------------------------------------- + +function renderDerived(refl, plan) { + const parts = []; + if (plan.explicit.length) parts.push(renderMemberBlock(plan.explicit, 0)); + let ref = plan.base.name; + if (plan.omit.length) { + ref = `Omit<${ref}, ${plan.omit.map((k) => JSON.stringify(k)).join(" | ")}>`; + } + if (plan.wrapper) ref = `${plan.wrapper}<${ref}>`; + parts.push(ref); + return `type ${refl.name} = ${parts.join(" & ")};`; +} + +/** Renders a top-level exported declaration as a TypeScript statement. */ +function renderDeclaration(refl) { + const name = refl.name + typeParamsOf(refl); + enclosingDeclName = refl.name; + renderingDecl = refl.name; + try { + const plan = plans.get(refl.name); + if (plan?.kind === "namedBase") { + const own = plan.own.length ? renderMemberBlock(plan.own, 0) : null; + return `type ${name} = ${[...plan.bases, own].filter(Boolean).join(" & ")};`; + } + if (plan?.kind === "derived") return renderDerived(refl, plan); + if (plan?.kind === "hoisted") { + const own = plan.own.length ? ` & ${renderMemberBlock(plan.own, 0)}` : ""; + return `type ${name} = ${plan.common}${own};`; + } + + if (refl.kind === ReflectionKind.TypeAlias) { + return `type ${name} = ${renderType(refl.type, 0)};`; + } + + if (refl.kind === ReflectionKind.Interface) { + const members = refl.children ?? []; + return `type ${name} = ${members.length ? renderMemberBlock(members, 0) : "{}"};`; + } + + if (refl.kind === ReflectionKind.Variable) { + return `const ${refl.name}: ${renderType(refl.type, 0)};`; + } + + if (refl.signatures?.length) { + return renderFunction(refl); + } + + return `type ${name} = ${renderType(refl.type, 0)};`; + } finally { + renderingDecl = null; + } +} + +function renderSynthetic(s) { + enclosingDeclName = s.name; + return [ + `// Shared by every ${s.union} variant. Documentation shorthand, not an exported type.`, + `type ${s.name} = ${renderMemberBlock(s.members, 0)};`, + ].join("\n"); +} + +/** + * Renders a callable. FeltController members are shown as calls on the + * receiver (`felt.getLayer(...)`), since that is how user code reaches them. + */ +function renderFunction(refl, receiver = null) { + const sig = refl.signatures?.[0]; + if (!sig) { + const t = renderType(refl.type, 0); + return receiver + ? `${receiver}.${refl.name}: ${t};` + : `const ${refl.name}: ${t};`; + } + const ret = renderType(sig.type, 0); + const head = receiver + ? `${receiver}.${refl.name}` + : `${ret.startsWith("Promise<") ? "async function" : "function"} ${refl.name}`; + return `${head}${typeParamsOf(sig)}(${renderParams(sig)}): ${ret};`; +} + +function memberCommentLines(m) { + const comment = m.signatures?.[0]?.comment ?? m.comment; + const lines = []; + const doc = summaryOf(comment); + if (doc) lines.push(`// ${doc}`); + lines.push(...remarksLines(comment)); + return lines; +} + +// --- Ordering -------------------------------------------------------------- + +/** + * Orders a block's declarations: simple aliases first so they precede their + * first use, derived types directly after their base, and any hoisted common + * type directly before its first constituent. + */ +function orderBlock(decls) { + let ordered = [...decls].sort((a, b) => categoryOf(a) - categoryOf(b)); + for (const d of decls) { + const p = plans.get(d.name); + if (p?.kind !== "derived") continue; + if (!ordered.some((x) => x.name === p.base.name)) continue; + ordered = ordered.filter((x) => x !== d); + let pos = ordered.findIndex((x) => x.name === p.base.name) + 1; + while ( + pos < ordered.length && + plans.get(ordered[pos].name)?.kind === "derived" && + plans.get(ordered[pos].name).base.name === p.base.name + ) { + pos++; + } + ordered.splice(pos, 0, d); + } + const items = []; + for (const d of ordered) { + const s = synthetics.get(d.name); + if (s) items.push({ synthetic: s }); + items.push(d); + } + return items; +} + +// --- Bucketing ------------------------------------------------------------- + +const typesByModule = new Map(); +for (const c of documentedTypes) { + const mod = moduleOfSource(c); + if (!typesByModule.has(mod)) typesByModule.set(mod, []); + typesByModule.get(mod).push(c); +} + +// FeltController members, bucketed by the controller they came from. +const membersByModule = new Map(); +for (const member of feltController.children ?? []) { + const from = member.inheritedFrom + ? controllerToModule(String(member.inheritedFrom.name).split(".")[0]) + : "main"; + if (!membersByModule.has(from)) membersByModule.set(from, []); + membersByModule.get(from).push(member); +} + +const moduleNames = [ + ...new Set([...typesByModule.keys(), ...membersByModule.keys()]), +]; + +const unplaced = moduleNames.filter((m) => !MODULE_ORDER.includes(m)); +if (unplaced.length) { + for (const m of unplaced) { + console.error( + `New module "${m}": add it to MODULE_ORDER in scripts/build-llm-docs.mjs ` + + `to choose where it appears in ${OUT}.`, + ); + } + process.exit(1); +} +moduleNames.sort((a, b) => MODULE_ORDER.indexOf(a) - MODULE_ORDER.indexOf(b)); + +const isEvent = (m) => /^on[A-Z]/.test(m.name); +const isCallable = (m) => !!m.signatures?.length; + +// --- Emit ------------------------------------------------------------------ + +const emittedTypes = []; +const emittedMembers = []; +const out = []; + +out.push("# Felt SDK API Reference"); +out.push(""); +out.push( + "Generated from the SDK's TypeScript definitions. Sections follow the SDK's " + + "module structure; every type and every `FeltController` member appears exactly once. " + + `\`${RECEIVER}\` is the \`FeltController\` returned by \`Felt.embed(...)\` or \`Felt.connect(...)\`.`, +); +out.push(""); + +// A compact menu of everything callable, so the model can pick a method before +// reading its section. +out.push(`Methods on \`${RECEIVER}\`, by section:`); +out.push(""); +for (const mod of moduleNames) { + const members = (membersByModule.get(mod) ?? []).filter(isCallable); + if (!members.length) continue; + const methods = members.filter((m) => !isEvent(m)).map((m) => m.name); + const events = members.filter(isEvent).map((m) => m.name); + const parts = []; + if (methods.length) parts.push(methods.join(", ")); + if (events.length) parts.push(`events: ${events.join(", ")}`); + out.push(`- ${moduleTitle(mod)}: ${parts.join("; ")}`); +} +out.push(""); + +function emitTypeBlock(items) { + out.push("```typescript"); + for (const item of items) { + if (item.synthetic) { + out.push(renderSynthetic(item.synthetic)); + out.push(""); + continue; + } + const doc = summaryOf(item.comment); + if (doc) out.push(`// ${doc}`); + out.push(...remarksLines(item.comment)); + out.push(renderDeclaration(item)); + out.push(""); + emittedTypes.push(item.name); + } + if (out[out.length - 1] === "") out.pop(); + out.push("```"); + out.push(""); +} + +for (const mod of moduleNames) { + const title = moduleTitle(mod); + out.push(`## ${title}`); + out.push(""); + + // --- Types ------------------------------------------------------------- + const types = typesByModule.get(mod) ?? []; + if (types.length) { + const grouped = new Map(); + for (const t of types) { + const sub = subGroupOf(t) ?? ""; + if (!grouped.has(sub)) grouped.set(sub, []); + grouped.get(sub).push(t); + } + // Small sub-groups fold into the module's unnamed block; a heading for one + // or two declarations costs more than it organises. + for (const [sub, decls] of [...grouped]) { + if (sub && decls.length < MIN_SUBGROUP) { + grouped.delete(sub); + if (!grouped.has("")) grouped.set("", []); + grouped.get("").push(...decls); + } + } + if (grouped.has("")) { + // Restore source order after folding. + grouped.get("").sort((a, b) => types.indexOf(a) - types.indexOf(b)); + } + const subs = [...grouped.keys()].sort((a, b) => + a === "" ? -1 : b === "" ? 1 : a.localeCompare(b), + ); + + for (const sub of subs) { + if (sub) { + out.push(`### ${sub}`); + out.push(""); + } + emitTypeBlock(orderBlock(grouped.get(sub))); + } + } + + // --- Properties, methods and events ----------------------------------- + const members = membersByModule.get(mod) ?? []; + if (members.length) { + const properties = members.filter((m) => !isCallable(m)); + const events = members.filter((m) => isCallable(m) && isEvent(m)); + const methods = members.filter((m) => isCallable(m) && !isEvent(m)); + + for (const [heading, group] of [ + ["Properties", properties], + ["Methods", methods], + ["Events", events], + ]) { + if (!group.length) continue; + out.push(`### ${heading}`); + out.push(""); + out.push("```typescript"); + for (const m of group) { + out.push(...memberCommentLines(m)); + out.push(renderFunction(m, RECEIVER)); + out.push(""); + emittedMembers.push(m.name); + } + if (out[out.length - 1] === "") out.pop(); + out.push("```"); + out.push(""); + } + } +} + +// --------------------------------------------------------------- coverage + +const expectedTypes = new Set(documentedTypes.map((c) => c.name)); +const expectedMembers = new Set( + (feltController.children ?? []).map((c) => c.name), +); + +function coverageErrors(label, expected, emitted) { + const errors = []; + const counts = new Map(); + for (const n of emitted) counts.set(n, (counts.get(n) ?? 0) + 1); + const missing = [...expected].filter((n) => !counts.has(n)); + const repeated = [...counts].filter(([, c]) => c > 1).map(([n]) => n); + const extra = [...counts.keys()].filter((n) => !expected.has(n)); + if (missing.length) errors.push(` missing ${label}: ${missing.join(", ")}`); + if (repeated.length) + errors.push(` repeated ${label}: ${repeated.join(", ")}`); + if (extra.length) errors.push(` unexpected ${label}: ${extra.join(", ")}`); + return errors; +} + +const errors = [ + ...coverageErrors("types", expectedTypes, emittedTypes), + ...coverageErrors("members", expectedMembers, emittedMembers), +]; +if (errors.length) { + console.error("Coverage check failed."); + for (const e of errors) console.error(e); + process.exit(1); +} + +fs.mkdirSync(path.dirname(OUT), { recursive: true }); +fs.writeFileSync(OUT, out.join("\n").replace(/\n{3,}/g, "\n\n")); + +const bytes = fs.statSync(OUT).size; +const planCounts = {}; +for (const p of plans.values()) + planCounts[p.kind] = (planCounts[p.kind] ?? 0) + 1; +console.log( + `${OUT}: ${expectedMembers.size} members, ${expectedTypes.size} types, ` + + `${(bytes / 1024).toFixed(1)}KB`, +); +console.log( + ` compaction: ${Object.entries(planCounts) + .map(([k, v]) => `${k}=${v}`) + .join(", ")}, aliases=${aliasByKey.size}, synthetic=${synthetics.size}`, +); diff --git a/scripts/check-api-docs.sh b/scripts/check-api-docs.sh index c687e783..c386352e 100644 --- a/scripts/check-api-docs.sh +++ b/scripts/check-api-docs.sh @@ -1,15 +1,16 @@ #!/bin/bash -# Check if API docs are up to date -# This script compares the generated docs to the committed docs -# and fails if there are any differences +# Check if generated docs are up to date +# This script compares the generated docs (TypeDoc output in docs/ and the +# LLM-facing reference llms-full.txt) to the committed versions and fails if +# there are any differences -if git diff --exit-code --quiet docs; then - echo "API docs are up to date." +if git diff --exit-code --quiet docs llms-full.txt; then + echo "Generated docs are up to date." exit 0 else - echo "Error: API docs are out of date. Please run npm run build:docs and commit the changes." + echo "Error: generated docs are out of date. Please run npm run build and commit the changes." echo "Changed files:" - git diff --name-only docs | cat + git diff --name-only docs llms-full.txt | cat exit 1 fi