Yayaw
Documentation
Vues

Vue Carte

Afficher les fiches d’une colonne de lieu sur une carte, avec regroupements, bulles, liste des fiches visibles et recherche par zone côté serveur.

La vue Carte affiche les fiches d’une table comme des marqueurs sur une carte, à partir d’une colonne de lieu. Les marqueurs proches se regroupent, une bulle montre les propriétés de la fiche avec Ouvrir, et une liste à côté de la carte montre les fiches visibles. La carte suit la recherche et les filtres de la vue, est enregistrée avec la vue et peut demander à votre serveur les fiches de la zone affichée. React et Vue se comportent de la même façon.

La carte est un élément de registre optionnel : la table n’a aucune dépendance cartographique tant que vous ne l’installez pas. Les deux éditions dessinent avec MapLibre GL : React via mapcn, Vue directement, avec les mêmes marqueurs, regroupements, bulle et contrôles.

Installer et activer

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

Cela installe maplibre-gl et le composant map de mapcn (https://mapcn.dev/r/map.json), et ajoute les fichiers dans components/ui/yayaw-table-map/. Passez le renderer et ajoutez "map" à 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

Cela installe maplibre-gl et ajoute les fichiers dans components/ui/yayaw-table-vue/map/. mapcn-vue n’est pas utilisé, car il demande Tailwind et des composants shadcn-vue que l’édition Vue n’utilise pas. Passez le renderer et ajoutez "map" à 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",
    },
  },
});

Sans renderer passé, "map" n’est pas proposé même s’il figure dans displayModes, et un lien qui le demande revient au mode par défaut. table.map est optionnel : un objet de réglages par défaut dont part chaque vue Carte, plus les options de l’hôte ci-dessous, ou false pour désactiver le mode. Une table sans colonne de lieu affiche « Ajoutez une colonne Lieu pour afficher ce tableau sur une carte. »

La carte se charge à la demande : React avec lazy et Suspense, Vue avec defineAsyncComponent. MapLibre est téléchargé à l’affichage de la première carte, pas avec la table, et les éléments de base de la table ne gagnent que les modèles partagés de carte et de lieu. MapLibre a besoin de WebGL ; un navigateur qui ne l’a pas affiche un message à la place de la carte.

Essayer la carte

L’aperçu passe mapRenderer et affiche les sites des projets sur le fond Positron d’OpenFreeMap, sans clé (Dark en mode sombre ; Liberty est proposé dans les réglages), colorés selon leur statut. Les marqueurs proches se regroupent : cliquez sur un marqueur pour sa fenêtre et Ouvrir, ou choisissez un enregistrement dans la liste à côté de la carte. Le projet en ligne n’a pas de site et il est compté au-dessus de la carte. Aucune clé d’API n’est utilisée, et le worker MapLibre est servi par cette documentation via workerUrl, comme recommandé dans Worker MapLibre. L’aperçu utilise les projets d’ouverture de magasins communs à tous les guides de vues ; voir les données des exemples pour project-config.ts et l’hôte en mémoire.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Fond de carte

La bibliothèque ne fournit ni tuiles ni clés d’API. Le fond de carte, l’image sous les marqueurs, vient de votre configuration :

  • table.map.style : l’URL d’un style MapLibre, un objet de style, { light, dark? } pour un autre style en mode sombre, ou l’id d’une entrée de styles.

  • table.map.styles : les fonds proposés dans les réglages de la vue, [{ id, label, light, dark?, attribution? }]. Une vue en choisit un par son id ; sans choix, style est utilisé, puis la première entrée.

  • table.map.attribution : un texte ajouté à l’attribution de chaque fond, à côté de celle que chaque style déclare pour ses propres sources.

Sans style, la carte n’a pas de fond : les marqueurs sont dessinés sur un fond vide et un avis indique « Aucun fond de carte n’est configuré (table.map.style). »

En développement, OpenFreeMap sert des styles sans clé, comme dans les démos :

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",
    },
  ],
};

En production, choisissez un fournisseur de tuiles dont les conditions correspondent à votre trafic et à votre usage : un service hébergé avec une clé (MapTiler, Stadia Maps et d’autres), ou vos propres tuiles (par exemple des fichiers Protomaps sur votre stockage). Une clé placée dans l’URL d’un style est visible par le navigateur : limitez-la à vos domaines dans la console du fournisseur. Gardez l’attribution que votre fournisseur exige : les styles portent celle de leurs sources, et attribution ajoute la vôtre. Les styles clair et sombre suivent la classe .dark ou .light ou data-theme sur le document, sinon la préférence du système.

