Recipes ​
Common patterns for connecting TacticalDraw to application UI and state.
Toggle pins ​
This Vite example shows one unit on a plain map. Check Show pins to raise the symbol. Uncheck it to show the ordinary symbol at its map location.
Install the packages:
pnpm add @orbat-mapper/tactical-draw @orbat-mapper/tactical-draw-adapter-maplibre @orbat-mapper/point-symbols milsymbol maplibre-gl@^6
pnpm add -D vite typescriptCreate index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Toggle pins</title>
<style>
body {
margin: 0;
font-family: sans-serif;
}
#map {
height: 100vh;
}
.controls {
position: absolute;
top: 12px;
left: 12px;
padding: 12px;
background: white;
}
</style>
</head>
<body>
<div id="map"></div>
<label class="controls"> <input id="show-pins" type="checkbox" disabled /> Show pins </label>
<script type="module" src="/main.ts"></script>
</body>
</html>Create main.ts:
import * as maplibregl from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import "maplibre-gl/dist/maplibre-gl.css";
import { TacticalDraw, type PointSymbol } from "@orbat-mapper/tactical-draw";
import { MapLibreAdapter } from "@orbat-mapper/tactical-draw-adapter-maplibre";
import { milsymbolPointSymbols } from "@orbat-mapper/point-symbols";
maplibregl.setWorkerUrl(workerUrl);
const map = new maplibregl.Map({
container: "map",
center: [10.75, 59.91],
zoom: 13,
pitch: 55,
style: {
version: 8,
sources: {},
layers: [{ id: "background", type: "background", paint: { "background-color": "#e2e8f0" } }],
},
});
const pinToggle = document.querySelector<HTMLInputElement>("#show-pins")!;
let td: TacticalDraw | undefined;
map.on("load", () => {
const draw = new TacticalDraw(new MapLibreAdapter(map), {
pointSymbols: milsymbolPointSymbols(),
});
td = draw;
const unit: PointSymbol = {
id: "unit-1",
kind: "point-symbol",
sidc: "130315003611010300000000000000",
position: [10.75, 59.91],
rotation: 0,
size: { value: 44, unit: "pixels" },
};
draw.setPinPresentation({
enabled: false,
mode: "screen",
liftPixels: 72,
sizePixels: 32,
});
draw.render([unit]);
pinToggle.disabled = false;
pinToggle.onchange = () => {
draw.setPinPresentation({
...draw.getPinPresentationState().defaults,
enabled: pinToggle.checked,
});
};
});
// Release the map and drawing resources when leaving the page.
window.addEventListener(
"pagehide",
() => {
pinToggle.onchange = null;
td?.destroy();
map.remove();
},
{ once: true },
);Run pnpm exec vite and open the local URL it prints. Replace style with your map style to add a basemap.
The toggle keeps the other pin settings and does not change unit data. Units with their own enabled override keep that setting. See Billboard pins for per-unit settings.
Variable-length draw with Done / Cancel buttons ​
For a variable-length kind, wire Done to the active session's commit() and Cancel to td.cancel(). Use onSession to keep the Done button in sync with canCommit.
import { ignoreAbort } from "@orbat-mapper/tactical-draw";
const pending = td.draw(
{ kind: "fortified-area" },
{
onSession(s) {
doneButton.disabled = !s.canCommit;
s.onChange(() => {
doneButton.disabled = !s.canCommit; // inside the kind's valid range?
});
},
},
);
doneButton.onclick = () => {
const session = td.activeSession;
if (session && "canCommit" in session) session.commit();
};
cancelButton.onclick = () => td.cancel();
void pending
.then(({ graphic }) => {
measures.push(graphic);
td.render(measures);
})
.catch(ignoreAbort);Show a live radius while the user draws a circle ​
onTransientChange reports working geometry for each pointer movement. Use haversineDistance to calculate the great-circle radius in meters. The circle generator itself builds the ring in projected Web Mercator meters, so ground distance is smaller than projected distance at high latitudes.
Place the readout with getPixelFromCoordinate and hide it when phase is "end", which occurs on both commit and cancel.
import { haversineDistance } from "@orbat-mapper/control-measures";
await td.draw(
{ kind: "circle" },
{
onSession(session) {
session.onTransientChange((event) => {
if (event.phase === "end" || event.points.length < 2) {
hideReadout();
return;
}
const meters = haversineDistance(event.points[0], event.points[1]);
const pixel = adapter.getPixelFromCoordinate(event.points[event.pointIndex ?? 1]);
if (pixel) showReadout(pixel, meters);
});
},
},
);The same subscription on an EditSession follows the handle being dragged.
Validate edits and reject invalid changes ​
onChange runs after each completed gesture. Call event.reject() to restore the previous state, close() to accept and finish, or abort() to discard the session. TacticalDraw applies the result after all listeners have run.
await td.edit(measure, {
onSession(session) {
session.onChange((event) => {
if (!isInsideAllowedArea(event.graphic.graphic.controlPoints)) {
event.reject(); // bounce the gesture, keep editing
}
});
},
});Drive style & options from UI controls ​
During an edit, session.setStyle() and session.setOptions() shallow-merge changes into the working copy, rerender the preview, mark the session dirty, and emit onChange.
import { ignoreAbort } from "@orbat-mapper/tactical-draw";
const pending = td.edit(measure);
strokeWidthInput.oninput = () => {
const session = td.activeSession;
if (session && "setStyle" in session) {
session.setStyle({ strokeWidth: Number(strokeWidthInput.value) });
}
};
smoothToggle.onchange = () => {
const session = td.activeSession;
if (session && "setOptions" in session) {
session.setOptions({ smooth: smoothToggle.checked });
}
};
cancelButton.onclick = () => td.cancel();
void pending
.then(({ graphic: next }) => {
const i = measures.findIndex((m) => m.id === next.id);
if (i !== -1) measures.splice(i, 1, next);
td.render(measures);
})
.catch(ignoreAbort);