Map view plugin for Object UI.
Renders the records of an ObjectQL query as markers on a MapLibre map: every marker comes from a record's own coordinate fields, and the first paint frames the records that were fetched. It is a view over data — there is no authored marker list, and no pin you place by hand.
Importing the package registers one component type on the ComponentRegistry:
object-map, the object-bound renderer. Inside an ObjectView, a stored map
view is compiled to an object-map node, so a saved map view ends at the same
component.
The bare
mapnode type key is retired (objectui#10393, following the objectui#8008 ruling that retiredgantt). The registry used to accept a node authored"type": "map"(and its namespaced twinview:map) while the published declaration refused it —ObjectMapSchema.typeis the literal'object-map'and no schema arm namesmap— so a validated document could not use the key the registry took. Write"type": "object-map".
⚠️ The stored view type"map"— what a savedlistViews[].typeordefaultViewTypeholds — is a different layer and is unchanged. Do not rewrite it:ObjectViewmaps a storedmapview onto theobject-mapnode type, so no saved view moves.
pnpm add @object-ui/plugin-mapObjectMap imports MapLibre's stylesheet at module scope
(import 'maplibre-gl/dist/maplibre-gl.css'), and Node has no loader for .css at all.
Importing the published entry from plain Node ESM — no bundler, no loader hooks — therefore
resolves and then fails during evaluation:
TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"
for .../maplibre-gl/dist/maplibre-gl.css
This is a supported-configuration statement, not a bug to report. Unbundled Node consumption is not supported for style-carrying plugin packages. It was ruled that way on objectui#5384 — deliberately, over the alternative of moving the stylesheet out of module scope — because a MapLibre canvas without its stylesheet is not a map, and no unbundled-Node consumer exists to serve.
Consume it through a host that handles CSS imports, which every supported host does: Vite,
webpack, or Next with the package listed in transpilePackages. If you have a real need to
import it under plain Node — SSR with no bundler, a Node-side script — please open an issue.
That reopens the question as a design decision rather than a defect, and the shape of your
consumer is the missing input.
Registration is a side effect of the import. There is no manual-registration export to iterate over — the import is the registration.
import '@object-ui/plugin-map';
import type { ObjectMapSchema } from '@object-ui/types';
// Object-bound: the markers are the records the query returns.
const schema: ObjectMapSchema = {
type: 'object-map',
objectName: 'stores',
map: {
latitudeField: 'lat',
longitudeField: 'lng',
titleField: 'name',
descriptionField: 'address',
},
};A literal record array instead of a query, with the same map block:
import type { ObjectMapSchema } from '@object-ui/types';
const schema: ObjectMapSchema = {
type: 'object-map',
staticData: [
{ id: 1, name: 'San Francisco HQ', lat: 37.7749, lng: -122.4194 },
{ id: 2, name: 'Oakland Office', lat: 37.8044, lng: -122.2711 },
],
map: { latitudeField: 'lat', longitudeField: 'lng', titleField: 'name' },
};filter and sort are the query's filter and order — they reach the data
source as $filter / $orderby, and the spec's per-element dataSource binding
is honoured as well.
The provider does not change which query keys apply (objectui#9061, the port
of objectui#8769). An authored filter and sort narrow and order the rows on
every provider, inline ones included — staticData and
data: { provider: 'value', items } both reach the same in-memory adapter the
other providers go through, so filter is evaluated with the same matcher. A
bare array under data is not a record source on this map (objectui#8348): the
ladder falls through to staticData, then objectName, so inline rows belong
under staticData. Before objectui#9061 the inline provider skipped that query
and plotted every authored row with an authored filter silently dropped. The
platform row ceiling (2,000 drawn rows, with a footnote naming both numbers — objectui#7210,
ruling a′) applies to inline rows too, and it is applied to the filtered set,
never to the raw one: a large inline array that a filter cuts below the ceiling
plots every matching row and shows no footnote.
onMarkerClick against the
authored array with === needs id equality instead; and the copy is a JSON
round-trip, so inline rows must be JSON-serializable.
The declared configuration input. Every key is optional:
| Key | Description |
|---|---|
latitudeField |
Record field holding the latitude. Needs longitudeField alongside it; both values must be numbers. |
longitudeField |
Record field holding the longitude. |
locationField |
Single field holding both coordinates — see the formats below. Used when the lat/lng pair yields nothing. |
titleField |
Field shown as the marker title. Omitted, the title is resolved by the object's own record-title precedence (@object-ui/core's getRecordDisplayName, ADR-0079): the declared nameField, its deprecated displayNameField alias, the legacy titleFormat template, a type-aware pick from the object's fields, then name-ish keys read straight off the record — the rung that answers when no object definition reached the view, as staticData and an inline data: { provider: 'value', items } configuration never fetch one. Record #<id> is the floor; Marker is reached only by a record carrying no id at all. |
descriptionField |
Field shown under the title in the marker popup. |
zoom |
Zoom level. Declaring it opts this view out of the auto-fit (see below). |
center |
[latitude, longitude] — a two-number tuple, latitude first. Declaring it opts this view out of the auto-fit. |
style |
MapLibre style URL/spec, replacing the default public demo style. |
Nothing is guessed — an unbound map REFUSES. A map with no coordinate binding renders
Map configuration required — declare
map.locationFieldormap.latitudeField+map.longitudeField
in place of the map, rather than painting an empty one (objectui#8169, ruled
2026-09-07). Unbound is a property of the resolved config, not of the spelling
that produced it — an absent map block, a block naming only titleField, and a
latitudeField with no longitudeField beside it all refuse alike.
The four field-name defaults this component used to fall back to — latitude /
longitude / location / description — are gone, and so are the
locationField: … || 'location' floors that plugin-list and plugin-view used
to add on the way in. latitude / longitude columns no longer plots on a view
that declared no binding — it refuses, and the fix is to declare the binding.
A title field is still never guessed (objectui#5953): an unconfigured marker
takes its title from the record-title precedence above.
There is no default zoom and no default centre. With records to show and no camera declared, the map fits the records: their bounding box, measured along the shortest arc that contains them (so a set straddling the antimeridian is framed across the line, not around the far side of the planet), with 48px of padding and a city-scale zoom ceiling of 12 — a single record does not become a rooftop view.
Two cases sit outside the fit:
- Nothing placeable (empty result, or no record yielding coordinates): the
whole world, centred on
0, 0. - A declared camera:
zoomorcenterin themapblock wins and the fit is skipped. Declaring one half keeps the other derived —zoomalone is applied at the centre of the records,centeralone at a continental zoom.
A center that is not a two-number tuple (the { lat, lng } object form, say) is
rejected by the config schema, warned about in the console, and not adapted —
and it does not cost the view its fit.
locationField names one record field, and the value in it is read at RUNTIME:
extractCoordinates() in packages/plugin-map/src/ObjectMap.tsx tests the value's
shape per record and takes the first of these three that answers. So this is a
reference read of what that parser accepts, not a value you author — which is why
it is a table rather than a snippet:
Shape of record[locationField] |
Example value | What the parser accepts |
|---|---|---|
| Object | { lat: 37.7749, lng: -122.4194 } |
lat or latitude for the latitude; lng, lon or longitude for the longitude. Both must already be numbers — an object form is not string-parsed. |
| String | '37.7749,-122.4194' |
"lat,lng": split on the comma, each half trimmed and parseFloat-ed. |
| Array | [37.7749, -122.4194] |
[lat, lng], exactly two elements, each parseFloat-ed. |
The lat/lng PAIR (latitudeField + longitudeField) is tried first and is
stricter: both values must already be numbers, with no parsing step at all.
A record whose coordinates are missing, unparseable, or out of range (latitude beyond ±90, longitude beyond ±180) is left off the map and counted in a notice above it, rather than being silently dropped or rescued. The range test and the notice live in that same file, alongside the parser.
Keys that look plausible on a map schema but have no read site here: markers
(markers are records), layers, height (the container is a fixed responsive
height, 300px through 600px), useGeolocation (the map carries a
user-initiated "show my location" button instead), and per-marker icon /
color / popup styling. A map configuration stashed under filter.map — a
shape predating the map input — is no longer read either, and says so in the
console.
ObjectMap (the component), ObjectMapRenderer (the registered wrapper, for a
host that registers types itself) and the ObjectMapProps type are the package's
exports:
import { ObjectMap, type ObjectMapProps } from '@object-ui/plugin-map';
declare const dataSource: ObjectMapProps['dataSource'];
<ObjectMap
schema={{ type: 'object-map', objectName: 'stores', map: { latitudeField: 'lat', longitudeField: 'lng' } }}
dataSource={dataSource}
onMarkerClick={(record) => console.log(record)}
/>;| Prop | Description |
|---|---|
schema |
The map schema — the keys above. |
dataSource |
Resolves the object provider. Not needed for staticData or an inline data: { provider: 'value', items } configuration. |
className |
Classes for the wrapper around the map. |
data |
Records to render directly, bypassing the component's own fetch — the shape ListView passes when it already holds the rows. Tracked live: passing a new array after mount (e.g. once a host's own in-flight query resolves) updates the map. |
onMarkerClick |
Called with the clicked record. |
onRowClick |
Record click handler; takes priority over the navigation overlay. |
onEdit / onDelete |
Passing either adds that button to the marker popup (and to the mobile record sheet). |
enableClustering |
Forces clustering on; without it, clustering starts above 100 visible markers. |
clusterRadius |
Clustering granularity (default 50): the grid cell is clusterRadius / 2 ** zoom, so a larger value groups more aggressively. |
In a schema-driven page these handlers may equally be authored on the node
itself: SchemaRenderer spreads a node's non-metadata properties onto the
component.
- 📚 Documentation — the full
authoring reference for the schema and the
mapblock - 📦 npm package
- 📝 Changelog
- 🐛 Report an issue
- 🤝 Contributing Guide
- 🗺️ Roadmap
MIT — see LICENSE.