Part of the Iframe Embedding & Isolation guide.
Operative rule: send intent, not implementation — a message says “show categories A and B between these dates”, never a renderer expression, or the host page becomes coupled to whichever library the map happens to use this quarter.
The Contract Is the Design
Once controls live outside the map, the message format is the interface between two codebases that will be changed by different people at different times. Treating it as a versioned contract — a named channel, a version field, a small set of message types and a documented payload shape — is what keeps that interface from decaying into a set of undocumented assumptions.
Sending intent rather than implementation is the other half. A payload of field names and values can be translated by the frame into whatever its current renderer needs; a payload containing a renderer expression means every change to the map’s internals is a breaking change to the host.
Production-Ready Implementation
// ── host page ─────────────────────────────────────────────────────────────
const CHANNEL = "geo-dashboard:v1";
const FRAME_ORIGIN = "https://maps.example.org";
const frame = document.getElementById("map-frame");
let frameReady = false;
const queued = [];
function send(type, payload) {
const message = { channel: CHANNEL, type, payload };
if (!frameReady) { queued.push(message); return; } // never lost
frame.contentWindow.postMessage(message, FRAME_ORIGIN);
}
window.addEventListener("message", (event) => {
if (event.origin !== FRAME_ORIGIN) return;
if (event.source !== frame.contentWindow) return;
if (event.data?.channel !== CHANNEL) return;
if (event.data.type === "ready") {
frameReady = true;
while (queued.length) frame.contentWindow.postMessage(queued.shift(), FRAME_ORIGIN);
}
});
// Controls call this; it is the only coupling point.
export function applyFilters({ categories, from, to, minSeverity }) {
send("filter", { categories, from, to, minSeverity });
}
// ── embedded map ──────────────────────────────────────────────────────────
const HOST_ORIGIN = "https://dashboard.example.org";
const ALLOWED_CATEGORIES = new Set(["flood", "storm", "heat", "fire"]);
function validateFilter(payload) {
const categories = Array.isArray(payload?.categories)
? payload.categories.filter((c) => ALLOWED_CATEGORIES.has(c))
: [];
const asTime = (value) => {
const t = Date.parse(value ?? "");
return Number.isFinite(t) ? t / 1000 : null;
};
return {
categories,
from: asTime(payload?.from),
to: asTime(payload?.to),
minSeverity: Number.isFinite(payload?.minSeverity)
? Math.min(Math.max(payload.minSeverity, 0), 5) : 0,
};
}
function toExpression(filter) {
const expression = ["all"];
// An empty category list means "no constraint", never "match nothing".
if (filter.categories.length) {
expression.push(["in", ["get", "category"], ["literal", filter.categories]]);
}
if (filter.from !== null) expression.push([">=", ["get", "ts"], filter.from]);
if (filter.to !== null) expression.push(["<=", ["get", "ts"], filter.to]);
if (filter.minSeverity > 0) {
expression.push([">=", ["get", "severity"], filter.minSeverity]);
}
return expression;
}
window.addEventListener("message", (event) => {
if (event.origin !== HOST_ORIGIN) return;
const data = event.data;
if (data?.channel !== CHANNEL || data.type !== "filter") return;
const filter = validateFilter(data.payload);
map.setFilter("incidents", toExpression(filter));
});
parent.postMessage({ channel: CHANNEL, type: "ready" }, HOST_ORIGIN);
Validation on arrival is not defensive decoration. The payload crossed an origin boundary, so it is input: category names are checked against an allow-list, dates are parsed rather than trusted, and numbers are clamped. A message that fails validation should degrade to a safe filter — usually “show everything” — rather than throwing and leaving the map in whatever state it was in.
Handling the Ready Race
Filtering Without Refetching
Verification Steps
- Apply a filter from the host before the frame finishes loading and confirm it takes effect.
- Send a payload containing an unknown category and confirm it is dropped, not applied.
- Send a malformed date and confirm the map shows everything rather than nothing.
- Post a message from an unexpected origin and confirm the frame ignores it.
- Confirm applying a filter causes no network requests when the data is already loaded.
Common Errors & Fixes
The filter applies on the second interaction but not the first
The ready handshake is missing. Queue on the host and flush on ready.
An empty filter hides everything
An empty category list is being translated into an in clause with no values, which matches nothing. Treat empty as “no constraint” and omit the clause.
Filters work until the map is rebuilt with a different renderer
The message carried an expression. Move the translation inside the frame so the contract stays about intent.
The host and the frame disagree about what is filtered
Two sources of truth. The host owns the control values; the frame owns the rendering. Have the frame post its applied filter back and let the host reconcile if they differ.
Acknowledging What Was Applied
A one-way channel leaves the host guessing. It sent a filter; it does not know whether the frame received it, whether every clause was understood, or how many features remain. For a control panel that is enough to produce a confusing interface — a category checkbox that appears active while the map shows everything, because the category name was rejected by validation.
An acknowledgement message closes the loop cheaply. After applying a filter the frame posts back what it actually applied and what the result was: the normalised filter, the number of features matching it in the current view, and a list of anything it ignored. The host can then reflect reality — greying out a category that produced no matches, showing the result count beside the controls, or warning that a value was not recognised.
The count is the part readers notice. “142 features match” beside a filter panel turns an abstract control into a direct answer, and it makes an empty result explicable rather than alarming: a map that goes blank after a filter is a bug until a number explains that the filter matched nothing.
Keep the acknowledgement advisory. The host should never block on it, because a frame that fails to respond must not freeze the surrounding page, and a timeout that silently disables the controls is worse than a control panel that is occasionally optimistic.
Where Filter State Should Live
Once filters are driven from the host, the question of who owns them has a clear answer: the host, because that is where the controls are and where the URL is. The frame holds a derived copy so it can render, and treats every incoming message as authoritative.
That ordering matters when a reader shares a link. The host serialises its own filter state into its URL, the recipient’s host page parses it, and it is posted into the frame during the ready handshake — the same path a fresh interaction takes. There is no separate restore mechanism inside the frame, and therefore no second implementation to keep in step.
It also settles what happens when the frame reloads for any reason: it comes up filterless, announces readiness, and the host immediately re-sends the current state. The reader sees the map briefly unfiltered and then correct, which is acceptable; what they must never see is a map that stays unfiltered while the controls insist otherwise.
Gotchas & Edge Cases
- Filtering a layer does not filter its labels unless the label layer carries the same filter; a filtered map with unfiltered labels is a common and confusing half-state.
- A filter applied to a vector-tile layer can only reference attributes retained during tiling, so the host’s control vocabulary is constrained by a decision made in the pipeline.
- Date filters need a timezone contract. Send absolute instants rather than local dates, or a filter set in one timezone selects a different range for a reader in another.
- Clearing a filter must be an explicit message rather than an empty payload, so a malformed message cannot be mistaken for “show everything”.
- If the map aggregates or clusters, a filter changes the aggregates too — the reader should see the bins recomputed, not the same bins with fewer members hidden inside them.
Related
- Iframe Embedding & Isolation for Python-Generated Maps — the isolation rules this channel lives inside
- Auto-Resizing Embedded Maps with postMessage — the same channel carrying height
- Syncing Embedded Map State with the Host Page URL — making the resulting state shareable
- Data-Driven Styling with MapLibre Expressions from Python — what the frame translates the intent into