Docs
Views

Map view

Show the records of a location column on a map, with clusters, popups, a list of the records in view and a server-side area search.

The Map view shows a table's records as markers on a map, from a location column. Nearby markers group into clusters, a popup shows the record's properties with Open, and a list beside the map shows the records in view. The map follows the view's search and filters, is saved with the view, and can ask your server for the records of the area shown. React and Vue behave the same.

Map is an optional registry item, so the table keeps no map dependency until you install it. Both editions draw with MapLibre GL: React through mapcn, Vue directly with the same markers, clusters, popup and controls.

Install and enable

npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-map.json

This installs maplibre-gl and mapcn's map component (https://mapcn.dev/r/map.json), and adds the files under components/ui/yayaw-table-map/. Pass the renderer and list "map" in displayModes:

map-enable.tsx
import { mapRenderer } from "@/components/ui/yayaw-table-map/map-renderer";

<DataTable
  displayModeRenderers={{ map: mapRenderer }}
  // ...
/>;
npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-map.json

This installs maplibre-gl and adds the files under components/ui/yayaw-table-vue/map/. mapcn-vue is not used, because it needs Tailwind and shadcn-vue components that the Vue edition does not use. Pass the renderer and list "map" in displayModes:

map-enable.vue
<script setup lang="ts">
import { mapRenderer } from "@/components/ui/yayaw-table-vue/map/map-renderer";
</script>

<template>
  <YayawDataTable :display-mode-renderers="{ map: mapRenderer }" />
</template>
sites-config.ts
export const sitesConfig = defineTableConfig({
  ...projectConfig,
  table: {
    ...projectConfig.table,
    displayModes: ["table", "map"],
    map: {
      locationColumn: "site",
      titleColumn: "name",
      colorColumn: "status",
      style: "https://tiles.openfreemap.org/styles/positron",
    },
  },
});

Without a passed renderer, "map" is not offered even if listed in displayModes, and a link asking for it falls back to the default mode. table.map is optional: an object of default settings that every Map view starts from, plus the host options below, or false to turn the mode off. A table without a location column shows "Add a location column to show this table on a map."

The map loads lazily: React with lazy and Suspense, Vue with defineAsyncComponent. MapLibre is fetched when the first map is shown, not with the table, and the core table items only gain the shared map and location models. MapLibre needs WebGL; a browser without it shows a message instead of the map.

Try the map

The preview passes mapRenderer and shows the projects' sites on OpenFreeMap's keyless Positron basemap (Dark in dark mode; Liberty is offered in the settings), colored by status. Nearby markers group into clusters: click a marker for its popup and Open, or pick a record in the list beside the map. The online project has no site and is counted above the map. No API key is used, and the MapLibre worker is served by this documentation through workerUrl, as recommended in MapLibre worker. The preview uses the store-opening projects shared by every view guide; see the example setup for project-config.ts and the in-memory host.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Basemap

The library ships no tiles and no API keys. The basemap, the map image under the markers, comes from your configuration:

  • table.map.style: a MapLibre style URL, a style object, { light, dark? } for a different style in dark mode, or the id of one of styles.

  • table.map.styles: basemaps offered in the view settings, [{ id, label, light, dark?, attribution? }]. A view chooses one by id; without a choice, style is used, then the first entry.

  • table.map.attribution: text added to the attribution of every basemap, next to the one each style declares for its own sources.

Without a style, the map has no basemap: markers are drawn on an empty background and a notice says "No basemap is configured (table.map.style)."

For development, OpenFreeMap serves styles without a key, as the demos do:

map-styles.ts
export const devMap = {
  style: "positron",
  styles: [
    {
      id: "positron",
      label: "Positron",
      light: "https://tiles.openfreemap.org/styles/positron",
      dark: "https://tiles.openfreemap.org/styles/dark",
    },
    {
      id: "liberty",
      label: "Liberty",
      light: "https://tiles.openfreemap.org/styles/liberty",
    },
  ],
};

In production, choose a tile provider whose terms fit your traffic and use: a hosted service with a key (MapTiler, Stadia Maps and others), or your own tiles (for example Protomaps files on your storage). Keys that go into a style URL are visible to the browser, so restrict them to your domains in the provider's console. Keep the attribution your provider requires: styles carry their sources' attribution, and attribution adds yours. Light and dark styles follow the .dark or .light class or data-theme on the document, else the system preference.

MapLibre worker

MapLibre parses tiles in a web worker. By default the worker script is loaded from unpkg for the installed MapLibre version, as mapcn does:

https://unpkg.com/maplibre-gl@<version>/dist/maplibre-gl-worker.mjs

We recommend serving it yourself in production: a strict Content Security Policy may not allow unpkg, an offline or intranet app cannot reach it, and a self-hosted file does not depend on a third-party CDN. Copy the worker that ships with your installed package to your public folder, with maplibre-gl-shared.mjs, the module it imports from the same folder in MapLibre 6, and point table.map.workerUrl at the worker:

