Manual/Tutorials

Writing Plugins

This tutorial walks through the patterns behind Meratyn’s most complex built-in plugins: how lifecycle, state, subscriptions, and UI surfaces fit together, and how to combine them into something bigger than a single map control. The Plugin API reference lists every method; this page explains how to use them together. You only need TypeScript and the browser DevTools — everything here works in an external plugin installed from a zip or manifest URL, no fork required.

The mental model

A Meratyn plugin is a small lifecycle object that borrows surfaces from the host:

Meratyn plugin lifecycle Diagram of the Meratyn plugin lifecycle: a plugin's activate call registers surfaces such as layers, map controls, panels, menus, and temporal bindings through the host application, deactivate undoes everything, and project state round-trips between the host and the saved project file. ACTIVATE DEACTIVATE SAVE RESTORE PLUGIN Plugin object activate · deactivate HOST Meratyn app MappViewAppAPI Layers addGeoJsonLayer Map controls addMapControl Right panel registerRightPanel Toolbar menu registerToolbarMenu Time Slider registerTemporalLayer Project file .meratyn.json LEGEND ACTIVATE DEACTIVATE = UNDO EVERYTHING PROJECT STATE ROUND-TRIP

Three rules follow from this model, and nearly every bug we see in plugins breaks one of them:

  1. You never mutate the map’s layer store or UI directly for anything Meratyn manages. You call a capability (addTileLayer, registerRightPanel, …) and the host applies it. Only layers you created yourself with getMap().addLayer() are yours to touch.
  2. activate() and deactivate() must be symmetric. Every control, listener, panel, DOM node, and interval you acquire in activate() is undone in deactivate(). The host may deactivate your plugin when the user switches projects, uninstalls it, or toggles it off — and reactivate it later.
  3. State has two homes. Ephemeral runtime state lives in your module. Anything the user configured must round-trip through the project file via getProjectState() / applyProjectState(), because a saved project restores your plugin from scratch by calling activate() followed by applyProjectState().

Primitives at a glance

Pick each primitive for its ownership semantics, not its looks:

PrimitiveOwnsMultiple openPersists in project
Map control (addMapControl)A button cluster pinned to a map corneryesposition only
Toolbar menu (registerToolbarMenu)A banner entry pointone per idno
Right panel (registerRightPanel)A primary workspace docked beside Layers/Styleone active at a timepresence restored by activation
Floating panel (registerFloatingPanel)An ancillary card over the mapyesno
addGeoJsonLayer / tile helpersA data layer visible to the whole appyesyes
registerExternalNativeLayerA layer you paint yourselfyesyes
getDeckGL()Renders GPU overlays on the host’s instance—no

Rules of thumb:

  • Primary, persistent workspace → right panel (dock positions and shared-rail modes are described in the reference).
  • A tool the user opens, drags around, and closes → floating panel.
  • A permanent mode toggle (like the swipe comparison control) → map control.
  • An entry point that dispatches into any of the above → toolbar menu, usually registered alongside a panel so its items can call app.openRightPanel?.("my-id").

Walkthrough: building a layered plugin

We will build a “quakewatch” plugin that shows recent earthquakes: a toolbar menu entry, a dockable workspace with a live feed, a data layer on the map, a per-user filter that persists in the project, and correct teardown throughout. Each step introduces one pattern.

1. Skeleton with symmetric lifecycle

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) {},
};

The first pattern is small but pays for itself immediately: keep a list of disposers and accumulate it in registration order, then unwind it in reverse on teardown. This removes the classic deactivate-ordering bugs (using something after unregistering it).

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

Every host subscription in the API (onBasemapChange, registerRightPanel, registerToolbarMenu, registerFloatingPanel, registerExternalNativeLayer) either returns an unsubscribe function or has an explicit unregister counterpart. Route all of them through this list instead of keeping loose variables per resource.

Do not rely on deactivate() receiving the same state you left behind. After deactivation, module variables may hold references to removed controls. Clear them during teardown, as shown below, so a later activate() starts clean.

2. Register UI and own the DOM

Panels hand you an empty HTMLElement; external plugins cannot share Meratyn’s React instance, so the contract is plain DOM. Render declaratively-owned nodes and listeners, and return a cleanup function from render() — the host runs it when the panel closes or is unregistered.

const disposers = createDisposers();

