Skip to content
EN

Build a Route Search plugin

Last updated

We use the Plugin Playground to build a plugin that searches for a route between two points and draws it on the globe. It runs entirely in the browser, so there is no local environment to prepare.

A Route Search widget draws the path between two points on the globe. The reader sets a start and an end, either by typing coordinates or by clicking the map. After a travel mode of walk, bike, or car is picked, the widget calls an external routing service to fetch the route. The route is drawn as a clickable line that reports its distance and travel time, with a marker at each endpoint and a button that clears everything.

The finished Route Search widget beside a walking route drawn in blue between two points in Tokyo, with a marker at each end

The widget uses four parts of the Plugin API:

  • UI and Visualizer communication — passing data in both directions between the widget iframe and the plugin logic, through reearth.ui and reearth.extension
  • External API integration — calling a routing service with fetch and converting the response to GeoJSON
  • Layers API — adding, updating, finding, and deleting layers at runtime
  • Camera API — setting the scene’s opening view
  • A Re:Earth Visualizer project.
  • The Plugin Playground open in a browser tab.
  • Familiarity with HTML, CSS, and JavaScript. This guide explains the Re:Earth Plugin API rather than general web code.

If this is your first plugin, work through Run it in Plugin Playground, Display UI, and Communicate between a Plugin and Re:Earth first. They cover the Playground’s layout, the reearth.yml manifest, the export and install flow, and the message-passing pattern that this guide reuses.

In the Custom section of the Playground’s file tree, select My Plugin. It already contains a reearth.yml manifest and a demo-widget file, and those are the two files you edit.

Open reearth.yml and replace its contents with a single widget extension:

The reearth.yml manifest open in the Playground's Code panel, with My Plugin selected in the file tree

id: route-search-plugin
name: Route Search
version: 1.0.0
extensions:
- id: demo-widget
type: widget
name: Route Search
description: Search and draw a route between two points
widgetLayout:
defaultLocation:
zone: outer
section: left
area: top

The extension id stays demo-widget so that it still matches the existing demo-widget file — an extension id and its file name must be identical. The plugin id takes lowercase letters, numbers, _, and -, holds no spaces, and cannot be reearth. Installation fails later if a plugin with the same id is already in the project, so pick an id nothing else uses.

Select demo-widget and replace its contents with the following. reearth.ui.show takes an HTML string and renders it as the widget, inside an iframe:

The demo-widget file selected in the Playground's file tree, with its starting code in the Code panel

reearth.ui.show(`
<style>
@import url("https://reearth.github.io/visualizer-plugin-sample-data/public/css/preset-ui.css");
</style>
<div class="rounded-sm secondary-background" style="width: 290px">
<div class="display-flex flex-between p-16" style="height: 48px">
<h2 class="text-md">Route Search</h2>
</div>
<div class="flex-column p-8">
<div class="flex-column gap-8 p-16">
<div class="flex-between" style="gap: 0">
<label class="font-bold" for="start-point">Start</label>
<div class="display-flex" style="width: 180px; height: 30px; border-radius: 4px; border: 1px solid #8b8b8b">
<input id="start-point" type="text" placeholder="lng,lat" style="border: none; outline: 0" />
<button class="icon-btn" onclick="handleIconClick('start', this)">📍</button>
</div>
</div>
<div class="flex-between" style="gap: 0">
<label class="font-bold" for="end-point">End</label>
<div class="display-flex" style="width: 180px; height: 30px; border-radius: 4px; border: 1px solid #8b8b8b">
<input id="end-point" type="text" placeholder="lng,lat" style="border: none; outline: 0" />
<button class="icon-btn" onclick="handleIconClick('end', this)">📍</button>
</div>
</div>
</div>
<div class="display-flex p-16" style="align-items: flex-start; gap: 43px">
<label class="font-bold">Mode</label>
<div class="flex-column gap-4">
<label><input type="radio" name="transport" value="foot" checked /> Walk</label>
<label><input type="radio" name="transport" value="bike" /> Bike</label>
<label><input type="radio" name="transport" value="car" /> Car</label>
</div>
</div>
<div class="display-flex gap-8 p-8">
<button class="btn-primary button-padding w-full text-sm" onclick="searchRoute()">Search</button>
<button class="btn-neutral button-padding w-full text-sm" onclick="deleteRouteAndMarker()">Clear</button>
</div>
<p id="status" class="text-sm p-8" style="margin: 0; min-height: 18px"></p>
</div>
</div>
`);

The widget’s behaviour arrives as a <script> block in the steps that follow, so the buttons do nothing yet.