package.json
{
  "scripts": {
    "postinstall": "cp node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs node_modules/maplibre-gl/dist/maplibre-gl-shared.mjs public/"
  }
}
table: {
  map: { workerUrl: "/maplibre-gl-worker.mjs" },
}

Copy both files again whenever you upgrade maplibre-gl, so the worker and the library keep the same version. Your Content Security Policy must also allow the tile and style hosts of your basemap in connect-src and img-src.

What the map shows

  • Markers: one per record with a place in the location column, with the title column as its name. The records without a place are counted under the map ("3 records without a location").

  • Clusters: nearby markers group into a counted marker (MapLibre clustering, up to zoom 14). Clicking a cluster zooms in until it splits. Group nearby markers turns clustering off.

  • Colors: with a color column (select, multi-select or tag), markers take the option's color, else its tag hue.

  • Popup: clicking a marker opens a popup with the title, the place, the popup properties formatted like the table, and Open, which opens the record like a row click (the record view, the edit form or a row link, following rowClickMode). Clicking the map background closes it.

  • Fit to results: a button next to the zoom buttons fits the map to every marker. The map also fits on first load unless the view's start view is a saved position.

  • Records in view: a list beside the map shows the records inside the area shown, up to 300. Hovering or focusing a row highlights its marker, and the reverse; clicking a row flies to the marker and opens its popup. The list can be collapsed.

  • Phones: under 768 px wide, the list becomes a bottom sheet, closed at first.

  • Keyboard: markers and clusters are buttons with a name, reachable with Tab. Enter opens a marker's popup and moves focus to Open; Escape closes the popup and returns focus to the marker. On a cluster, Enter zooms in.

Map settings

Choose Map under View settings › Display mode, then open Card settings in the same menu. Settings are saved with the view (config.map) and in the <tableId>-map URL key, for example ?projects-display=map&projects-map={"cluster":false}.

SettingDefaultDescription
locationColumnThe first location columnThe column holding the places.
titleColumnThe first text columnMarker name and popup title.
colorColumnNoneA select, multi-select or tag column whose option colors color the markers.
popupColumnsThe first three other columnsColumns listed in the popup, in this order.
showPopupLabelstrueShow the property names in the popup.
clustertrueGroup nearby markers.
styletable.map.style, else the first of stylesThe id of one of table.map.styles. Offered when the host lists basemaps.
initialView"fit""fit" fits the map to the results; "saved" opens at center and zoom. Choosing Current position stores where the map is.
center, zoomNone[longitude, latitude] and the zoom of the saved start view.
searchOnMovefalseLoad the records of the area after each move instead of offering Search this area.

View settings win over table.map, which wins over these defaults. Unknown or malformed values in a saved view or a link are dropped.

Host options

table.map also takes options that only the host sets and that are never saved in views:

OptionDefaultDescription
styleNoneThe basemap: a style URL or object, { light, dark? }, or the id of one of styles.
styles[]Basemaps offered in the view settings: [{ id, label, light, dark?, attribution? }].
attributionNoneAttribution added to every basemap.
maxRows2000Records kept at most when the map loads them.
workerUrlunpkg, for the installed versionURL of MapLibre's worker script.

Search this area

After the user moves the map, a Search this area button appears. It reloads the map with the records of the area shown only; with searchOnMove, the map does so after every move. The records come from actions.list, with the view's usual search, filters and sorting plus a scope, like the calendar's date range:

list({
  ...query,
  scope: { kind: "bbox", field: "site", west: 2.22, south: 48.81, east: 2.47, north: 48.9 },
  page: 1,
  pageSize: 100,
});
// → { data, meta: { scope: "applied", pageCount?, totalCount? } }
  • field is the location column. west, south, east and north are degrees (WGS 84). west greater than east means the area crosses the antimeridian (longitude 180).

  • The map asks page after page (100 rows each) until the answer ends or maxRows is passed. Return meta.pageCount or meta.totalCount so the last page is known.

  • A server that filters by the scope answers meta.scope: "applied". Otherwise the table filters the loaded rows in the browser, which still works but transfers more data, and says "The area is filtered in the browser over the loaded records."

  • The map keeps maxRows records at most (2,000 by default). When more match, it shows the first ones and says "Only the first 2,000 records are shown. Narrow the filters to see all of them."

With the fit start view, the map first loads the view's records without a scope, with the same cap, and fits to them. With a saved start view, it searches the saved area right away.

Server example

The server filters the rows whose place lies in the rectangle, and reads the usual query as it does for the table. This PostgreSQL example stores the place as jsonb and derives the coordinates the filter needs. With PostGIS:

sites-postgis.sql
create extension if not exists postgis;