// Minimal string table: plugins have no access to the app's react-i18next
// catalogs, so resolve every user-facing string through this (see below).
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"; // namespace all styling!

  const list = document.createElement("ul");
  list.setAttribute("role", "list");
  container.appendChild(list);

  // One cleanup per resource, always captured.
  disposers.add(
    app.registerRightPanel?.({
      id: "quakewatch",
      // A getter re-localizes live on language changes (see i18n below).
      title: () => t("panelTitle"),
      defaultWidth: 340,
      render(el) {
        el.appendChild(container);
        return () => {
          el.replaceChildren(); // drop our DOM and its 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();
};

Because panel DOM stays mounted across collapse/expand, keep per-panel DOM state (scroll position, form input) in the elements themselves and reserve plugin variables for things that survive full close/open cycles.

If your panel renders incrementally (a live feed appending rows), also handle the case where render() runs twice after a close-open cycle: either rebuild the inner DOM from source-of-truth state rather than mutating it ad hoc, or reset that state in the cleanup function.

Two more rules for anything you render:

  • Scope your CSS. Plugin stylesheets inject globally into the host document. Prefix every selector with a class unique to your plugin (quakewatch .feed-item {...}), never ship bare element selectors like ul { ... }.
  • Guard the optional methods. Surfaces like right panels are typed optional because some host variants lack them: call app.registerRightPanel?.(...), app.addCogLayer?.(...), etc., and degrade gracefully when absent rather than throwing inside activate().

3. Add data through the host, not the map

For the earthquake layer itself, never call getMap().addSource()/addLayer() when a helper matches your data shape. Native helpers give the layer full standing in the app: Layers panel visibility, opacity, ordering, project persistence.

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();

  // Returns the new layer id; appears in the Layers panel and persists.
  return app.addGeoJsonLayer("Earthquakes", data, "https://example.com/quakes.geojson");
}

Choose the helper by data type: GeoJSON vectors use addGeoJsonLayer; pre-rendered XYZ/WMTS/WMS tiles use their helpers; cloud rasters use await app.addCogLayer?.(...) (GeoTIFF rendered client-side); scientific cubes use await app.addZarrLayer?.(...).

The escape hatch is registerExternalNativeLayer, for layers you paint — a WebGL layer, a canvas overlay, a deck.gl-driven surface drawn outside the helpers. Mirror it so the rest of the app knows it exists:

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"],
  // We paint it ourselves, so hide Style-panel sliders that cannot reach it.
  paintMode: "plugin",
  // Optional bridge keeps opacity/visibility controls live.
  paintBridge: {
    setOpacity: (opacity) => layer.setOpacity(opacity),
    setVisibility: (visible) => layer.setVisible(visible),
  },
});

Three subtleties from the reference worth repeating here, because they bite large plugins first:

  • paintBridge setters live outside the serialized layer record (functions cannot go into a project file), so re-register the layer after a project reload to restore the bridge.
  • With deck.gl, obtain modules via await app.getDeckGL?.() and render on the host’s single instance. Bundling your own copy ships duplicate luma.gl globals and fails to render at version mismatch.
  • Layers added directly with getMap().addLayer() and not mirrored are invisible to the layer store: they vanish from the Layers panel and are lost on save.

4. Persist user choices in the project

Users who set a magnitude filter expect it back tomorrow. Expose the two project-state hooks. Treat the incoming value as untrusted data — an old saved project, a hand-edited file, or another plugin may have written it — and normalize defensively.

interface QuakeWatchState {
  minMagnitude: number;
}

// Accept anything older versions might have written; fall back sanely.
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; // refuse garbage rather than adopt it
  state = next;
  rerenderFilterUI();
  return true;
};

Keep the persisted payload minimal, serializable JSON: ids and numbers, not functions, Map instances, or layer objects. If you need live handles across a reload (like a paint bridge or a temporal adapter), re-create them in activate()/applyProjectState() rather than trying to persist them. The Time Slider integration follows exactly this split: the binding metadata persists, the adapter is re-registered by code.

If restoring the state requires rebuilding UI (the built-in Layer Swipe plugin does this for its panel layout), the robust order is: capture current live state, tear down, remount from restored state. Only skip the rebuild when the incoming state equals what is already mounted — comparing first avoids a visible flicker when a project carries values identical to the running ones.

5. Subscribe to host events

Plugins that care about context subscribe to it explicitly. There is only one subscription today — basemap changes — plus MapLibre events on getMap(), and the discipline is the same for both: store the unsubscribe and route it through the disposers.

// Rebuild anything that caches style-dependent state (e.g. baked-in style
// sprites, or a control constructed with a style URL like the swipe control's
// comparison map). Carry state across the rebuild so the user sees continuity.
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));
}

When reacting to rapid streams — mouse move, every layer store mutation, every feed tick — batch the reaction. High-frequency handlers that synchronously mutate many layers cause visible jank:

let scheduled = false;

function scheduleSync() {
  if (scheduled) return; // coalesce N calls into one pass
  scheduled = true;
  queueMicrotask(() => {
    scheduled = false;
    syncAll();
  });
}

This mirrors what the built-in layer-sync machinery does: reconcile state once per frame or microtask, never once per event.

6. Survive async races

