Manual/Referencia

API de Plugins de Meratyn

Esta página es la referencia completa de la API. Para un recorrido guiado por los patrones de ciclo de vida, estado y superficies de interfaz detrás de los plugins complejos, ver Escribir plugins.

Interfaz

import type { FeatureCollection } from "geojson";
import type { IControl } from "maplibre-gl";

export type MappViewMapControlPosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";

export type MappViewBuiltInMapControl =
  | "navigation"
  | "fullscreen"
  | "geolocate"
  | "globe"
  | "terrain"
  | "scale"
  | "attribution"
  | "logo"
  | "layer-control";

export interface MappViewPlugin {
  id: string;
  name: string;
  version: string;
  activeByDefault?: boolean;
  /** Los plugins del mismo grupo no pueden estar activos a la vez. */
  exclusiveGroup?: string;
  /** Se requiere al menos un nombre para que se llame a handleUrlParameters. */
  urlParameterNames?: string[];
  activate: (app: MappViewAppAPI) => boolean | void;
  deactivate: (app: MappViewAppAPI) => void;
  handleUrlParameters?: (app: MappViewAppAPI, params: URLSearchParams) => void | Promise<void>;
  getMapControlPosition?: () => MappViewMapControlPosition;
  setMapControlPosition?: (
    app: MappViewAppAPI,
    position: MappViewMapControlPosition,
  ) => boolean | void;
  getProjectState?: () => unknown;
  applyProjectState?: (app: MappViewAppAPI, state: unknown) => boolean | void;
}

// Resuelto por app.getDeckGL(): los módulos deck.gl propios de Meratyn, de modo que
// un plugin renderiza sobre la instancia única del anfitrión en vez de empaquetar su
// propia copia.
export interface MappViewDeckGL {
  core: typeof import("@deck.gl/core");
  layers: typeof import("@deck.gl/layers");
  geoLayers: typeof import("@deck.gl/geo-layers");
  meshLayers: typeof import("@deck.gl/mesh-layers");
  mapbox: typeof import("@deck.gl/mapbox");
}

export interface MappViewAppAPI {
  setBasemap: (styleUrl: string) => void;
  addGeoJsonLayer: (name: string, data: FeatureCollection, sourcePath?: string) => string;
  // Capas nativas de ráster/teselas (ver "Capas de ráster y teselas" abajo). Cada
  // una devuelve el id de la nueva capa y la capa aparece en el panel de Capas y
  // persiste con el proyecto, igual que addGeoJsonLayer para datos vectoriales.
  addTileLayer?: (name: string, url: string, options?: MappViewTileLayerOptions) => string;
  addWmtsLayer?: (name: string, url: string, options?: MappViewTileLayerOptions) => string;
  addWmsLayer?: (name: string, options: MappViewWmsLayerOptions) => string;
  // COG nativo del lado cliente (lee el GeoTIFF directamente; controles de
  // banda/rescale/colormap/nodata). Resuelve con el id de la nueva capa (ver
  // "Capas de ráster y teselas" abajo).
  addCogLayer?: (name: string, url: string, options?: MappViewCogLayerOptions) => Promise<string>;
  // Zarr por medio de la instancia @carbonplan/zarr-layer propia del anfitrión, con
  // reproyección crs/proj4 (ver "Capas Zarr" abajo).
  addZarrLayer?: (name: string, url: string, options: MappViewZarrLayerOptions) => Promise<string>;
  setZarrLayerSelector?: (
    layerId: string,
    selector: Record<string, number | string>,
  ) => Promise<boolean>;
  // Registra una capa que el plugin añadió al mapa por su cuenta, de modo que
  // aparezca en el panel de Capas (ver "Capas personalizadas (WebGL) y propiedad
  // del pintado" abajo).
  registerExternalNativeLayer?: (layer: MappViewExternalNativeLayerRegistration) => void;
  unregisterExternalNativeLayer?: (id: string) => void;
  getActiveBasemap: () => string;
  onBasemapChange: (callback: (styleUrl: string) => void) => () => void;
  fetchArrayBuffer?: (url: string) => Promise<ArrayBuffer>;
  fitBounds?: (bounds: [number, number, number, number]) => void;
  getMap?: () => import("maplibre-gl").Map | null;
  addMapControl: (control: IControl, position?: MappViewMapControlPosition) => boolean;
  removeMapControl: (control: IControl) => void;
  // Nota: mostrar el control "terrain" (visible: true) también activa el terreno
  // 3D, igual que el menú de Controles, para que el usuario no tenga que pulsar el
  // botón del control como segundo paso. Ocultarlo desactiva el terreno de vuelta.
  setBuiltInMapControlVisible: (control: MappViewBuiltInMapControl, visible: boolean) => boolean;
  getBuiltInMapControlPosition: (control: MappViewBuiltInMapControl) => MappViewMapControlPosition;
  setBuiltInMapControlPosition: (
    control: MappViewBuiltInMapControl,
    position: MappViewMapControlPosition,
  ) => boolean;
  getDeckGL?: () => Promise<MappViewDeckGL>;
  // Paneles de la barra lateral derecha (ver "Paneles laterales derechos" abajo).
  registerRightPanel?: (panel: MappViewRightPanelRegistration) => () => void;
  unregisterRightPanel?: (id: string) => void;
  openRightPanel?: (id: string) => boolean;
  collapseRightPanel?: (id: string) => void;
  closeRightPanel?: (id: string) => void;
  getActiveRightPanel?: () => string | null;
  setActiveRightPanelDock?: (dock: MappViewRightPanelDock) => void;
  getActiveRightPanelDock?: () => MappViewRightPanelDock | null;
  // Menús de la barra superior (ver "Menús de la barra" abajo).
  registerToolbarMenu?: (menu: MappViewToolbarMenu) => () => void;
  unregisterToolbarMenu?: (id: string) => void;
  // Paneles flotantes (ver "Paneles flotantes" abajo).
  registerFloatingPanel?: (panel: MappViewFloatingPanelRegistration) => () => void;
  unregisterFloatingPanel?: (id: string) => void;
  openFloatingPanel?: (id: string) => boolean;
  closeFloatingPanel?: (id: string) => void;
  getOpenFloatingPanels?: () => string[];
}

export interface MappViewToolbarMenu {
  id: string;
  label: string;
  icon?: string; // URL o data: URI
  items: MappViewToolbarMenuItem[];
}

export type MappViewToolbarMenuItem =
  | {
      type?: "action";
      id: string;
      label: string;
      icon?: string;
      disabled?: boolean;
      onSelect: () => void;
    }
  | { type: "submenu"; id: string; label: string; icon?: string; items: MappViewToolbarMenuItem[] }
  | { type: "separator"; id?: string };

export interface MappViewFloatingPanelRegistration {
  id: string;
  // Un getter hace que el título se re-localice en vivo ante cambios de idioma: se
  // re-evalúa en cada llamada a getFloatingPanel(), así que toma el idioma actual
  // sin re-registrar el panel. Advertencia: el registro en sí no se suscribe a
  // eventos i18n, así que el getter solo se re-ejecuta cuando un consumidor relee
  // el panel. En la práctica cada componente del anfitrión que renderiza el título
  // también llama a useTranslation(), cuyo re-render por languageChanged relee el
  // panel como efecto secundario; un anfitrión que lea el título sin esa
  // suscripción mostraría un título obsoleto tras un cambio de idioma hasta la
  // siguiente mutación del registro, y debe releer el panel por sí mismo ante un
  // cambio de idioma. Una cadena plana se congela en el momento del registro.
  title: string | (() => string);
  icon?: string; // URL o data: URI
  defaultWidth?: number;
  render: (container: HTMLElement) => void | (() => void);
  onOpen?: () => void;
  onClose?: () => void;
}