Step 4 — Switch the basemap to OpenStreetMap

Section titled “Step 4 — Switch the basemap to OpenStreetMap”

The route comes from OpenStreetMap data, so switch the scene’s basemap to match it before you start clicking around the map. Below reearth.ui.show(...), add:

reearth.viewer.overrideProperty({
tiles: [
{
id: "osm",
type: "open_street_map",
},
],
});

open_street_map is one of Re:Earth Visualizer’s built-in basemaps and needs no Cesium Ion access token, unlike some of the project’s own basemap options. See Change the basemap for the other built-in choices and for using a custom tile server instead.

The Plugin Playground&#x27;s Code panel with the reearth.viewer.overrideProperty call highlighted, and the globe now showing OpenStreetMap tiles instead of the default basemap

The reader clicks the 📍 icon to enter pick mode, then clicks the map to fill the matching input. That takes a message in each direction.

Below reearth.ui.show(...), add a listener for map clicks:

reearth.viewer.on("click", (event) => {
const { lat, lng } = event;
if (lat === undefined || lng === undefined) return; // clicked the sky / empty space
reearth.ui.postMessage({ type: "position", lat, lng });
});

reearth.viewer.on("click", ...) fires on every map click, and reearth.ui.postMessage sends data from the Visualizer into the widget iframe. A click that misses the globe carries no coordinate, which is why the handler returns early: posting a position of undefined leaves the widget calling .toFixed() on nothing, and pick mode jams with no visible cause.

Inside the HTML you passed to reearth.ui.show, just before the closing backtick, add a <script> block:

<script>
let selecting = null;
function handleIconClick(type, btn) {
if (selecting === type) {
selecting = null;
resetIconColors();
} else {
selecting = type;
resetIconColors();
btn.style.background = "#ffcccc";
}
}
function resetIconColors() {
document.querySelectorAll(".icon-btn").forEach((b) => (b.style.background = ""));
}
window.addEventListener("message", (e) => {
const msg = e.data;
if (msg.type === "position" && selecting) {
const coordinates = msg.lng.toFixed(6) + "," + msg.lat.toFixed(6);
document.getElementById(selecting + "-point").value = coordinates;
selecting = null;
resetIconColors();
}
});
</script>

Inside the iframe, messages arrive through window.addEventListener("message", ...). While pick mode is on, the coordinate is written into the start or end input. The coordinate order is lng,lat, which is the order the routing service expects.

Click the 📍 icon and then the map: the input fills with the coordinate you clicked.

The Route Search widget with the Start and End inputs filled after clicking the map, and the widget&#x27;s message-listener script highlighted in the Code panel

Step 6 — Show markers for the selected points

Section titled “Step 6 — Show markers for the selected points”

A marker on each selected point comes from the Layers API.

Inside the <script>, add a helper:

function addMarkerLayer(lat, lng, pointName) {
parent.postMessage({ action: "addMarkerLayer", lat, lng, pointName }, "*");
}

Then call it from the message listener by adding addMarkerLayer(msg.lat, msg.lng, selecting); directly above selecting = null;.

parent.postMessage sends data from the widget iframe back to the Visualizer. Together with reearth.ui.postMessage from the previous step, it forms the two-way channel that carries almost every interactive plugin.

Below the reearth.viewer.on("click", ...) block, add the id variables that will track this plugin’s own layers, and a handler for messages coming from the widget:

let startMarkerId = null;
let endMarkerId = null;
reearth.extension.on("message", (msg) => {
if (msg.action === "addMarkerLayer") {
const value = {
type: "FeatureCollection",
features: [
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [msg.lng, msg.lat] } },
],
};
const markerId = msg.pointName === "start" ? startMarkerId : endMarkerId;
if (markerId) {
reearth.layers.override(markerId, { data: { type: "geojson", value } });
} else {
const newId = reearth.layers.add({
type: "simple",
title: msg.pointName,
data: { type: "geojson", value },
marker: { pointColor: "blue", pointSize: 12, style: "point" },
});
if (msg.pointName === "start") startMarkerId = newId;
else endMarkerId = newId;
}
}
});

reearth.extension.on("message", ...) receives what the widget sends, and the handler branches on msg.action. startMarkerId and endMarkerId hold the id reearth.layers.add returns for each marker. Looking a layer up by id, rather than by searching for a layer titled start or end with reearth.layers.find, is what keeps reearth.layers.override from touching some other layer that happens to share that title — an existing layer already in the project, or a temporary one added by another plugin.

A blue marker now appears where you click, and picking a new point moves it rather than leaving a trail.