Complex plugins eventually fire overlapping asynchronous work: the user changes the time slice before the last chunk resolved, or two refreshes race. Naive if (!loading) return guards leak. Two cheap primitives fix the common cases.

Generation tokens invalidate results from superseded passes:

let fetchGeneration = 0;

async function refreshFeed() {
  const gen = ++fetchGeneration;
  const response = await fetch(FEED_URL);
  if (!response.ok || gen !== fetchGeneration) return; // a newer pass exists
  applyToLayer(await response.json());
}

In-flight flags with explicit release deduplicate triggers that arrive while one pass is still running (a scroll handler firing fifty times during one fetch):

let syncing = false;

async function syncAll() {
  if (syncing) {
    pendingSync = true; // run once more after this pass settles
    return;
  }
  syncing = true;
  try {
    do {
      pendingSync = false;
      await reallySync();
    } while (pendingSync);
  } finally {
    syncing = false;
  }
}

The general shape: identify what makes a pass obsolete (newer pass, plugin deactivated, layer removed), encode that as a token checked at every await resumption, and cancel side effects the pass already applied during teardown. A plugin that loses this race typically leaves half-applied UI behind after a fast deactivate — test that path deliberately by toggling the plugin repeatedly while its network requests are in flight.

7. Coordinate with other plugins

Large workflows compose. If your analysis benefits from the Time Slider or another registered plugin, drive it through the inter-plugin API rather than duplicating it:

// Activate the Time Slider and hand it a patch of your project state in one go.
const ok = await app.activatePlugin?.("maplibre-gl-time-slider", { start: "2026-01-01" });
if (!ok) showHint("Time Slider is not available");

Bind your own custom temporal layers to the slider with registerTemporalLayer (adapter contract in the reference); layers added through addZarrLayer register themselves. Two constraints to respect: never target the calling plugin with activatePlugin/deactivatePlugin (both are meaningless on yourself and return false), and remember the other plugin may be inactive — check the result and degrade rather than assuming.

Declare urlParameterNames if your plugin should deep-link: ?yourParam=... activates your registered plugin and calls its handleUrlParameters after map readiness. Validate parameter values there as untrusted input (HTTPS-only origins, parse before fetching) exactly as the reference example shows.

8. Localize early

Plugins have no access to react-i18next: the plugins package is framework-agnostic, so it cannot import the host’s t() or catalogs. Translate through your own dictionary keyed by language, resolved at read time:

  • Panel titles accept a getter (title: () => t("panelTitle") instead of a plain string) so the panel header re-localizes live as language consumers re-read the registry.
  • Menus, buttons, and any DOM you own re-read the dictionary each time you render; if your strings can change mid-session, re-render on change or re-register the toolbar menu under the same id (it replaces in place).

Keep the dictionary small and shipped with the bundle; resolve ?locale=/?lang= from the URL first and fall back to navigator.language, mirroring how the embed locale works. Whatever mechanism you choose, resolve strings through functions evaluated at render/read time, not constants frozen at activate().

Shipping it

Package the bundle with a root plugin.json and install via zip, unpacked directory, manifest URL, or bake it into a deployment under public/plugins/<id>/. Full mechanics — manifest validation, size limits, marketplace registry entries, CSP constraints, digest pinning — are covered in External plugins; three habits matter most for large plugins:

  • Keep the entry self-contained: relative imports inside the zip are not resolved, so bundle everything into one .js.
  • Keep id/name/version identical in plugin.json and the exported plugin object; mismatches fail installation.
  • Plan for updates in place: reinstalling the same id replaces the loaded copy, so make activate() idempotent and deactivate() complete — that pair is precisely what makes hot replacement safe.

Pre-flight checklist

Run down this list before shipping a complex plugin:

  • Toggle the plugin off/on five times fast while a network request is in flight: no duplicated controls, listeners, or layers remain.
  • Save a project with your state, close, reopen, reactivate: state restored, bridges and adapters re-registered.
  • Open a different project without your plugin active: nothing of yours lingers in the UI.
  • Switch the basemap while your surfaces are mounted: nothing built from style-derived state goes stale.
  • Flip right-to-left locale and switch languages: titles via getters re-localize, layout uses logical properties (ms-/me-, text-start) not physical margins.
  • Load your plugin in both desktop and web builds where relevant (Tauri’s CSP allowlists hosts; tiles from a new domain silently fail on desktop unless allowed).
  • Confirm every visible string is dictionary-driven and every selector scoped under your plugin class.

Where to go next

  • Plugin API reference — the complete interface, including Zarr queries, dock positions, and trusted registries.
  • Built-in sources as worked examples: packages/plugins/src/plugins/swipe/ (project-state round-tripping and camera synchronization) and packages/plugins/src/plugins/time-slider/ (temporal adapters), alongside the smaller catalogue plugins for minimal shells.
  • Bundled plugins when a deployment should ship your plugin automatically.