export type MappViewRightPanelDock =
  | "left-of-layers" // a la izquierda del panel de Capas
  | "right-of-layers" // entre el panel de Capas y el mapa
  | "left-of-style" // entre el mapa y el panel de Estilo
  | "right-of-style" // a la derecha del panel de Estilo (predeterminado)
  | "replace-style" // comparte el raíl único de la barra de Estilo (modo raíl compartido)
  | "replace-layers"; // comparte el raíl único de la barra de Capas (modo raíl compartido)

export interface MappViewRightPanelRegistration {
  id: string;
  // Un getter hace que el título se re-localice en vivo ante cambios de idioma: se
  // re-evalúa en cada llamada a getRightPanel(), así que toma el idioma actual sin
  // re-registrar el panel. Advertencia: el registro en sí no se suscribe a eventos
  // i18n, así que el getter solo se re-ejecuta cuando un consumidor relee el panel.
  // En la práctica cada componente del anfitrión que renderiza el título también
  // llama a useTranslation(), cuyo re-render por languageChanged relee el panel como
  // efecto secundario; un anfitrión que lea el título sin esa suscripción mostraría
  // un título obsoleto tras un cambio de idioma hasta la siguiente mutación del
  // registro, y debe releer el panel por sí mismo ante un cambio de idioma. Una
  // cadena plana se congela en el momento del registro.
  title: string | (() => string);
  /** Posición de acople inicial; "right-of-style" (predeterminado). */
  dock?: MappViewRightPanelDock;
  /** Icono opcional del raíl: una URL o data: URI renderizada como imagen. */
  icon?: string;
  /** Ancho expandido preferido en px (solo escritorio; acotado por el anfitrión). */
  defaultWidth?: number;
  /** Si el anfitrión renderiza el botón de cerrar en el encabezado (predeterminado: true). */
  showCloseButton?: boolean;
  /** Llena el cuerpo del panel con tu propio DOM. Puede devolver una función de limpieza. */
  render: (container: HTMLElement) => void | (() => void);
  onOpen?: () => void;
  onCollapse?: () => void;
  onClose?: () => void;
}

Plugins mutuamente excluyentes

En PluginDefinition (MappViewPlugin), establece exclusiveGroup cuando varios plugins comparten un runtime o panel y solo uno puede estar activo. Al activar un miembro, el anfitrión desactiva el hermano activo del mismo grupo antes de montar el nuevo plugin. Si el montaje falla, el anfitrión intenta reactivar el hermano desplazado. Durante la restauración de un proyecto gana el último miembro listado para un grupo, por lo que los archivos antiguos que contengan ambos IDs siguen produciendo un plugin activo determinista.

Los navegadores integrados Catálogos STAC y Planet Open Data usan el grupo stac-catalog-browser. Planet Open Data está preconectado al catálogo STAC público de desastres de Planet; Catálogos STAC comienza con el navegador de catálogos general. Un plugin solo debe usar una cadena de grupo estable cuando sus miembros realmente no puedan coexistir, y aun así debe limpiar controles, listeners y capas en deactivate().

Registrar un plugin

import { PluginManager } from "@mappview/plugins";

const manager = new PluginManager();
manager.register(myPlugin);
manager.activate("my-plugin", appApi);

Añadir un plugin integrado (en este repositorio)

Los plugins integrados se distribuyen con el propio Meratyn. La mayoría de los plugins no necesitan estar integrados — ver Plugins externos para distribuir uno sin hacer fork de Meratyn. Para añadir uno a este repositorio:

  1. Crea un archivo de plugin en packages/plugins/src/plugins/.

    import type { MappViewAppAPI, MappViewPlugin } from "../types";
    
    export const myPlugin: MappViewPlugin = {
      id: "my-plugin",
      name: "My Plugin",
      version: "0.1.0",
      activate: (app: MappViewAppAPI) => {
        app.setBasemap("https://example.com/style.json");
      },
      deactivate: () => {
        /* el mapa base permanece hasta que el usuario lo cambie */
      },
    };

    Los plugins de mapa base deliberadamente dejan el estilo que aplicaron en su sitio al desactivarse — los plugins integrados osm-basemap y carto-light hacen lo mismo, pues revertir el mapa bajo el usuario sería más sorprendente que conservar lo que seleccionó por última vez. Los plugins que añaden controles, listeners o capas deben deshacerlos en deactivate().

  2. Expórtalo desde packages/plugins/src/index.ts.

    export { myPlugin } from "./plugins/my-plugin";
  3. Regístralo en apps/mappview/src/features/plugins/runtime/manager.ts.

    import { myPlugin } from "@mappview/plugins";
    
    manager.registerAll([
      maplibreLayerControlPlugin,
      maplibreGeoAgentPlugin,
      maplibreGeoEditorPlugin,
      myPlugin,
    ]);

Para un plugin de control de MapLibre, añade la dependencia del paquete, luego llama a app.addMapControl(control, "top-left") en activate() y a app.removeMapControl(control) en deactivate(). Si el paquete npm del control incluye su propia hoja de estilos, importa esa hoja en apps/mappview/src/main.tsx, junto a las importaciones existentes de maplibre-gl-*/style.css. Eso es solo para el CSS de una dependencia — cualquier arreglo específico de la app sobre él pertenece a index.css, como se describe en Estilizar controles de terceros abajo.

Los controles integrados de MapLibre como Navigation, Fullscreen, Geolocate, Globe, Terrain, Scale, Attribution y Logo se conmutan desde el menú de Controles de la app de escritorio, que también abre Search, un panel de búsqueda de lugares independiente respaldado por el plugin Components. Conserva los controles específicos de proyecto, como Layer Control y Components, en el menú de Plugins cuando usan la API de plugins o necesitan comportamiento del ciclo de vida de plugins.

El plugin Components envuelve los controles de maplibre-gl-components y cablea sus eventos de capa al almacén de Meratyn. Proporciona atajos de Añadir datos para FlatGeobuf, PMTiles, Zarr, LiDAR y Gaussian splats, mientras las capas ráster COG y GeoTIFF también pueden añadirse por el diálogo estándar de Añadir capa ráster.

Estilizar controles de terceros

Si un control de MapLibre de terceros necesita arreglos de estilo específicos de la app, añade sobrescrituras con alcance en apps/mappview/src/index.css en lugar de editar archivos en node_modules. Limita los selectores a la clase del control del plugin. Por ejemplo, los botones de la barra de GeoEditor necesitan una anulación local porque el CSS por defecto de los botones de control de MapLibre puede sobrescribir su centrado flex:

.geo-editor-control .geo-editor-tool-button {
  align-items: center;
  display: flex !important;
  justify-content: center;
  line-height: 0;
  padding: 0;
}

.geo-editor-control .geo-editor-tool-button svg {
  display: block;
  flex: 0 0 auto;
  margin: 0;
}

Ejecuta bun run build tras el cambio; si tocaste páginas bajo docs/, construye la app de documentación con bun run --filter docs build.

Plugins integrados