Two blue markers on the map at the selected start and end points, with the addMarkerLayer handler highlighted in the Code panel

The route comes from OSRM (Open Source Routing Machine), an open-source routing engine, through its public demo server. Add these functions inside the <script>:

// The demo server serves each travel mode from its own host.
const OSRM_HOSTS = {
foot: "https://routing.openstreetmap.de/routed-foot",
bike: "https://routing.openstreetmap.de/routed-bike",
car: "https://routing.openstreetmap.de/routed-car",
};
function setStatus(text) {
document.getElementById("status").textContent = text || "";
}
async function getRoute(start, end, mode) {
const url =
OSRM_HOSTS[mode] +
"/route/v1/driving/" + start + ";" + end +
"?overview=full&geometries=geojson";
const res = await fetch(url);
const data = await res.json();
if (!res.ok || data.code !== "Ok") throw new Error(data.message || "No route found");
const route = data.routes[0];
return {
type: "Feature",
geometry: route.geometry,
properties: { distance: route.distance, duration: route.duration },
};
}
function addRouteLayer(geojson) {
parent.postMessage({ action: "addRouteLayer", geojson }, "*");
}
async function searchRoute() {
const start = document.getElementById("start-point").value.trim();
const end = document.getElementById("end-point").value.trim();
if (!start || !end) {
setStatus("Set both a start and an end point.");
return;
}
const mode = document.querySelector('input[name="transport"]:checked').value;
setStatus("Searching...");
try {
const [startLng, startLat] = start.split(",").map(Number);
const [endLng, endLat] = end.split(",").map(Number);
if ([startLng, startLat, endLng, endLat].some(Number.isNaN)) {
throw new Error("Enter coordinates as lng,lat");
}
addMarkerLayer(startLat, startLng, "start");
addMarkerLayer(endLat, endLng, "end");
const geojson = await getRoute(start, end, mode);
addRouteLayer(geojson);
setStatus("");
} catch (err) {
console.error("Failed to get route:", err);
setStatus("Route search failed.");
}
}

getRoute calls the routing service with fetch, checks the response, and reshapes it into a GeoJSON Feature carrying distance in metres and duration in seconds. searchRoute validates the two inputs, parses each into lng and lat, and calls addMarkerLayer for both points before it calls getRoute. That is what gives a start or end point typed directly into the input its marker too, not just one picked on the map — until now, typing a coordinate moved the route but left the marker at wherever it last was, or missing entirely. Failures — an empty field, text that will not parse as coordinates, or a rejected route — all land in the status line under the buttons, so they report themselves instead of doing nothing.

Each travel mode has its own host, which is why OSRM_HOSTS maps a radio value to a host while the path stays /driving/. The profile segment of an OSRM URL does not select the travel mode on the public demo server: a request to router.project-osrm.org/route/v1/foot/... is accepted and still returns a driving route, which would leave the Walk, Bike, and Car options with no effect.

Handle the addRouteLayer action on the Visualizer side. This is a second branch of the same reearth.extension.on("message", ...) handler from the marker step: the leading } closes the addMarkerLayer branch, so replace that branch’s closing brace with the block below rather than pasting it after the whole handler. Also add a third id variable next to startMarkerId and endMarkerId: let routeLayerId = null;.

} else if (msg.action === "addRouteLayer") {
// remove the previous route so repeated searches don't stack up
if (routeLayerId) reearth.layers.delete(routeLayerId);
routeLayerId = reearth.layers.add({
type: "simple",
title: "route",
data: { type: "geojson", value: msg.geojson },
infobox: {
blocks: [{ pluginId: "reearth", extensionId: "propertyInfoboxBetaBlock" }],
},
polyline: { strokeColor: "blue", strokeWidth: 2 },
});
}

The route is a simple layer whose GeoJSON comes straight from the routing response. polyline styles the line. infobox reports the route’s distance and duration when the line is clicked. routeLayerId tracks this layer the same way startMarkerId and endMarkerId track the markers, so deleting the previous route before adding the new one only ever touches a layer this plugin created.

Set both points, choose a mode, and select Search: a blue line follows the route, and clicking it opens the infobox.

A blue route line connecting the two markers, with the addRouteLayer handler highlighted in the Code panel

Inside the <script>, add the function behind the Clear button:

function deleteRouteAndMarker() {
document.getElementById("start-point").value = "";
document.getElementById("end-point").value = "";
selecting = null;
resetIconColors();
setStatus("");
parent.postMessage({ action: "deleteRouteAndMarkerLayer" }, "*");
}