Worker MapLibre

MapLibre lit les tuiles dans un web worker. Par défaut, le script du worker est chargé depuis unpkg pour la version installée de MapLibre, comme le fait mapcn :

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

Nous recommandons de le servir vous-même en production : une Content Security Policy stricte peut refuser unpkg, une application hors ligne ou intranet ne peut pas l’atteindre, et un fichier auto-hébergé ne dépend pas d’un CDN tiers. Copiez le worker livré avec le paquet installé dans votre dossier public, avec maplibre-gl-shared.mjs, le module qu’il importe depuis le même dossier dans MapLibre 6, et pointez table.map.workerUrl sur le 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" },
}

Recopiez les deux fichiers à chaque mise à jour de maplibre-gl, pour que le worker et la bibliothèque gardent la même version. Votre Content Security Policy doit aussi autoriser les hôtes des tuiles et du style de votre fond de carte dans connect-src et img-src.

Ce que montre la carte

  • Marqueurs : un par fiche ayant un lieu dans la colonne de lieu, avec la colonne de titre comme nom. Les fiches sans lieu sont comptées sous la carte (« 3 enregistrements sans lieu »).

  • Regroupements : les marqueurs proches se regroupent en un marqueur compté (regroupement MapLibre, jusqu’au zoom 14). Cliquer sur un regroupement zoome jusqu’à ce qu’il se sépare. Regrouper les marqueurs proches désactive le regroupement.

  • Couleurs : avec une colonne de couleur (sélection, sélection multiple ou tag), les marqueurs prennent la color de l’option, sinon la teinte de son tag.

  • Bulle : cliquer sur un marqueur ouvre une bulle avec le titre, le lieu, les propriétés de la bulle mises en forme comme dans la table, et Ouvrir, qui ouvre la fiche comme un clic sur une ligne (la vue enregistrement, le formulaire d’édition ou un lien de ligne, selon rowClickMode). Cliquer sur le fond de la carte la ferme.

  • Ajuster aux résultats : un bouton à côté des boutons de zoom ajuste la carte à tous les marqueurs. La carte s’ajuste aussi au premier chargement, sauf si la vue de départ de la vue est une position enregistrée.

  • Fiches visibles : une liste à côté de la carte montre les fiches de la zone affichée, jusqu’à 300. Survoler ou focaliser une ligne met son marqueur en évidence, et inversement ; cliquer sur une ligne vole jusqu’au marqueur et ouvre sa bulle. La liste peut être repliée.

  • Téléphones : sous 768 px de large, la liste devient un panneau en bas de l’écran, fermé au départ.

  • Clavier : les marqueurs et les regroupements sont des boutons nommés, accessibles avec Tab. Entrée ouvre la bulle d’un marqueur et place le focus sur Ouvrir ; Échap ferme la bulle et rend le focus au marqueur. Sur un regroupement, Entrée zoome.

Réglages de la carte

Choisissez Carte dans Paramètres de la vue › Mode d’affichage, puis ouvrez Réglages des cartes dans le même menu. Les réglages sont enregistrés avec la vue (config.map) et dans la clé d’URL <tableId>-map, par exemple ?projects-display=map&projects-map={"cluster":false}.

RéglagePar défautDescription
locationColumnLa première colonne de lieuLa colonne qui contient les lieux.
titleColumnLa première colonne de texteNom du marqueur et titre de la bulle.
colorColumnAucuneUne colonne de sélection, de sélection multiple ou de tags dont les couleurs d’options colorent les marqueurs.
popupColumnsLes trois premières autres colonnesLes colonnes listées dans la bulle, dans cet ordre.
showPopupLabelstrueAfficher le nom des propriétés dans la bulle.
clustertrueRegrouper les marqueurs proches.
styletable.map.style, sinon la première entrée de stylesL’id d’une entrée de table.map.styles. Proposé quand l’hôte liste des fonds de carte.
initialView"fit""fit" ajuste la carte aux résultats ; "saved" l’ouvre à center et zoom. Choisir Position actuelle enregistre l’endroit où se trouve la carte.
center, zoomAucun[longitude, latitude] et le zoom de la vue de départ enregistrée.
searchOnMovefalseCharger les fiches de la zone après chaque déplacement au lieu de proposer Rechercher dans cette zone.