mappview-hosted-layers, mostrado a los usuarios como Online Layers, se registra como un integrado activo por defecto. En un proyecto nuevo el plugin se activa con la API real de la app y su panel se restaura en estado colapsado, de modo que el catálogo de capas está disponible sin tomar el mapa. El estado explícito de plugins de un proyecto guardado sigue ganando.

IDDescripción
osm-basemapEstilo Liberty de OpenFreeMap
carto-lightEstilo CARTO Positron GL
maplibre-gl-basemap-controlAñade un selector de mapas base de MapLibre
maplibre-gl-componentsAñade la cuadrícula de controles de MapLibre Components y paneles para FlatGeobuf, COG, PMTiles, Zarr, LiDAR y Gaussian splats
maplibre-gl-geo-editorAñade controles de dibujo GeoEditor
maplibre-gl-geoagentAñade controles del asistente de mapas GeoAgent
maplibre-gl-geolensAñade el catálogo GeoLens y el flujo de edición de features
mappview-hosted-layersPanel Online Layers predeterminado; resuelve IDs estables de capas alojadas y añade una representación de mapa adecuada
maplibre-gl-usgs-lidarAñade controles LiDAR
maplibre-gl-streetviewAñade controles de vista de calle
maplibre-gl-swipeAñade controles de swipe de mapa

Plugin de ejemplo

import type { MappViewAppAPI, MappViewPlugin } from "@mappview/plugins";

export const myPlugin: MappViewPlugin = {
  id: "my-plugin",
  name: "My Plugin",
  version: "0.1.0",
  activate(app: MappViewAppAPI) {
    app.setBasemap("https://example.com/style.json");
  },
  deactivate() {
    // Limpia controles, listeners y estado del plugin aquí.
  },
};

Los plugins de controles de mapa pueden exponer opcionalmente getMapControlPosition() y setMapControlPosition() para que el menú de Plugins del escritorio pueda mover el control entre las esquinas del mapa. Los plugins conscientes de la posición deben eliminar y recrear o re-añadir su control cuando la posición cambie.

Los plugins con ajustes de ejecución serializables pueden exponer getProjectState() y applyProjectState() para que Meratyn pueda guardar y restaurar esos ajustes en el archivo de proyecto. Un envoltorio debe usar estos hooks para adaptar APIs de controles externos como getState() sin exigir que cada paquete externo implemente una interfaz específica de Meratyn.

Los plugins que renderizan con deck.gl deben llamar a app.getDeckGL() (devuelve una promesa) para obtener los módulos deck.gl propios de Meratyn — core, layers, geoLayers, meshLayers y mapbox (usa mapbox.MapboxOverlay para el renderizado intercalado con MapLibre). Renderiza sobre la instancia única de deck.gl del anfitrión en lugar de empaquetar una segunda copia: deck.gl y luma.gl lanzan error ante un desfase de versiones y comparten singletons globales, así que una copia empaquetada falla al renderizar. Llámalo con encadenamiento opcional (app.getDeckGL?.()) pues una variante del anfitrión puede no incluir deck.gl.

Los plugins también pueden declarar parámetros de consulta de URL y manejarlos cuando Meratyn se abre. Los manejadores de parámetros de URL se ejecutan tras que el mapa esté listo, los plugins externos cargados y el estado de plugins del proyecto restaurado. Meratyn llama a los manejadores de los plugins cuyos nombres de parámetros declarados están presentes en la URL, y suprime el manejo repetido del mismo contexto de URL para el mismo plugin. Si un plugin que coincide está registrado (instalado) pero inactivo, Meratyn primero intenta activarlo vía PluginManager.activate; el manejador se ejecuta solo si la activación tiene éxito (un activate() que devuelve false o lanza deja el plugin inactivo y omite el despacho). Los nombres de parámetros distinguen mayúsculas, como los parámetros de consulta de URL: declarar exampleGeoJson no coincidirá con ?ExampleGeoJson=….

import type { MappViewAppAPI, MappViewPlugin } from "@mappview/plugins";

export const plugin: MappViewPlugin = {
  id: "example-url-loader",
  name: "Example URL Loader",
  version: "0.1.0",
  urlParameterNames: ["exampleGeoJson"],
  activate() {
    // Configura controles o estado del plugin aquí.
  },
  deactivate() {
    // Limpia controles, listeners y estado del plugin aquí.
  },
  async handleUrlParameters(app: MappViewAppAPI, params: URLSearchParams) {
    for (const dataUrl of params.getAll("exampleGeoJson")) {
      // Los valores de los parámetros de URL están controlados por un atacante:
      // solo descarga URLs HTTPS y verifica que el origen sea de confianza antes
      // de cargar. Analizar el valor rechaza URLs malformadas, y la comprobación
      // de protocolo bloquea esquemas no HTTPS (file://, data:, http://);
      // ninguno protege contra SSRF a direcciones de loopback o de red privada.
      let parsedUrl: URL;
      try {
        parsedUrl = new URL(dataUrl);
      } catch {
        continue;
      }
      if (parsedUrl.protocol !== "https:") continue;
      const response = await fetch(parsedUrl.href);
      if (!response.ok) continue;
      app.addGeoJsonLayer("Example URL layer", await response.json(), dataUrl);
    }
  },
};