On the Visualizer side, chain a third branch onto the same handler, the way the route branch was added:

} else if (msg.action === "deleteRouteAndMarkerLayer") {
if (routeLayerId) {
reearth.layers.delete(routeLayerId);
routeLayerId = null;
}
if (startMarkerId) {
reearth.layers.delete(startMarkerId);
startMarkerId = null;
}
if (endMarkerId) {
reearth.layers.delete(endMarkerId);
endMarkerId = null;
}
}

reearth.layers.delete removes a layer by id — the same three ids the other two branches maintain. Setting each one back to null after deleting it is what lets the next search create a fresh marker or route layer, rather than overriding a layer that no longer exists.

Give the scene an opening position. Add this once, after reearth.ui.show(...):

reearth.camera.setView({
lat: 35.68426,
lng: 139.71043,
height: 2000,
heading: 0,
pitch: -0.785,
roll: 0,
});

setView moves to a position immediately. reearth.camera.flyTo animates there instead.

The Playground&#x27;s Plugins toolbar, with the export control marked in red and the import control in blue

  1. In the Playground, select Export plugin to download a .zip.
  2. In your Re:Earth Visualizer project, open the plugin install dialog and choose Zip file from PC, then select the file you downloaded.
  3. After the upload reports success, add the widget from the Widgets tab.

If installation fails, check that reearth.yml is valid, that no plugin with the same id is installed already, and that the .zip holds a single folder with reearth.yml at its root. Some compression tools write a .zip that the dialog rejects, so try another one.

The finished Route Search widget beside a walking route drawn in blue between two points in Tokyo, with a marker at each end

Confirm the finished widget end to end:

  1. Select the 📍 next to Start, then click the map. The input fills and a blue marker appears.
  2. Do the same for End.
  3. Choose a travel mode and select Search. The route line is drawn, and clicking it reports distance and duration.
  4. Select Clear. The line and both markers are removed.
