Escribir plugins
Este tutorial recorre los patrones que hay detrás de los plugins integrados más complejos de Meratyn: cómo encajan el ciclo de vida, el estado, las suscripciones y las superficies de interfaz, y cómo combinarlos para construir algo más grande que un control de mapa. La referencia de la Plugin API lista cada método; esta página explica cómo usarlos juntos. Solo necesita TypeScript y las DevTools del navegador: todo lo que hay aquí funciona en un plugin externo instalado desde un zip o una URL de manifiesto, sin necesidad de hacer fork.
El modelo mental
Un plugin de Meratyn es un pequeño objeto de ciclo de vida que toma prestadas superficies de la aplicación anfitriona:
De este modelo se siguen tres reglas, y casi todos los errores que vemos en plugins rompen alguna:
- Nunca mute directamente el almacén de capas ni la interfaz de nada que gestione Meratyn. Llame a una capacidad (
addTileLayer,registerRightPanel, …) y la anfitriona la aplica. Solo las capas que usted mismo creó congetMap().addLayer()son suyas. activate()ydeactivate()deben ser simétricos. Todo control, listener, panel, nodo DOM e intervalo que adquiera enactivate()se deshace endeactivate(). La anfitriona puede desactivar su plugin cuando el usuario cambia de proyecto, lo desinstala o lo apaga — y reactivarlo después.- El estado tiene dos hogares. El estado efímero de ejecución vive en su módulo. Todo lo que el usuario configuró debe viajar de ida y vuelta por el archivo de proyecto mediante
getProjectState()/applyProjectState(), porque un proyecto guardado restaura su plugin desde cero llamando aactivate()seguido deapplyProjectState().
Las primitivas de un vistazo
Elija cada primitiva por su semántica de propiedad, no por su apariencia:
| Primitiva | Posee | Varias abiertas | Persiste en el proyecto |
|---|---|---|---|
Control de mapa (addMapControl) | Un grupo de botones fijado a una esquina del mapa | sí | solo la posición |
Menú de la barra (registerToolbarMenu) | Un punto de entrada en la cabecera | uno por id | no |
Panel derecho (registerRightPanel) | Un espacio de trabajo principal acoplado junto a Capas/Estilo | uno activo a la vez | presencia restaurada al activar |
Panel flotante (registerFloatingPanel) | Una tarjeta auxiliar sobre el mapa | sí | no |
addGeoJsonLayer / ayudantes de teselas | Una capa de datos visible para toda la app | sí | sí |
registerExternalNativeLayer | Una capa que pinta usted | sí | sí |
getDeckGL() | Renderiza superposiciones GPU en la instancia de la anfitriona | — | no |
Reglas prácticas:
- Espacio de trabajo principal y persistente → panel derecho (las posiciones de acople y los modos de raíl compartido están descritos en la referencia).
- Una herramienta que el usuario abre, arrastra y cierra → panel flotante.
- Un modo permanente conmutable (como el control de comparación swipe) → control de mapa.
- Un punto de entrada que despacha hacia cualquiera de los anteriores → menú de la barra, normalmente registrado junto a un panel para que sus elementos llamen a
app.openRightPanel?.("my-id").
Recorrido: construir un plugin por capas
Construiremos un plugin “quakewatch” que muestra terremotos recientes: una entrada en el menú de la barra, un espacio de trabajo acoplable con un canal en vivo, una capa de datos en el mapa, un filtro por usuario que persiste en el proyecto y un desmontaje correcto de principio a fin. Cada paso introduce un patrón.
1. Esqueleto con ciclo de vida simétrico
import type { MappViewAppAPI, MappViewPlugin } from "@mappview/plugins";
export const quakeWatch: MappViewPlugin = {
id: "quakewatch",
name: "Quake Watch",
version: "0.1.0",
activate(app: MappViewAppAPI) {},
deactivate(app: MappViewAppAPI) {},
};
El primer patrón es pequeño pero se paga de inmediato: mantenga una lista de liberadores, acumúlela en orden de registro y deshágala en orden inverso durante el desmontaje. Esto elimina los clásicos errores de orden de desactivación (usar algo después de haberlo desregistrado).
function createDisposers() {
const fns: Array<() => void> = [];
return {
add(fn: (() => void) | undefined) {
if (fn) fns.push(fn);
return fn;
},
unwind() {
for (let i = fns.length - 1; i >= 0; i--) fns[i]();
fns.length = 0;
},
};
}
Cada suscripción de la anfitriona en la API (onBasemapChange, registerRightPanel, registerToolbarMenu, registerFloatingPanel, registerExternalNativeLayer) devuelve una función de cancelación o tiene su contraparte explícita de desregistro. Encamine todas por esta lista en lugar de mantener variables sueltas por recurso.
No dependa de que
deactivate()reciba el mismo estado que dejó. Tras la desactivación, las variables del módulo pueden conservar referencias a controles eliminados. Límpielas durante el desmontaje, como se muestra abajo, para que un posterioractivate()empiece limpio.
2. Registre la interfaz y sea dueño del DOM
Los paneles le entregan un HTMLElement vacío; los plugins externos no pueden compartir la instancia de React de Meratyn, así que el contrato es DOM plano. Cree nodos y listeners de propiedad declarativa y devuelva una función de limpieza desde render() — la anfitriona la ejecuta cuando el panel se cierra o se desregistra.
const disposers = createDisposers();
// Tabla de cadenas mínima: los plugins no tienen acceso a los catálogos de
// react-i18next de la app, así que resuelva cada cadena visible por el usuario
// a través de esto (ver más abajo).
const LABELS = {
en: { panelTitle: "Quake Watch", openItem: "Open Quake Watch" },
es: { panelTitle: "Vigilancia sísmica", openItem: "Abrir Vigilancia sísmica" },
};
const language = () =>
new URLSearchParams(location.search).get("locale") ?? navigator.language.slice(0, 2);
const t = (key: keyof (typeof LABELS)["en"]) => LABELS[language()]?.[key] ?? LABELS.en[key];
quakeWatch.activate = (app: MappViewAppAPI) => {
const container = document.createElement("div");
container.className = "quakewatch"; // ¡espacio de nombres para todo el estilo!
const list = document.createElement("ul");
list.setAttribute("role", "list");
container.appendChild(list);
// Una limpieza por recurso, siempre capturada.
disposers.add(
app.registerRightPanel?.({
id: "quakewatch",
// Un getter re-localiza en vivo ante cambios de idioma (ver i18n abajo).
title: () => t("panelTitle"),
defaultWidth: 340,
render(el) {
el.appendChild(container);
return () => {
el.replaceChildren(); // elimina nuestro DOM y sus listeners
};
},
onClose: () => persistFeedScroll(container),
}),
);
disposers.add(
app.registerToolbarMenu?.({
id: "quakewatch-menu",
label: "Quake",
items: [
{
id: "open",
label: t("openItem"),
onSelect: () => app.openRightPanel?.("quakewatch"),
},
],
}),
);
};
quakeWatch.deactivate = () => {
disposers.unwind();
};
Como el DOM del panel permanece montado entre colapso/expansión, guarde el estado por panel (posición de desplazamiento, entrada de formulario) en los propios elementos y reserve las variables del plugin para lo que sobrevive a ciclos completos de cierre/apertura.
Si su panel se renderiza incrementalmente (un canal en vivo que añade filas), cubra también el caso en que render() corre dos veces tras un ciclo cerrar-abrir: reconstruya el DOM interior desde un estado fuente de verdad en lugar de mutarlo ad hoc, o reinicie ese estado en la función de limpieza.
Dos reglas más para todo lo que renderice:
- Acote su CSS. Las hojas de estilo de los plugins se inyectan globalmente en el documento anfitrión. Prefije cada selector con una clase única de su plugin (
quakewatch .feed-item {...}); nunca envíe selectores de elemento sueltos comoul { ... }. - Proteja los métodos opcionales. Superficies como los paneles derechos son tipadas opcionales porque algunas variantes de la anfitriona no las tienen: llame con encadenamiento opcional (
app.registerRightPanel?.(...),app.addCogLayer?.(...)etc.) y degrade con elegancia cuando falten, en lugar de lanzar dentro deactivate().
3. Añada datos por la anfitriona, no por el mapa
Para la propia capa de terremotos, nunca llame a getMap().addSource()/addLayer() cuando exista un ayudante que coincida con la forma de sus datos. Los ayudantes nativos dan a la capa plena categoría en la app: visibilidad en el panel de Capas, opacidad, orden y persistencia en el proyecto.
async function loadEarthquakes(app: MappViewAppAPI) {
const response = await fetch("https://example.com/quakes.geojson");
if (!response.ok) throw new Error(`Feed returned ${response.status}`);
const data = await response.json();
// Devuelve el id de la nueva capa; aparece en el panel de Capas y persiste.
return app.addGeoJsonLayer("Earthquakes", data, "https://example.com/quakes.geojson");
}
Elija el ayudante según el tipo de dato: vectores GeoJSON usan addGeoJsonLayer; teselas XYZ/WMTS/WMS pre-renderizadas usan sus ayudantes; rásteres de nube usan await app.addCogLayer?.(...) (GeoTIFF renderizado en el cliente); cubos científicos usan await app.addZarrLayer?.(...).
La válvula de escape es registerExternalNativeLayer, para capas que pinta usted — una capa WebGL, una superposición de canvas, una superficie dirigida por deck.gl dibujada fuera de los ayudantes. Refléjela para que el resto de la app sepa que existe:
const layer = new MyHeatmapLayer({ id: "quakewatch-density" });
app.getMap?.()?.addLayer(layer);
disposers.add(() => app.unregisterExternalNativeLayer?.("quakewatch-density"));
disposers.add(() => app.getMap?.()?.removeLayer("quakewatch-density"));
app.registerExternalNativeLayer?.({
id: "quakewatch-density",
name: "Quake density",
type: "raster",
nativeLayerIds: ["quakewatch-density"],
// La pintamos nosotros, así que ocultamos los deslizadores del panel de
// Estilo que no pueden alcanzarla.
paintMode: "plugin",
// Un puente opcional mantiene vivos los controles de opacidad/visibilidad.
paintBridge: {
setOpacity: (opacity) => layer.setOpacity(opacity),
setVisibility: (visible) => layer.setVisible(visible),
},
});
Tres sutilezas de la referencia que vale la pena repetir aquí, porque muerden primero a los plugins grandes:
- Los setters de
paintBridgeviven fuera del registro serializado de la capa (las funciones no entran en un archivo de proyecto), así que vuelva a registrar la capa tras recargar un proyecto para restaurar el puente. - Con deck.gl, obtenga los módulos vía
await app.getDeckGL?.()y renderice sobre la instancia única de la anfitriona. Empaquetar su propia copia duplica globales de luma.gl y falla al renderizar si las versiones difieren. - Las capas añadidas directamente con
getMap().addLayer()y no reflejadas son invisibles para el almacén de capas: desaparecen del panel de Capas y se pierden al guardar.
4. Persista las elecciones del usuario en el proyecto
Quien configura un filtro de magnitud espera encontrarlo mañana. Exponga los dos hooks de estado de proyecto. Trate el valor entrante como dato no confiable — un proyecto antiguo guardado, un archivo editado a mano u otro plugin pueden haberlo escrito — y normalícelo defensivamente.
interface QuakeWatchState {
minMagnitude: number;
}
// Acepta cualquier cosa que versiones anteriores hayan escrito; retrocede bien.
function normalizeState(value: unknown): QuakeWatchState | undefined {
if (typeof value !== "object" || value === null) return undefined;
const { minMagnitude } = value as { minMagnitude?: unknown };
if (typeof minMagnitude !== "number" || !Number.isFinite(minMagnitude)) return undefined;
return { minMagnitude: Math.min(10, Math.max(0, minMagnitude)) };
}
let state: QuakeWatchState = { minMagnitude: 4.5 };
quakeWatch.getProjectState = () => ({ ...state });
quakeWatch.applyProjectState = (app, saved) => {
const next = normalizeState(saved);
if (!next) return false; // rechaza basura en vez de adoptarla
state = next;
rerenderFilterUI();
return true;
};
Mantenga la carga persistida mínima, JSON serializable: ids y números, no funciones, instancias de Map ni objetos de capa. Si necesita manejos vivos tras una recarga (como un puente de pintura o un adaptador temporal), vuelva a crearlos en activate()/applyProjectState() en lugar de intentar persistirlos. La integración con Time Slider sigue exactamente esta división: los metadatos de vinculación persisten, el adaptador lo re-registra el código.
Si restaurar el estado exige reconstruir la interfaz (el plugin integrado Layer Swipe lo hace para su disposición de panel), el orden robusto es: capture el estado vivo actual, desmonte y remonte desde el estado restaurado. Omita la reconstrucción solo cuando el estado entrante iguale lo ya montado — comparar primero evita un parpadeo visible cuando un proyecto trae valores idénticos a los que corren.
5. Suscríbase a los eventos de la anfitriona
Los plugins que dependen del contexto se suscriben a él explícitamente. Hoy solo hay una suscripción — cambios de mapa base — además de los eventos de MapLibre sobre getMap(); la disciplina es la misma para ambos: guarde la cancelación y encamínela por los liberadores.
// Reconstruya todo lo que cachea estado dependiente del estilo (p. ej. sprites
// horneados, o un control construido con una URL de estilo como el mapa de
// comparación del control swipe). Transporte el estado entre reconstrucciones
// para que el usuario perciba continuidad.
disposers.add(
app.onBasemapChange((styleUrl) => {
const carried = captureLiveState();
rebuildControl(carried);
}),
);
import type { Map as MapLibreMap } from "maplibre-gl";
function watchMapEvents(map: MapLibreMap) {
const onClick = (e: maplibregl.MapMouseEvent) => pickNearestQuake(e.point);
map.on("click", onClick);
disposers.add(() => map.off("click", onClick));
}
Al reaccionar a flujos rápidos — movimiento del ratón, cada mutación del almacén de capas, cada tic del canal — agrupe la reacción. Los manejadores de alta frecuencia que mutan muchas capas sincrónicamente causan tirantes visibles:
let scheduled = false;
function scheduleSync() {
if (scheduled) return; // fusiona N llamadas en una sola pasada
scheduled = true;
queueMicrotask(() => {
scheduled = false;
syncAll();
});
}
Esto refleja lo que hace la maquinaria integrada de sincronización de capas: reconciliar el estado una vez por fotograma o microtarea, nunca una por evento.
6. Sobreviva a las condiciones de carrera asíncronas
Los plugins complejos terminan disparando trabajo asíncrono solapado: el usuario cambia el corte temporal antes de que el último fragmento resuelva, o dos refrescos compiten. Los guardas ingenuos if (!loading) return pierden memoria de estado. Dos primitivas baratas arreglan los casos comunes.
Tokens de generación invalidan resultados de pasadas superadas:
let fetchGeneration = 0;
async function refreshFeed() {
const gen = ++fetchGeneration;
const response = await fetch(FEED_URL);
if (!response.ok || gen !== fetchGeneration) return; // hay una pasada más nueva
applyToLayer(await response.json());
}
Banderas en vuelo con liberación explícita deduplican disparadores que llegan mientras una pasada sigue corriendo (un manejador de scroll disparando cincuenta veces durante una petición):
let syncing = false;
async function syncAll() {
if (syncing) {
pendingSync = true; // corre una vez más cuando esta pasada termine
return;
}
syncing = true;
try {
do {
pendingSync = false;
await reallySync();
} while (pendingSync);
} finally {
syncing = false;
}
}
La forma general: identifique qué hace obsoleta una pasada (una pasada más nueva, el plugin desactivado, la capa eliminada), codifíquelo como un token verificado en cada reanudación de await, y cancele durante el desmontaje los efectos secundarios que la pasada ya aplicó. Un plugin que pierde esta carrera típicamente deja interfaz a medio aplicar tras una desactivación rápida — pruebe ese camino deliberadamente alternando el plugin repetidamente con sus peticiones de red en vuelo.
7. Coordínese con otros plugins
Los flujos grandes se componen. Si su análisis se beneficia de Time Slider u otro plugin registrado, diríjalo por la API entre plugins en lugar de duplicarlo:
// Activa Time Slider y le entrega un parche de su estado de proyecto de una vez.
const ok = await app.activatePlugin?.("maplibre-gl-time-slider", { start: "2026-01-01" });
if (!ok) showHint("Time Slider is not available");
Vincule sus propias capas temporales personalizadas a la línea de tiempo con registerTemporalLayer (el contrato del adaptador está en la referencia); las capas añadidas mediante addZarrLayer se registran solas. Dos restricciones que respetar: nunca apunte al plugin llamante con activatePlugin/deactivatePlugin (ambas carecen de sentido sobre usted mismo y devuelven false), y recuerde que el otro plugin puede estar inactivo — verifique el resultado y degrade en lugar de asumir.
Declare urlParameterNames si su plugin debe admitir enlaces profundos: ?yourParam=... activa su plugin registrado y llama a su handleUrlParameters cuando el mapa está listo. Valide allí los valores de los parámetros como entrada no confiable (solo orígenes HTTPS, analice antes de descargar), tal como muestra el ejemplo de la referencia.
8. Localice desde temprano
Los plugins no tienen acceso a react-i18next: el paquete de plugins es agnóstico del framework, así que no puede importar el t() ni los catálogos de la anfitriona. Traduzca mediante su propio diccionario indexado por idioma, resuelto al leerse:
- Los títulos de panel aceptan un getter (
title: () => t("panelTitle")en lugar de una cadena plana) para que el encabezado del panel se re-localice en vivo conforme los consumidores de idioma relean el registro. - Los menús, botones y cualquier DOM que posea releen el diccionario cada vez que renderiza; si sus cadenas pueden cambiar a mitad de sesión, re-renderice al cambiar o re-registre el menú de la barra bajo el mismo
id(reemplaza en el sitio).
Mantenga el diccionario pequeño y embarcado con el bundle; resuelva ?locale=/?lang= desde la URL primero y retroceda a navigator.language, imitando cómo funciona la configuración regional de incrustación. Sea cual sea el mecanismo que elija, resuelva las cadenas por medio de funciones evaluadas al renderizar/leer, no constantes congeladas en activate().
Publíquelo
Empaquete el bundle con un plugin.json en la raíz e instálelo vía zip, directorio desempaquetado, URL de manifiesto, u hórnolo en un despliegue bajo public/plugins/<id>/. La mecánica completa — validación del manifiesto, límites de tamaño, entradas del registro del mercado, restricciones CSP, fijación de digests — está cubierta en Plugins externos; tres hábitos importan más para los plugins grandes:
- Mantenga la entrada autocontenida: los imports relativos dentro del zip no se resuelven, así que empaquete todo en un solo
.js. - Mantenga
id/name/versionidénticos enplugin.jsony el objeto plugin exportado; las discrepancias fracasan en la instalación. - Planifique actualizaciones en caliente: reinstalar el mismo id reemplaza la copia cargada, así que haga
activate()idempotente ydeactivate()completo — esa pareja es precisamente lo que vuelve seguro el reemplazo en caliente.
Lista de verificación previa al envío
Recorra esta lista antes de publicar un plugin complejo:
- Alternar el plugin apagado/encendido cinco veces rápido con una petición de red en vuelo: no quedan controles, listeners ni capas duplicados.
- Guardar un proyecto con su estado, cerrar, reabrir, reactivar: el estado se restaura, los puentes y adaptadores se re-registran.
- Abrir un proyecto distinto sin su plugin activo: nada suyo queda en la interfaz.
- Cambiar el mapa base con sus superficies montadas: nada construido de estado derivado del estilo queda obsoleto.
- Cambiar a una configuración regional derecha-a-izquierda y de idioma: los títulos vía getters se re-localizan, la disposición usa propiedades lógicas (
ms-/me-,text-start), no márgenes físicos. - Cargar su plugin en las compilaciones de escritorio y web donde aplique (la CSP de Tauri permite lista de hosts; las teselas de un dominio nuevo fallan silenciosamente en escritorio si no está permitido).
- Confirmar que toda cadena visible viene del diccionario y todo selector está acotado bajo la clase de su plugin.
Dónde ir después
- Referencia de la Plugin API — la interfaz completa, incluidas consultas Zarr, posiciones de acople y registros confiables.
- Fuentes integradas como ejemplos trabajados:
packages/plugins/src/plugins/swipe/(viaje de ida y vuelta del estado de proyecto y sincronización de cámara) ypackages/plugins/src/plugins/time-slider/(adaptadores temporales), junto a los plugins más pequeños del catálogo como cascarones mínimos. - Plugins incluidos cuando un despliegue debe distribuir su plugin automáticamente.