Les réglages de la vue l’emportent sur table.map, qui l’emporte sur ces valeurs par défaut. Les valeurs inconnues ou mal formées d’une vue enregistrée ou d’un lien sont ignorées.

Options de l’hôte

table.map accepte aussi des options que seul l’hôte définit et qui ne sont jamais enregistrées dans les vues :

OptionPar défautDescription
styleAucunLe fond de carte : l’URL ou l’objet d’un style, { light, dark? }, ou l’id d’une entrée de styles.
styles[]Les fonds proposés dans les réglages de la vue : [{ id, label, light, dark?, attribution? }].
attributionAucuneAttribution ajoutée à chaque fond de carte.
maxRows2000Nombre maximal de fiches que la carte garde en les chargeant.
workerUrlunpkg, pour la version installéeURL du script du worker MapLibre.

Rechercher dans cette zone

Quand l’utilisateur déplace la carte, un bouton Rechercher dans cette zone apparaît. Il recharge la carte avec les seules fiches de la zone affichée ; avec searchOnMove, la carte le fait après chaque déplacement. Les fiches viennent de actions.list, avec la recherche, les filtres et le tri habituels de la vue, plus un scope, comme la plage de dates du calendrier :

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 est la colonne de lieu. west, south, east et north sont en degrés (WGS 84). west plus grand que east signifie que la zone traverse l’antiméridien (longitude 180).

  • La carte demande les pages l’une après l’autre (100 lignes chacune) jusqu’à la fin de la réponse ou au-delà de maxRows. Renvoyez meta.pageCount ou meta.totalCount pour que la dernière page soit connue.

  • Un serveur qui filtre selon le scope répond meta.scope: "applied". Sinon, la table filtre les lignes chargées dans le navigateur, ce qui fonctionne mais transfère plus de données, et l’indique : « La zone est filtrée dans le navigateur parmi les enregistrements chargés. »

  • La carte garde au plus maxRows fiches (2 000 par défaut). Quand il y en a davantage, elle affiche les premières et l’indique : « Seuls les 2 000 premiers enregistrements sont affichés. Affinez les filtres pour tous les voir. »

Avec la vue de départ fit, la carte charge d’abord les fiches de la vue sans scope, avec la même limite, et s’y ajuste. Avec une vue de départ enregistrée, elle cherche tout de suite dans la zone enregistrée.

Exemple serveur

Le serveur filtre les lignes dont le lieu se trouve dans le rectangle, et lit la requête habituelle comme il le fait pour la table. Cet exemple PostgreSQL stocke le lieu en jsonb et en dérive les coordonnées dont le filtre a besoin. Avec 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);

Sans PostGIS, de simples colonnes de latitude et de longitude avec un index B-tree suffisent pour des 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
  );
}

/** Condition SQL du rectangle ; `west > east` traverse l’antiméridien. */
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>) {
  // Vérifiez la session et ajoutez ici vos filtres d’organisation et de permissions.
  const values: unknown[] = [];
  const where = ["true"]; // plus votre recherche, vos filtres et filtres avancés
  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" } : {}),
    },
  };
}

Ne répondez meta.scope: "applied" que si vous avez réellement filtré selon le scope : un scope invalide ou inconnu doit être laissé hors de la requête, pour que la table filtre dans le navigateur. Les autres scopes, comme le dateRange du calendrier, suivent la même règle.

Traductions

La carte a des libellés intégrés en anglais et en français ; le français est utilisé quand la locale commence par fr. Remplacez-en n’importe lequel avec des clés plates map.<key> dans les traductions de la table, en React et en Vue, par exemple "map.searchArea": "Chercher ici".

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

  • Liste : list, inView (avec {count}), showList, hideList, emptyList.

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

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

Le nom du mode dans le sélecteur de mode d’affichage est views.display.map en React et display.map en Vue. L’éditeur de lieu et les filtres utilisent leurs propres libellés location.<key>.

Notes de compatibilité

TableDisplayMode inclut désormais "map". Si votre code garde une table exhaustive Record<TableDisplayMode, …>, par exemple des icônes ou des libellés par mode, ajoutez une entrée map pour qu’il compile toujours :

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

ListScope inclut aussi BoundsScope (kind: "bbox"), et rowInScope le gère. Un handler list qui distingue les cas de scope.kind doit ignorer les valeurs qu’il ne connaît pas plutôt qu’échouer.