Full demo-widget code
// ---------- Plugin context: build and show the widget UI ----------
// (only the code inside the <script> below runs in the widget iframe)
reearth.ui.show(`
<style>
@import url("https://reearth.github.io/visualizer-plugin-sample-data/public/css/preset-ui.css");
</style>
<div class="rounded-sm secondary-background" style="width: 290px">
<div class="display-flex flex-between p-16" style="height: 48px">
<h2 class="text-md">Route Search</h2>
</div>
<div class="flex-column p-8">
<div class="flex-column gap-8 p-16">
<div class="flex-between" style="gap: 0">
<label class="font-bold" for="start-point">Start</label>
<div class="display-flex" style="width: 180px; height: 30px; border-radius: 4px; border: 1px solid #8b8b8b">
<input id="start-point" type="text" placeholder="lng,lat" style="border: none; outline: 0" />
<button class="icon-btn" onclick="handleIconClick('start', this)">📍</button>
</div>
</div>
<div class="flex-between" style="gap: 0">
<label class="font-bold" for="end-point">End</label>
<div class="display-flex" style="width: 180px; height: 30px; border-radius: 4px; border: 1px solid #8b8b8b">
<input id="end-point" type="text" placeholder="lng,lat" style="border: none; outline: 0" />
<button class="icon-btn" onclick="handleIconClick('end', this)">📍</button>
</div>
</div>
</div>
<div class="display-flex p-16" style="align-items: flex-start; gap: 43px">
<label class="font-bold">Mode</label>
<div class="flex-column gap-4">
<label><input type="radio" name="transport" value="foot" checked /> Walk</label>
<label><input type="radio" name="transport" value="bike" /> Bike</label>
<label><input type="radio" name="transport" value="car" /> Car</label>
</div>
</div>
<div class="display-flex gap-8 p-8">
<button class="btn-primary button-padding w-full text-sm" onclick="searchRoute()">Search</button>
<button class="btn-neutral button-padding w-full text-sm" onclick="deleteRouteAndMarker()">Clear</button>
</div>
<p id="status" class="text-sm p-8" style="margin: 0; min-height: 18px"></p>
</div>
</div>
<script>
let selecting = null;
// The demo server serves each travel mode from its own host.
const OSRM_HOSTS = {
foot: "https://routing.openstreetmap.de/routed-foot",
bike: "https://routing.openstreetmap.de/routed-bike",
car: "https://routing.openstreetmap.de/routed-car",
};
function setStatus(text) {
document.getElementById("status").textContent = text || "";
}
function resetIconColors() {
document.querySelectorAll(".icon-btn").forEach((b) => (b.style.background = ""));
}
function handleIconClick(type, btn) {
if (selecting === type) {
selecting = null;
resetIconColors();
} else {
selecting = type;
resetIconColors();
btn.style.background = "#ffcccc";
}
}
function addMarkerLayer(lat, lng, pointName) {
parent.postMessage({ action: "addMarkerLayer", lat, lng, pointName }, "*");
}
function addRouteLayer(geojson) {
parent.postMessage({ action: "addRouteLayer", geojson }, "*");
}
async function getRoute(start, end, mode) {
const url =
OSRM_HOSTS[mode] +
"/route/v1/driving/" + start + ";" + end +
"?overview=full&geometries=geojson";
const res = await fetch(url);
const data = await res.json();
if (!res.ok || data.code !== "Ok") throw new Error(data.message || "No route found");
const route = data.routes[0];
return {
type: "Feature",
geometry: route.geometry,
properties: { distance: route.distance, duration: route.duration },
};
}
async function searchRoute() {
const start = document.getElementById("start-point").value.trim();
const end = document.getElementById("end-point").value.trim();
if (!start || !end) {
setStatus("Set both a start and an end point.");
return;
}
const mode = document.querySelector('input[name="transport"]:checked').value;
setStatus("Searching...");
try {
const [startLng, startLat] = start.split(",").map(Number);
const [endLng, endLat] = end.split(",").map(Number);
if ([startLng, startLat, endLng, endLat].some(Number.isNaN)) {
throw new Error("Enter coordinates as lng,lat");
}
addMarkerLayer(startLat, startLng, "start");
addMarkerLayer(endLat, endLng, "end");
const geojson = await getRoute(start, end, mode);
addRouteLayer(geojson);
setStatus("");
} catch (err) {
console.error("Failed to get route:", err);
setStatus("Route search failed.");
}
}
function deleteRouteAndMarker() {
document.getElementById("start-point").value = "";
document.getElementById("end-point").value = "";
selecting = null;
resetIconColors();
setStatus("");
parent.postMessage({ action: "deleteRouteAndMarkerLayer" }, "*");
}
window.addEventListener("message", (e) => {
const msg = e.data;
if (msg.type === "position" && selecting) {
const target = selecting;
document.getElementById(target + "-point").value =
msg.lng.toFixed(6) + "," + msg.lat.toFixed(6);
addMarkerLayer(msg.lat, msg.lng, target);
selecting = null;
resetIconColors();
}
});
</script>
`);
// ---------- Plugin logic (runs in the Visualizer context) ----------
reearth.viewer.overrideProperty({
tiles: [
{
id: "osm",
type: "open_street_map",
},
],
});
reearth.viewer.on("click", (event) => {
const { lat, lng } = event;
if (lat === undefined || lng === undefined) return; // clicked the sky / empty space
reearth.ui.postMessage({ type: "position", lat, lng });
});
let startMarkerId = null;
let endMarkerId = null;
let routeLayerId = null;
reearth.extension.on("message", (msg) => {
if (msg.action === "addMarkerLayer") {
const value = {
type: "FeatureCollection",
features: [
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [msg.lng, msg.lat] } },
],
};
const markerId = msg.pointName === "start" ? startMarkerId : endMarkerId;
if (markerId) {
reearth.layers.override(markerId, { data: { type: "geojson", value } });
} else {
const newId = reearth.layers.add({
type: "simple",
title: msg.pointName,
data: { type: "geojson", value },
marker: { pointColor: "blue", pointSize: 12, style: "point" },
});
if (msg.pointName === "start") startMarkerId = newId;
else endMarkerId = newId;
}
} else if (msg.action === "addRouteLayer") {
// remove the previous route so repeated searches don't stack up
if (routeLayerId) reearth.layers.delete(routeLayerId);
routeLayerId = reearth.layers.add({
type: "simple",
title: "route",
data: { type: "geojson", value: msg.geojson },
infobox: {
blocks: [{ pluginId: "reearth", extensionId: "propertyInfoboxBetaBlock" }],
},
polyline: { strokeColor: "blue", strokeWidth: 2 },
});
} else if (msg.action === "deleteRouteAndMarkerLayer") {
if (routeLayerId) {
reearth.layers.delete(routeLayerId);
routeLayerId = null;
}
if (startMarkerId) {
reearth.layers.delete(startMarkerId);
startMarkerId = null;
}
if (endMarkerId) {
reearth.layers.delete(endMarkerId);
endMarkerId = null;
}
}
});
reearth.camera.setView({
lat: 35.68426,
lng: 139.71043,
height: 2000,
heading: 0,
pitch: -0.785,
roll: 0,
});