Valida los valores de los parámetros de URL antes de actuar sobre ellos. Cualquiera puede fabricar un enlace hacia Meratyn, así que los manejadores que descargan un valor de parámetro deben rechazar esquemas inesperados (file://, data:, http:// plano) y solo contactar orígenes de confianza.

Por ejemplo:

http://localhost:5173/?url=https://example.com/project.mappview.json&exampleGeoJson=https://example.com/data.geojson

Un parámetro de URL activa solo un plugin ya registrado (instalado) que lo posea; nunca carga un plugin desde la URL. Para plugins externos, incluye la URL del manifiesto del plugin en el estado plugins del proyecto (para que el plugin esté registrado) antes de depender de su manejador de URL — el parámetro coincidente entonces lo activa y despacha aunque no esté en el conjunto activo.

Capas de ráster y teselas

addGeoJsonLayer registra datos vectoriales como una capa nativa. Para datos de ráster y teselas hay tres ayudantes equivalentes — addTileLayer (XYZ), addWmtsLayer (WMTS) y addWmsLayer (WMS). Cada uno devuelve el id de la nueva capa, y la capa aparece en el panel de Capas con soporte completo de opacidad, reordenamiento y estilizado, y persiste con el proyecto, de modo que un plugin ya no tiene que llamar a getMap().addSource()/addLayer() directamente (lo que deja la capa invisible para el almacén de capas de Meratyn).

export interface MappViewTileLayerOptions {
  tileSize?: number; // 256 por defecto
  attribution?: string;
  bounds?: [number, number, number, number]; // [oeste, sur, este, norte] en WGS84
  minzoom?: number;
  maxzoom?: number;
  scheme?: "xyz" | "tms"; // "xyz" por defecto
  visible?: boolean; // true por defecto
  opacity?: number; // 1 por defecto
  beforeLayerId?: string; // inserta bajo esta capa
}

export interface MappViewWmsLayerOptions extends MappViewTileLayerOptions {
  url: string; // endpoint WMS GetMap
  layers: string; // nombre(s) de capa separados por comas
  styles?: string;
  format?: string; // "image/png" por defecto
  transparent?: boolean; // true por defecto
  version?: string; // "1.1.1" (por defecto) o "1.3.0" (envía CRS en vez de SRS)
}

export interface MappViewCogLayerOptions {
  bands?: string; // "1" (banda única) o "1,2,3" (RGB)
  colormap?: string; // mapa de color con nombre para un COG de banda única, p. ej. "terrain"
  rescaleMin?: number;
  rescaleMax?: number;
  nodata?: number; // valor de píxel renderizado transparente
  opacity?: number; // 1 por defecto
  beforeLayerId?: string;
}
// Teselas XYZ (p. ej. un endpoint de imaginería, topo o sombreado de MDE).
app.addTileLayer?.("LINZ Aerial Imagery", "https://tiles.example.nz/aerial/{z}/{x}/{y}.png", {
  attribution: "Sourced from LINZ. CC BY 4.0",
  maxzoom: 22,
  bounds: [166.0, -47.5, 178.6, -34.0],
});

// Plantilla de URL de teselas WMTS.
app.addWmtsLayer?.("LINZ Topo50", "https://tiles.example.nz/topo50/{z}/{x}/{y}.png");

// WMS — pasa los parámetros de la petición; el anfitrión construye la URL de teselas GetMap.
app.addWmsLayer?.("LINZ Coverage", {
  url: "https://wms.example.nz/wms",
  layers: "coverage",
  transparent: true,
});

// COG — lee el GeoTIFF directamente (del lado cliente), con controles ráster.
const cogId = await app.addCogLayer?.("LINZ DEM", "https://cog.example.nz/dem.tif", {
  colormap: "terrain",
  nodata: -9999,
});

addTileLayer/addWmtsLayer/addWmsLayer esperan teselas pre-renderizadas (p. ej. un COG ya servido por un tiler como titilar como endpoint XYZ). addCogLayer es distinto: carga el GeoTIFF mismo y lo renderiza del lado cliente, exponiendo selección de banda, rescale, mapa de color y nodata en el panel de ráster. Es asíncrono (descarga la cabecera del archivo), así que devuelve Promise<string> y rechaza si el COG no puede leerse.

Los ayudantes están tipados opcionales por compatibilidad futura con variantes del anfitrión, así que llámalos con encadenamiento opcional (app.addTileLayer?.(...)).

Nota de escritorio (Tauri): la app de escritorio aplica una Content Security Policy que restringe qué hosts de teselas puede alcanzar el WebView. Si tu plugin registra teselas de un host que no está ya en la lista permitida de la CSP de Meratyn, la capa se crea pero sus teselas fallan silenciosamente al cargar. Para plugins integrados (de primera parte), añade el host a connect-src / img-src en apps/mappview/src-tauri/tauri.conf.json; los plugins externos solo pueden alcanzar hosts ya permitidos. El build web no se ve afectado.

Capas Zarr

addZarrLayer renderiza un almacén Zarr (Zarr v2/v3, Icechunk por HTTP, NetCDF en nube respaldado por kerchunk) por medio de la instancia propia de @carbonplan/zarr-layer de Meratyn y refleja el resultado en el panel de Capas. Es el equivalente Zarr de addCogLayer.

No empaquetes @carbonplan/zarr-layer en un plugin: una segunda copia incluye una carga WASM de numcodecs duplicada, y añadir la capa del renderizador por tu cuenta con getMap().addLayer() produce una capa personalizada de MapLibre, que no tiene propiedades de pintado para que el panel de Estilo las dirija.

export interface MappViewZarrLayerOptions {
  variable: string; // array a renderizar (obligatorio)
  selector?: Record<string, number | string>; // dimensiones no espaciales, p. ej. { time: 0 }
  clim?: [number, number];
  colormap?: string | string[]; // rampa con nombre ("viridis") o paradas hex
  opacity?: number;
  zarrVersion?: 2 | 3;
  crs?: string; // p. ej. "EPSG:32633"
  proj4?: string; // para un CRS sin equivalente integrado
  bounds?: [number, number, number, number]; // [xMin, yMin, xMax, yMax] en el CRS del almacén
  spatialDimensions?: { lat?: string; lon?: string };
  headers?: Record<string, string>; // almacenes autenticados
  beforeLayerId?: string;
}
// Una cuadrícula nacional proyectada: crs/proj4 se reenvían al renderizador, que
// reproyecta en la GPU. Sin ellos el almacén se lee como WGS84 y aterriza en el
// lugar equivocado.
const layerId = await app.addZarrLayer?.("seNorge tmax", "https://example.no/senorge.zarr", {
  variable: "tmax",
  selector: { time: 0 },
  clim: [-30, 30],
  colormap: "viridis",
  crs: "EPSG:32633",
});

// Dirige un deslizador de tiempo sin reconstruir la capa: el renderizador conserva
// los chunks ya descargados. (`addZarrLayer` es opcional, así que protege el id.)
if (layerId) {
  await app.setZarrLayerSelector?.(layerId, { time: 12 });

  // Lee el píxel clicado sin cambiar el corte en pantalla. Los puntos devuelven
  // datos de clic-a-valor; las geometrías Polygon/MultiPolygon devuelven valores
  // de región.
  const result = await app.queryZarrLayer?.(
    layerId,
    { type: "Point", coordinates: [-105.27, 40.02] },
    { time: 11 },
    { includeSpatialCoordinates: true },
  );
}

addZarrLayer es headless: no abre el panel Zarr (el usuario aún puede abrirlo desde Añadir datos → Capa Zarr para ajustar el mapa de color y los límites de color). Resuelve con el id de la nueva capa una vez registrada, y rechaza cuando falta variable o el almacén no puede leerse. La capa soporta visibilidad, opacidad, orden y eliminación desde el panel de Capas como cualquier otra.

selector elige un corte por valor de coordenada, no por índice: en un eje month de 1-12, diciembre es { month: 12 }. El campo Selector (JSON) del panel significa lo mismo, y editarlo ahora re-corta las capas ya en el mapa.

El panel también puede abrir un almacén desde una carpeta en disco, vía un botón Explorar carpeta junto a la URL de Zarr. Aparece en la app de escritorio y en navegadores con la File System Access API (Chromium), pues leer una carpeta necesita una API de sistema de archivos que el plugin no puede suministrar; en otros lugares el panel no cambia. Un cubo local se vincula al Time Slider como uno remoto — sus units CF se leen de la carpeta, porque su URL registrada es un identificador en lugar de una dirección.

queryZarrLayer delega en la misma instancia viva del renderizador, así que los plugins no necesitan su propio lector zarrita ni lógica de reproyección. Las geometrías de consulta son WGS84; los arrays de coordenadas devueltos usan el CRS fuente del almacén. Resuelve a null para un id de capa desconocido, devuelve arrays de valores vacíos fuera de la cuadrícula o antes de que los primeros chunks carguen, y rechaza una petición abortada.

Dirigir la dimensión temporal de una capa desde el Time Slider

El Time Slider entiende tres clases de capa temporal. Dos son integradas: una capa vectorial filtrada por una propiedad de marca de tiempo, y una serie temporal ráster de fuentes fechadas entre las que el dock avanza. La tercera es para una capa que es un almacén con el tiempo como dimensión interna — un cubo de datos Zarr, o la capa propia de frames de un plugin — donde la línea de tiempo elige un corte en lugar de una fuente.

Esa tercera clase se expresa como un adaptador temporal:

export interface TemporalLayerAdapter {
  getTimeValues: () => ReadonlyArray<Date | number | string>; // la coordenada temporal, en orden de índice
  setTime: (date: Date) => void | Promise<void>; // aplica una fecha a la capa
  dimension?: string; // el nombre del eje, "time" por defecto
}

El deslizador posee el ajuste (snapping): lee getTimeValues(), encuentra el índice más cercano a la fecha actual, y llama a setTime() con la fecha del propio corte, saltándose un tic que aterriza en el corte ya mostrado. Así, un cubo diario bajo una línea de tiempo que avanza por mes cuesta una descarga de chunk por índice cambiado, no una por tic. Las llamadas se estrangulan mientras el tirador se arrastra, con el borde final siempre aplicado. Las formas de valor aceptadas son Dates, segundos/milisegundos de época, años de calendario simples y cadenas de fecha.

Una capa Zarr añadida con addZarrLayer registra su propio adaptador para su eje time (decodificando units CF como "days since 1970-01-01" de los metadatos del almacén), así que no necesita ninguna llamada aquí. Usa registerTemporalLayer para una capa personalizada que renderices tú:

const times = frames.map((frame) => frame.timestamp); // Date[] | number[] | string[]

const detach = app.registerTemporalLayer?.(
  layerId,
  {
    dimension: "time",
    getTimeValues: () => times,
    setTime: (date) => showFrame(nearestFrame(date)),
  },
  { bind: true },
);

Registrarla hace la capa vinculable: el menú de fila del panel de Capas gana “Vincular dimensión de tiempo al Time Slider”. Pasar { bind: true } la vincula inmediatamente y abre el dock, que suele ser lo que un plugin que acaba de cargar un cubo quiere. La vinculación se persiste en layer.metadata.timeBinding (como { kind: "selector", dimension, min, max, granularity }) para que sobreviva a un viaje de ida y vuelta del proyecto; los valores de tiempo en sí no se persisten, porque pertenecen al almacén de datos — re-registra el adaptador cuando la capa se recree y la línea de tiempo lo retoma.

Llama a la función devuelta, o a app.unregisterTemporalLayer?.(layerId), para soltar el adaptador. Eliminar la capa también lo hace.

Un cubo vinculado comparte la línea de tiempo con cualquier vinculación vectorial y superposiciones fechadas: la pista abarca la unión de sus extensiones, y el dataset más amplio fija la granularidad de avance.

Cerrar el dock de nuevo. Un dock que una vinculación abrió se cierra solo una vez que la última capa temporal desaparece, así que nunca permanece sobre un mapa sin línea de tiempo. Eliminar la capa vinculada basta; si tu plugin conserva la capa y solo suelta la vinculación, limpia layer.metadata.timeBinding además de desregistrar el adaptador. Un dock que el usuario abrió desde el menú de Plugins se deja tranquilo, y también lo es uno que aún tiene fuentes ráster propias o una superposición <TimeSpan> KML que dirigir.

Activar y desactivar otros plugins

await app.activatePlugin?.("maplibre-gl-time-slider");
app.deactivatePlugin?.("maplibre-gl-time-slider");

activatePlugin toma un segundo argumento opcional, un parche de estado de proyecto aplicado una vez que el objetivo está activo; resuelve false cuando el plugin no está disponible, se niega a activarse o rechaza el estado. deactivatePlugin es su contraparte y devuelve true cuando el plugin terminó inactivo.

Ninguno puede apuntar al plugin llamante: activatePlugin sobre ti mismo carece de sentido, y desactivarte a ti mismo desmontaría el código aún en la pila. Ambos devuelven false en ese caso.

Capas personalizadas (WebGL) y propiedad del pintado

registerExternalNativeLayer refleja una capa que el plugin añadió al mapa por su cuenta en el almacén de capas de Meratyn, de modo que aparece en el panel de Capas y persiste con el proyecto:

export interface MappViewExternalNativeLayerRegistration {
  id: string;
  name: string;
  type?: MappViewLayer["type"]; // el tipo integrado más cercano, p. ej. "raster"
  nativeLayerIds: string[]; // los id(s) de capa de MapLibre que añadiste
  source?: Record<string, unknown>;
  sourceIds?: string[];
  sourceId?: string;
  geojson?: FeatureCollection; // para capas vectoriales
  beforeId?: string;
  opacity?: number;
  style?: Partial<LayerStyle>;
  metadata?: Record<string, unknown>;
  sourcePath?: string;
  paintMode?: "mappview" | "plugin"; // ver abajo
  paintBridge?: {
    // ver abajo
    setOpacity?: (opacity: number) => void;
    setVisibility?: (visible: boolean) => void;
  };
}

Un plugin que renderiza con su propio CustomLayerInterface de MapLibre — una capa WebGL que dibuja sus propios píxeles — la registra de la misma forma:

const layer = new MyWebGLLayer({ id: "my-layer" });
app.getMap?.()?.addLayer(layer);

app.registerExternalNativeLayer?.({
  id: "my-layer",
  name: "My WebGL layer",
  type: "raster",
  nativeLayerIds: ["my-layer"],
  // La capa no tiene propiedades de pintado de MapLibre, así que Meratyn no debe
  // ofrecer editores de pintado que no pueden alcanzarla.
  paintMode: "plugin",
  // Opcional: mantiene vivos los deslizadores de Opacidad reenviándolos a la capa.
  paintBridge: {
    setOpacity: (opacity) => layer.setOpacity(opacity),
    setVisibility: (visible) => layer.setActive(visible),
  },
});
  • paintMode: "plugin" indica al panel de Estilo que el plugin pinta la capa. Entonces muestra solo los controles que realmente aplican — insertar-bajo, rango de zoom y (con un puente) opacidad — en lugar de los deslizadores de brillo/saturación/contraste/matiz del ráster que silenciosamente no hacen nada. La visibilidad, el reordenamiento y la eliminación siguen funcionando desde el panel de Capas; MapLibre honra visibility y el rango de zoom en una capa personalizada.
  • paintBridge suministra los setters que Meratyn llama cuando el usuario cambia opacidad o visibilidad. Suministrar setOpacity mantiene el deslizador de Opacidad en los paneles de Capas y Estilo; omitirlo oculta el deslizador en lugar de dejar uno inerte. Suministrar un puente implica paintMode: "plugin".
  • Los setters se llaman solo cuando el valor cambia, no en cada sincronización de capa, y se guardan fuera del registro de capa (las funciones no pueden serializarse en un archivo de proyecto). Re-registra la capa tras una recarga de proyecto para restaurar el puente, y llama a unregisterExternalNativeLayer(id) desde deactivate.

Para Zarr específicamente, prefiere addZarrLayer arriba: el renderizador del anfitrión ya se integra con los paneles, así que no se necesita capa personalizada ni puente.

Paneles laterales derechos

Un plugin puede registrar un panel nativo de la barra lateral derecha que se acopla junto al panel de Estilo integrado y se comporta como una parte de primera clase del espacio de trabajo, en lugar de emular uno con una superposición fija. El anfitrión renderiza el chrome del panel (un encabezado con botones de colapsar y cerrar, un raíl colapsable y un tirador de redimensión); el plugin posee solo el cuerpo.

export const myPlugin: MappViewPlugin = {
  id: "my-workbench",
  name: "Workbench",
  version: "0.1.0",
  activate(app) {
    // Registra una vez, luego abre. registerRightPanel devuelve una fn de desregistro.
    this._unregister = app.registerRightPanel?.({
      id: "my-workbench",
      title: "Workbench",
      defaultWidth: 360,
      render(container) {
        const button = document.createElement("button");
        button.textContent = "Run analysis";
        container.appendChild(button);
        // Limpieza opcional, ejecutada cuando el panel se cierra o se desregistra.
        return () => button.remove();
      },
      onOpen() {},
      onCollapse() {},
      onClose() {},
    });
    app.openRightPanel?.("my-workbench");
  },
  deactivate(app) {
    app.closeRightPanel?.("my-workbench");
    this._unregister?.();
  },
} as MappViewPlugin & { _unregister?: () => void };

Notas:

  • render(container) se llama una vez con un elemento vacío que llenas con DOM plano. Un plugin externo no puede compartir la instancia de React de Meratyn, así que el contrato es DOM, no un nodo React. El contenedor permanece montado a través de colapsos, así que cualquier estado en tu DOM persiste; la limpieza devuelta se ejecuta al cerrar o desregistrar.
  • Solo un panel de plugin está activo a la vez. El panel integrado en el lado donde el panel del plugin está acoplado (Capas a la izquierda, Estilo a la derecha) colapsa a su raíl mientras el panel del plugin está expandido junto a él, y se restaura cuando el panel del plugin se mueve al otro lado, colapsa a su propio raíl o se cierra.
  • openRightPanel(id) hace el panel activo y expandido (también expande un panel colapsado); collapseRightPanel(id) lo colapsa a su raíl sin cerrarlo; closeRightPanel(id) libera el espacio de trabajo; getActiveRightPanel() devuelve el id activo o null.
  • El panel es un hermano flex del mapa, así que abrirlo encoge la vista del mapa (el mapa sigue llenando el espacio restante); no se necesita padding manual del mapa.
  • Posición de acople: un panel se acopla en una de cuatro posiciones (izquierda a derecha): left-of-layers, right-of-layers (entre Capas y el mapa), left-of-style (entre el mapa y Estilo) o right-of-style (el predeterminado). Fija dock en el registro para elegir la posición inicial. El usuario mueve el panel entre posiciones en tiempo de ejecución con los dos botones de mover del encabezado del panel (deshabilitados en los extremos), y un plugin puede fijarla directamente con app.setActiveRightPanelDock?.(...). La posición vuelve al dock declarado del panel cuando se cierra o se abre otro panel.
  • Modos de raíl compartido (replace-style / replace-layers): dos acoples no posicionales para plugins estilo banco de trabajo que quieren sentirse como un espacio de trabajo de barra lateral de primera clase en lugar de un segundo raíl junto a Estilo (derecha) o Capas (izquierda). Regístrate con dock: "replace-style" (o "replace-layers") y el anfitrión muestra un único raíl en ese borde listando tu panel y el panel integrado; seleccionar uno lo expande mientras el otro queda como entrada de raíl. Los dos son mutuamente excluyentes, así que el usuario nunca ve dos raíles adyacentes. El panel integrado arranca colapsado para que el banco de trabajo se lea como el espacio de trabajo activo, y el usuario puede expandirlo (lo que colapsa el banco de trabajo) en cualquier momento. Todo lo demás (chrome, redimensión, colapso, cierre, hooks de ciclo de vida) no cambia.
  • Cambiar de modo en tiempo de ejecución: los modos no son elecciones exclusivas fijadas en el registro. En un acople posicional el encabezado del panel muestra un botón de fusión que une el raíl compartido en su lado actual — un panel del lado de las capas (left-of-layers/right-of-layers) se une al raíl de Capas, uno del lado de Estilo al raíl de Estilo. En un raíl compartido muestra un botón de separación que saca el panel a un panel posicional movible en el mismo lado (right-of-layers / right-of-style), donde vuelven los botones de mover izquierda/derecha. Un plugin puede dirigir el mismo cambio con app.setActiveRightPanelDock?.("replace-style" | "replace-layers" | "right-of-style" | ...). Los raíles compartidos no forman parte de la secuencia de pasos izquierda/derecha (las flechas solo recorren los cuatro acoples posicionales); la fusión/separación es el camino de entrada y salida.
  • Estos métodos están tipados opcionales por compatibilidad futura con variantes del anfitrión sin barra lateral derecha, así que llámalos con encadenamiento opcional (app.registerRightPanel?.(...)).

Menús de la barra

Un plugin puede añadir su propio botón de menú de nivel superior a la cabecera de Meratyn (junto a Project / Edit / View / Plugins), con submenús anidados y elementos de acción. Registra el menú en activate y desregístralo en deactivate:

const unregister = app.registerToolbarMenu?.({
  id: "my-plugin-menu",
  label: "Workbench",
  items: [
    { id: "open", label: "Open workbench", onSelect: () => app.openRightPanel?.("my-workbench") },
    {
      type: "submenu",
      id: "tools",
      label: "Tools",
      items: [{ id: "qa", label: "Data QA", onSelect: () => app.openFloatingPanel?.("my-qa") }],
    },
    { type: "separator" },
    { id: "about", label: "About", disabled: false, onSelect: () => {} },
  ],
});

Cada elemento es una acción (onSelect, el predeterminado cuando se omite type), un submenu (items anidados) o un separador. Los elementos típicamente abren un panel derecho o un panel flotante, pero onSelect puede ejecutar cualquier cosa. Re-registrar el mismo id reemplaza el menú, así que puedes reconstruirlo a medida que cambia el estado de tu plugin.

Los menús de plugins externos (cargados desde un zip, una URL de manifiesto o un drop-in incluido en el build) se renderizan al final de la cabecera, tras el menú de Help, de modo que los menús de terceros queden juntos después de los menús integrados. Los menús de plugins integrados se renderizan junto a los menús integrados. El anfitrión decide la ubicación a partir del plugin propietario del menú, así que no necesitas hacer nada especial.

Paneles flotantes

Un panel flotante es una tarjeta arrastrable y cerrable que el anfitrión superpone en la esquina superior izquierda del mapa. A diferencia de un panel derecho acoplable (un solo panel activo acoplado en una posición fija), varios paneles flotantes pueden estar abiertos a la vez y no encogen el mapa. Fija allowMainView: true para una superficie visual inmersiva con la que los usuarios deben poder alternar con el mapa. El anfitrión añade un conmutador de barra de título de solo iconos que intercambia las dos superficies: el panel llena el área de visualización mientras el mapa vivo toma el marco exacto de ese panel, y volver a cambiar invierte el intercambio. La misma tarjeta y el conmutador permanecen en su sitio durante todo el proceso, y el plugin montado preserva su estado. El contrato de render es el mismo render(container) de DOM plano que los paneles derechos.

const unregister = app.registerFloatingPanel?.({
  id: "my-qa",
  title: "Data QA",
  defaultWidth: 300,
  allowMainView: true,
  render(container) {
    container.textContent = "Rendered by the plugin via registerFloatingPanel().";
    return () => {
      // limpieza opcional, ejecutada al cerrar/desregistrar
    };
  },
});

app.openFloatingPanel?.("my-qa"); // abre (o trae al frente)
app.closeFloatingPanel?.("my-qa"); // cierra
app.getOpenFloatingPanels?.(); // -> string[] de ids abiertos, en orden de apilamiento

Usa un panel derecho para un espacio de trabajo principal y persistente, y un panel flotante para una herramienta o tablero auxiliar que el usuario posiciona sobre el mapa. Como con las otras superficies, llama a estos métodos con encadenamiento opcional pues están tipados opcionales.

Plugins externos

Usa los ejemplos de este repositorio como punto de partida para el desarrollo de plugins externos. Un plugin necesita un manifiesto plugin.json, un punto de entrada de plugin de Meratyn y un zip que coincida con la disposición que Meratyn Desktop espera.

Meratyn Desktop carga los plugins externos del directorio plugins/ de los datos de la app al arrancar. Los plugins externos son código confiable y pueden instalarse como:

  • Un archivo .zip con un plugin.json en la raíz.
  • Un directorio desempaquetado con un plugin.json en la raíz.
  • Una URL de manifiesto plugin.json HTTPS.

La forma más rápida de instalar un .zip es Administrar plugins > Ajustes > Instalar desde archivo: elige un archivo de plugin empaquetado y Meratyn lo valida (analizando plugin.json, aplicando las reglas del manifiesto y comprobando que la entrada/estilo estén presentes y dentro del límite de tamaño) antes de instalarlo. El plugin carga inmediatamente y persiste; reinstalar el mismo id reemplaza la copia anterior y recarga la versión actualizada. La persistencia difiere por build:

  • Escritorio copia el archivo al directorio plugins/ de los datos de la app como <plugin-id>.zip, donde el escaneo de arranque lo recarga.
  • Web desempaqueta el archivo en el navegador y guarda el bundle en IndexedDB, reproduciéndolo en la siguiente visita. Los plugins instalados por web se listan bajo Instalar desde archivo con un control de desinstalación (las copias de escritorio viven en disco y se administran allí).

La sección de Plugins de los Ajustes también puede añadir directorios de desarrollo locales fuera de la carpeta de datos de la app. Cada directorio configurado puede contener zips de plugins, carpetas de bundles de plugins desempaquetados, o ser un único bundle de plugin desempaquetado. Los directorios de desarrollo configurados se escanean antes que el directorio plugins/ de los datos de la app, así que una copia de desarrollo puede sobrescribir un plugin externo instalado con el mismo ID. Los plugins integrados aún tienen precedencia sobre todos los plugins externos.

Para la app web, usa URLs de manifiesto o Instalar desde archivo (arriba). URLs de manifiesto: Meratyn descarga el manifiesto, resuelve entry y style relativos a la URL del manifiesto, y luego carga la entrada ESM empaquetada. La carga en el navegador exige HTTPS excepto para localhost y depende de que el host permita CORS. Instalar-desde-archivo desempaqueta el zip subido en el navegador (sin red ni CORS) y lo persiste en IndexedDB. Ambas rutas ejecutan la entrada ESM empaquetada de la misma forma (un import() de blob:, permitido por el script-src del build web), así que los plugins externos siguen siendo código confiable sin importar cómo se instalaron.

Plugins incluidos en el build (horneados)

Para distribuir un plugin externo como parte de Meratyn — cargado automáticamente, sin entrada en Ajustes y sin URL de manifiesto — deja su bundle construido en el directorio público de Vite, una carpeta por id de plugin:

apps/mappview/public/plugins/example-plugin/
  plugin.json
  dist/index.js
  dist/style.css

Este es el mismo contenido que serviría una URL de manifiesto. Un drop-in es todo lo que se requiere — sin ediciones de fuente por plugin. El plugin de Vite bundledPlugins() (apps/mappview/vite-plugins/bundled-plugins.ts) escanea public/plugins/ en el build y al arrancar el dev server, expone las rutas de manifiesto descubiertas por el módulo virtual:bundled-plugins, y apps/mappview/src/features/plugins/runtime/manager.ts los carga por la ruta normal de plugins externos (fetch → import blob → registro). El descubrimiento ocurre en tiempo de build, así que reinicia el dev server o reconstruye tras añadir, actualizar o eliminar una carpeta de plugin.

La misma carpeta sirve ambos builds, web y escritorio: la app de escritorio incluye el frontend idéntico (frontendDist en tauri.conf.json) y lo sirve desde tauri://localhost, que es de mismo origen y está permitido por la CSP de escritorio (connect-src 'self', script-src ... blob:). Las URLs de manifiesto incluidas se inyectan al tiempo de carga en lugar de guardarse en Ajustes, así que un plugin horneado siempre carga y no puede ser eliminado por un usuario; se deduplican por id de plugin contra cualquier plugin de usuario o de proyecto del mismo id.

Los plugins privados deben quedar fuera de git bajo public/plugins/ (ver el .gitignore de esa carpeta) y copiarse en tiempo de build/despliegue (por ejemplo en CI antes de bun run build, o por el script de instalación del propio repositorio del plugin) para que su código quede fuera del historial de Meratyn. El código de descubrimiento es genérico y está versionado; solo la carga útil del plugin se excluye.

El plugin.json de un drop-in incluido puede además fijar "activeByDefault": true para activar el plugin al arrancar, de modo que su control aparezca sin un viaje al menú de Plugins. El estado de plugins guardado sigue ganando: un proyecto cargado (o el estado de plugins persistido del usuario) que lleve activePluginIds sobrescribe el predeterminado. La bandera se honra solo para drop-ins incluidos, pues un desplegador que hornea un plugin en el build es de confianza como un autor integrado; se ignora silenciosamente en los manifiestos instalados en tiempo de ejecución desde URLs o zips.

Si en cambio quieres un plugin compilado en el bundle JS principal (sin plugin.json, sin fetch), regístralo como plugin integrado (ver “Añadir un plugin” en el README del repositorio).

{
  "id": "example-plugin",
  "name": "Example Plugin",
  "version": "0.1.0",
  "entry": "dist/index.js",
  "description": "Optional short description",
  "style": "dist/style.css"
}

El archivo entry debe exportar un MappViewPlugin como exportación por defecto o como exportación con nombre plugin. El id, name y version del plugin exportado deben coincidir con plugin.json. La entrada debe ser un bundle .js o .mjs autocontenido porque este primer cargador no resuelve los imports de módulos relativos dentro del zip.

Las entradas de plugins externos se ejecutan con import(URL.createObjectURL(...)), que es por lo que la CSP de escritorio en tauri.conf.json incluye blob: en script-src. Quitar blob: de script-src rompe la carga de plugins externos. Combinado con 'unsafe-eval', esto significa que código que pueda crear una URL de blob puede ejecutar scripts, lo cual es aceptable porque los plugins externos son archivos locales confiables instalados por el usuario.

Como los plugins se ejecutan como código confiable en el documento anfitrión, pueden leer window.__MAPPVIEW_RUNTIME_ENV__, el mapa de entorno de ejecución. En la app de escritorio este mapa incluye las claves de entorno del sistema operativo del Asistente IA (las variables de proveedor en lista permitida leídas del shell del usuario), no solo los valores tecleados en Ajustes → Variables de entorno. Trata cualquier credencial alcanzable por el entorno de la app como visible para los plugins instalados, e instala solo plugins de confianza.

Las rutas del manifiesto deben ser rutas zip relativas con barras diagonales, sin barra inicial, sin barras invertidas y sin segmentos ... Los plugins externos no pueden fijar activeByDefault en el objeto plugin exportado, y la bandera a nivel de manifiesto solo se honra para drop-ins incluidos (ver “Plugins incluidos” arriba); el estado de proyecto guardado aún puede reactivar un plugin externo por ID después de que el zip se cargue.

El style CSS opcional se inyecta globalmente en el documento anfitrión, no con alcance al plugin. Los autores de plugins son responsables de acotar sus selectores (por ejemplo con un prefijo de clase específico del plugin) para que reglas amplias no reestilicen el resto de la app. El CSS inyectado también puede emitir peticiones de red mediante referencias url() y @import, así que una hoja de estilos de plugin puede cargar fuentes, imágenes u hojas adicionales externas; trata el CSS de plugins con las mismas expectativas de confianza que el código de plugins.

Mantén plugin.json y el punto de entrada del plugin sincronizados para que id, name y version coincidan. Luego copia el zip empaquetado al directorio plugins/ de los datos de la app de escritorio, añade su directorio desempaquetado en Ajustes > Plugins para desarrollo local, u hospeda el directorio y añade su URL de plugin.json.

Mercado de plugins

La entrada Administrar Plugins del menú Ajustes abre un diálogo independiente (modelado según el gestor de plugins de QGIS) con las secciones Todos, Instalados, No instalados, Actualizables y Ajustes. Las primeras cuatro listan plugins de un registro curado para que los usuarios los instalen, actualicen y desinstalen sin introducir URLs de manifiesto a mano; la sección de Ajustes instala un plugin desde un .zip local y administra directorios de plugins locales adicionales y URLs de manifiesto manuales. Las acciones se aplican inmediatamente (instalar/desinstalar/actualizar son en vivo; desinstalar pide confirmación). Es una capa delgada sobre el cargador de URLs de manifiesto de arriba: instalar una entrada registra su URL de manifiesto en la lista de URLs de manifiesto de plugins, y el cargador existente la descarga y registra. No introduce una nueva ruta de confianza.

El registro es JSON, descargado de VITE_MAPPVIEW_PLUGIN_REGISTRY_URL. El predeterminado es el origen de plugins del despliegue (registry.json en plugins.mappview.mapptech.com.co en producción), generado por scripts/build/trusted-plugins.ts. Es un array, o un objeto con un array plugins, de entradas:

{
  "version": 1,
  "plugins": [
    {
      "id": "example-plugin",
      "name": "Example Plugin",
      "version": "1.0.0",
      "description": "Optional short description",
      "author": "Example Author",
      "homepage": "https://github.com/example/example-plugin",
      "manifestUrl": "https://example.com/example-plugin/plugin.json",
      "categories": ["Example"],
      "minMappViewVersion": "1.0.0"
    }
  ]
}

id, name, version y manifestUrl son obligatorios; el resto es opcional. Un manifestUrl relativo se resuelve contra la ubicación del registro, así que un plugin hospedado junto al registro (p. ej. sample/plugin.json) puede listarse con una ruta relativa. minMappViewVersion condiciona la instalación contra la versión de la app en ejecución. Cura el registro y hospeda los bundles de plugins en infraestructura propiedad de Meratyn o del operador del despliegue.

Desinstalar pide confirmación, luego desregistra el plugin en tiempo de ejecución (desactivando cualquier control de mapa activo) para que el menú de Plugins se actualice sin recarga. Cuando una entrada del registro anuncia una version más nueva que la del plugin cargado, el mercado muestra una acción de Actualizar que vuelve a descargar la URL del manifiesto y re-registra la versión publicada en su sitio; la nueva versión se descarga y valida antes de eliminar la antigua, así que una actualización fallida deja el plugin instalado intacto.

Plugins externos de confianza del despliegue

VITE_MAPPVIEW_TRUSTED_PLUGIN_REGISTRY_URL apunta a un segundo documento de registro independiente. Cada URL de manifiesto exacta devuelta por este registro puede cargar sin un aviso de confianza de proyecto. La app descarga este documento al arrancar, así que sus entradas y versiones de plugins pueden cambiar sin reconstruir ni redesplegar Meratyn; solo cambiar la propia URL del registro exige un nuevo build de la app.

Este registro es una raíz de confianza de distribución de código, no un mercado. Cualquiera que pueda modificarlo puede dirigir la app hacia nuevo JavaScript que se ejecutará sin consentimiento del usuario. Hospédalo en infraestructura propiedad del despliegue con HTTPS y acceso de escritura fuertemente restringido. Los plugins externos confiables se registran al arrancar pero no se activan por defecto; el estado de proyecto guardado sigue decidiendo si se activan.

Usa URLs de manifiesto versionadas e inmutables. Meratyn fija el digest SHA-256 de cada bundle remoto en el primer uso y bloquea cambios inesperados de contenido en la misma URL. Para lanzar una actualización, publícala bajo una nueva ruta de versión y cambia el manifestUrl de la entrada del registro.

Los artefactos MappStock y Cali pueden generarse sin incrustarlos en la app:

bun run build:trusted-plugins

Esto escribe output/trusted-plugins/registry.json y directorios de plugins versionados. En producción el servicio Compose de plugins los sirve en plugins.mappview.mapptech.com.co; los builds de desarrollo y escritorio hornean una copia en public/plugins/.

Publicar un plugin nuevo en el registro

El documento del registro y sus bundles versionados se generan a partir de directorios de release versionados en el repositorio; nada se sube a mano. El flujo completo:

  1. Compila un bundle autocontenido. El punto de entrada debe exportar el MappViewPlugin (export por defecto o con nombre plugin) como un único archivo .js minificado con todas las dependencias incluidas, porque el registro sirve el archivo exacto. Por ejemplo, con Bun:

    bun build ./src/entry.ts --outfile index.js --minify
  2. Crea un directorio de release inmutable scripts/build/releases/<slug>/<versión>/ con:

    • index.js — el bundle exacto que se servirá.
    • release.json — id, name, version y description del plugin, más sha256: el digest SHA-256 hexadecimal de index.js. La compilación de plugins de confianza se niega a ejecutarse si el digest ya no coincide.

    Nunca reemplaces archivos de un directorio de versión existente; publica un directorio de versión nuevo.

  3. Registra el release en el arreglo releases de scripts/build/trusted-plugins.ts con su slug (el espacio de nombres de la URL), directory, author, current: true para la versión que lista el registro y aliases opcionales (como ["latest"], copiado a plugins/<slug>/<alias>/).

  4. Genera el documento del registro:

    bun run build:trusted-plugins

    Esto escribe output/trusted-plugins/registry.json más output/trusted-plugins/plugins/<slug>/<versión>/ con el bundle y un plugin.json generado. Solo los releases marcados current: true se convierten en entradas del registro, y minMappViewVersion se sella con la versión del repositorio en tiempo de compilación.

  5. Despliega. El servicio Compose de plugins (docker/Dockerfile.plugins) ejecuta el mismo script con MAPPVIEW_PLUGINS_BASE_URL y sirve el resultado en el origen de plugins; el manifestUrl de cada entrada es <base>/plugins/<slug>/<versión>/plugin.json. Los builds de desarrollo y escritorio consumen una copia horneada en public/plugins/ como drop-in. Haz commit del directorio de release y del registro en trusted-plugins.ts juntos.

Recuerda el modelo de confianza de la sección anterior: este registro es una raíz de distribución de código. Las URLs de versión son inmutables y los clientes fijan el digest del bundle en el primer uso; por eso las actualizaciones son directorios de versión nuevos más una regeneración del registro, nunca reemplazos en el mismo lugar.