alter table projects
  add column site jsonb,
  add column site_geom geometry(Point, 4326) generated always as (
    case when site ? 'lat' and site ? 'lng'
      then st_setsrid(st_makepoint((site->>'lng')::float8, (site->>'lat')::float8), 4326)
    end
  ) stored;
create index projects_site_geom on projects using gist (site_geom);

Without PostGIS, plain latitude and longitude columns with a B-tree index are enough for rectangles:

sites-plain.sql
alter table projects
  add column site jsonb,
  add column site_lat float8 generated always as ((site->>'lat')::float8) stored,
  add column site_lng float8 generated always as ((site->>'lng')::float8) stored;
create index projects_site_lat_lng on projects (site_lat, site_lng);
server/projects-list.ts
"use server";

import { Pool } from "pg";

const pool = new Pool();
const LOCATION_COLUMNS = new Set(["site"]);
const MAX_PAGE_SIZE = 200;
const USE_POSTGIS = true;

type BboxScope = {
  kind: "bbox";
  field: string;
  west: number;
  south: number;
  east: number;
  north: number;
};

const inRange = (value: unknown, limit: number) =>
  typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= limit;

function isBbox(scope: unknown): scope is BboxScope {
  const s = scope as BboxScope | undefined;
  return (
    s?.kind === "bbox" &&
    LOCATION_COLUMNS.has(s.field) &&
    inRange(s.west, 180) &&
    inRange(s.east, 180) &&
    inRange(s.south, 90) &&
    inRange(s.north, 90) &&
    s.south <= s.north
  );
}

/** SQL condition for the rectangle; `west > east` crosses the antimeridian. */
function bboxWhere(scope: BboxScope, values: unknown[]): string {
  const p = (value: number) => `$${values.push(value)}`;
  if (USE_POSTGIS) {
    const envelope = (west: number, east: number) =>
      `site_geom && st_makeenvelope(${p(west)}, ${p(scope.south)}, ${p(east)}, ${p(scope.north)}, 4326)`;
    return scope.west <= scope.east
      ? envelope(scope.west, scope.east)
      : `(${envelope(scope.west, 180)} or ${envelope(-180, scope.east)})`;
  }
  const lat = `site_lat between ${p(scope.south)} and ${p(scope.north)}`;
  const lng =
    scope.west <= scope.east
      ? `site_lng between ${p(scope.west)} and ${p(scope.east)}`
      : `(site_lng >= ${p(scope.west)} or site_lng <= ${p(scope.east)})`;
  return `${lat} and ${lng}`;
}

export async function listProjects(params: Record<string, unknown>) {
  // Check the session and add your organization and permission filters here.
  const values: unknown[] = [];
  const where = ["true"]; // plus your search, filters and advanced filters
  const scope = isBbox(params.scope) ? params.scope : undefined;
  if (scope) {
    where.push(bboxWhere(scope, values));
  }
  const page = Math.max(1, Number(params.page) || 1);
  const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
  const filter = where.join(" and ");
  const [rows, total] = await Promise.all([
    pool.query(
      `select id, name, status, site from projects where ${filter}
        order by name, id limit ${pageSize} offset ${(page - 1) * pageSize}`,
      values
    ),
    pool.query(`select count(*)::int as count from projects where ${filter}`, values),
  ]);
  const totalCount = total.rows[0].count;
  return {
    data: rows.rows,
    meta: {
      totalCount,
      pageCount: Math.max(1, Math.ceil(totalCount / pageSize)),
      ...(scope ? { scope: "applied" } : {}),
    },
  };
}

Answer meta.scope: "applied" only when you really filtered by the scope: an invalid or unknown scope should be left out of the query, so the table filters in the browser. Other scopes, such as the calendar's dateRange, follow the same rule.

Translations

The map has built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat map.<key> keys in the table's translations, in React and Vue, for example "map.searchArea": "Search here".

  • Map: loading, map, noLocationColumn, withoutLocation (with {count}), withoutLocationOne, searchArea, searching, fit, zoomIn, zoomOut, open, close, cluster (with {count}), marker (with {title}).

  • List: list, inView (with {count}), showList, hideList, emptyList.

  • Messages: truncated (with {count}), clientFiltered, noStyle, unavailable.

  • Settings: locationColumn, titleColumn, colorColumn, none, popupColumns, showPopupLabels, clusterSetting, style, initialView, initialFit, initialSaved, searchOnMove, on, off.

The mode's name in the display mode picker is views.display.map in React and display.map in Vue. The location editor and filters use their own location.<key> labels.

Compatibility notes

TableDisplayMode now includes "map". If your code keeps an exhaustive Record<TableDisplayMode, …> map, such as icons or labels per mode, add a map entry so it keeps compiling:

const modeLabels: Record<TableDisplayMode, string> = {
  // ...
  map: "Map",
};

ListScope now also includes BoundsScope (kind: "bbox"), and rowInScope handles it. A list handler that switches over scope.kind should ignore kinds it does not know rather than fail.