MapLibre
A complete MapLibre GL map to copy and paste. Also fits Mapbox GL.
Base URL https://api.offendersearch.appWhat this page builds
A complete map with MapLibre GL JS: one HTML page and one script. The same code fits Mapbox GL JS: load that library instead, replace maplibregl with mapboxgl, and set your access token and style.
- Loads the view when the map first appears, and again each time it stops moving.
- Waits a quarter of a second after the last move, cancels the request it no longer needs, and sends
If-None-Matchso an unchanged view is free. - Draws clusters with their counts. A click on a cluster fits the map to its
bboxat itsexpansionZoom. - Draws pins from zoom 13, and name labels from zoom 15. It reads
modeandlabelfrom the response, so it holds no zoom numbers of its own. - Opens a list from the groups route for a pin that stands for several people, and for an approximate-area pin.
- Opens a record panel from the records route when a pin or a list row is clicked.
- Shows the
noticesentence on the map.
The sample runs in test mode: the request carries
sandbox=true, so it works before the plan is on and is never billed. To show real data, remove the line marked TEST MODE in map.js.Run it
- Save the two files below as
index.htmlandmap.jsin one folder. - Create a map browser key in the dashboard and add
http://localhost:8000to its allowed sites. Put the key inKEYat the top ofmap.js. See Access & keys. - Serve the folder:
python3 -m http.server 8000. Openhttp://localhost:8000. - Zoom in on the clusters. Pins appear, then names. Click a pin with a number on it for the list, and a pin for the record.
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map — MapLibre</title>
<link rel="stylesheet"
href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css">
<style>
html, body { margin: 0; height: 100%; }
body { font: 14px/1.4 system-ui, sans-serif; }
#map { position: absolute; inset: 0 320px 0 0; }
#panel {
position: absolute; top: 0; right: 0; bottom: 0; width: 320px;
box-sizing: border-box; padding: 16px; overflow: auto;
border-left: 1px solid #d0d5dd; background: #fff;
}
#notice {
position: absolute; left: 12px; bottom: 32px; z-index: 1000;
margin: 0; padding: 6px 10px; border-radius: 6px;
background: #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .3);
}
.dot {
display: grid; place-items: center; box-sizing: border-box;
border: 2px solid #fff; border-radius: 999px; cursor: pointer;
color: #fff; font-weight: 600; white-space: nowrap;
}
.cluster { min-width: 36px; height: 36px; padding: 0 6px; background: #3b5bdb; }
.pin { width: 16px; height: 16px; background: #e03131; }
.group { min-width: 26px; height: 26px; padding: 0 4px; background: #e03131; }
.area {
min-width: 28px; height: 28px; padding: 0 4px;
background: #868e96; border-style: dashed;
}
.label {
position: absolute; left: 100%; margin-left: 4px; padding: 1px 6px;
border-radius: 4px; background: #fff; color: #111; font-weight: 500;
}
.row { display: block; width: 100%; margin: 4px 0; padding: 8px; text-align: left; }
#panel img { max-width: 100%; border-radius: 6px; }
</style>
</head>
<body>
<div id="map"></div>
<p id="notice"></p>
<aside id="panel">
<p id="summary"></p>
<div id="detail"></div>
</aside>
<script
src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="map.js"></script>
</body>
</html>map.js
// ── Settings ───────────────────────────────────────────────
// Calling from the browser: a map browser key (os_map_pk_…)
// whose allowed sites include this page.
// Calling through your own server: set API to '' and KEY to ''.
const API = 'https://api.offendersearch.app';
const KEY = 'os_map_pk_your_key';
// ── MapLibre ───────────────────────────────────────────────
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty',
center: [-82.9988, 39.9612], // test mode has people here
zoom: 10,
});
let placed = [];
function currentView() {
const b = map.getBounds();
return {
west: b.getWest(), south: b.getSouth(),
east: b.getEast(), north: b.getNorth(),
zoom: map.getZoom(),
};
}
function place(lat, lng, dot, onClick) {
dot.addEventListener('click', onClick);
const marker = new maplibregl.Marker({ element: dot });
placed.push(marker.setLngLat([lng, lat]).addTo(map));
}
function clearMarkers() {
placed.forEach((marker) => marker.remove());
placed = [];
}
function zoomTo(lat, lng, zoom) {
map.easeTo({ center: [lng, lat], zoom });
}
// ── Load: one map request per settled view ─────────────────
const cache = new Map(); // request path → { etag, view }
let timer = null;
let pending = null;
function viewChanged() {
clearTimeout(timer);
timer = setTimeout(loadView, 250); // wait until the map settles
}
function call(path, signal, etag) {
const headers = {};
if (KEY) headers['X-API-Key'] = KEY;
if (etag) headers['If-None-Match'] = etag;
return fetch(API + path, { headers, signal });
}
function lng180(x) {
return ((((x + 180) % 360) + 360) % 360) - 180;
}
async function loadView() {
if (pending) pending.abort(); // the previous answer is no longer wanted
pending = new AbortController();
const v = currentView();
const whole = v.east - v.west >= 360;
const box = [
whole ? -180 : lng180(v.west),
Math.max(v.south, -85),
whole ? 180 : lng180(v.east),
Math.min(v.north, 85),
];
const query = new URLSearchParams({
bbox: box.map((n) => n.toFixed(5)).join(','),
zoom: String(Math.floor(v.zoom)),
sandbox: 'true', // TEST MODE: remove this line to show real data
});
const path = '/v1/map/viewport?' + query;
const seen = cache.get(path);
try {
const res = await call(path, pending.signal, seen && seen.etag);
if (res.status === 304) return draw(seen.view); // unchanged: free
if (!res.ok) return showError(res);
const view = await res.json();
if (cache.size > 50) cache.clear();
cache.set(path, { etag: res.headers.get('ETag'), view });
draw(view);
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
}
// ── Draw: clusters, pins, named pins, approximate areas ────
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
function draw(view) {
clearMarkers();
document.getElementById('notice').textContent = view.notice;
let summary = view.total + ' people in this view';
if (view.unplaced.count) {
summary += '. ' + view.unplaced.count + ' more in ' +
view.unplaced.states.join(', ') + ' have no mappable address';
}
document.getElementById('summary').textContent = summary + '.';
view.clusters.forEach(drawCluster); // zoom 3–12
view.markers.forEach(drawPin); // from zoom 13
view.areas.forEach(drawArea);
}
function drawCluster(c) {
const dot = el('div', 'dot cluster', String(c.count));
// The breakdown is already on the cluster: no call on hover.
dot.title = c.breakdown.street + ' of ' + c.count + ' at a street address';
place(c.lat, c.lng, dot, () => {
// Fit the cluster's extent at its expansionZoom: it comes apart.
const [west, south, east, north] = c.bbox;
zoomTo((south + north) / 2, (west + east) / 2, c.expansionZoom);
});
}
function drawPin(m) {
if (m.groupId) { // several people at one address
const dot = el('div', 'dot group', String(m.count));
const title = m.count + ' people at this address';
place(m.lat, m.lng, dot, () => openGroup(m.groupId, title, 1));
return;
}
const dot = el('div', 'dot pin');
// A label is on the pin from zoom 15.
if (m.label) dot.appendChild(el('span', 'label', m.label));
place(m.lat, m.lng, dot, () => openRecord(m.id));
}
function drawArea(a) {
const dot = el('div', 'dot area', String(a.count));
const title = a.count + ' people, approximate area ' + a.label;
place(a.lat, a.lng, dot, () => openGroup(a.groupId, title, 1));
}
// ── The list behind a many-people or approximate-area pin ──
async function openGroup(groupId, title, page) {
const res = await call('/v1/map/groups/' + groupId + '?page=' + page);
if (!res.ok) return showError(res);
const group = await res.json();
const detail = document.getElementById('detail');
if (page === 1) detail.replaceChildren(el('h2', '', title));
for (const person of group.members) {
const row = el('button', 'row', person.label || 'View record');
row.onclick = () => openRecord(person.id);
detail.appendChild(row);
}
if (group.page < group.totalPages) { // each page: one map request
const more = el('button', 'row', 'Show more');
more.onclick = () => {
more.remove();
openGroup(groupId, title, page + 1);
};
detail.appendChild(more);
}
}
// ── The record panel: one record open ──────────────────────
async function openRecord(markerId) {
const res = await call('/v1/map/records/' + markerId);
if (!res.ok) return showError(res);
const { record, notice } = await res.json();
const home = record.addresses[0] || {};
const lines = [home.line1, home.city, home.state, home.zipcode];
const detail = document.getElementById('detail');
detail.replaceChildren(
el('h2', '', record.name.full),
el('p', '', lines.filter(Boolean).join(', ')),
el('p', '', record.offense ? record.offense.crime : ''),
el('p', '', notice),
);
const photo = record.images[0];
if (photo) {
const img = el('img');
img.src = photo.url; // a signed link
img.alt = '';
detail.appendChild(img);
}
}
async function showError(res) {
const body = await res.json().catch(() => ({}));
const d = body.detail || {};
const text = typeof d === 'string' ? d : d.message;
document.getElementById('detail').replaceChildren(
el('p', '', text || 'The map could not be loaded.'),
);
}
// ── Start ──────────────────────────────────────────────────
map.on('load', viewChanged);
map.on('moveend', viewChanged);How it works
The first part of map.js is the only part that knows about MapLibre. Everything from “Load” down is plain JavaScript and is the same in all three library examples.
| Function | What it does with MapLibre |
|---|---|
currentView() | Reads the box from map.getBounds() and the zoom from map.getZoom(). |
place() | Adds a maplibregl.Marker with the element, and listens for its click. |
clearMarkers() | Removes every marker placed for the last view. |
zoomTo() | map.easeTo() to a centre and a zoom. |
| Start | load and moveend both call viewChanged(). |
| Function | What it does |
|---|---|
viewChanged() | Called on every move end. Restarts a short timer so a drag or a zoom makes one request, not twenty. |
loadView() | Cancels the previous request, sends the box and the zoom, and remembers each answer with its ETag. A 304 redraws the remembered answer. |
draw(view) | Clears the map, then draws clusters, markers and areas, the count line and the notice. |
drawCluster(c) | A circle with count. On click it moves to the middle of bbox at expansionZoom, which loads the next view. |
drawPin(m) | A pin. With a groupId it is a many-people pin and opens the list. With a label it shows the name. |
drawArea(a) | A dashed circle for people known only to a ZIP, city or county. It opens the list. |
openGroup() | One page of the groups route. “Show more” loads the next page. |
openRecord() | The records route. Fills the panel with the name, address, offense, notice and photo. |
Going live
- Turn the Map API on. See Turn it on.
- Remove the
sandboxline markedTEST MODE. - Use a browser key whose allowed sites include your site, or point
APIat your own server and clearKEY. See Access & keys. - The base map style in the sample is a public one. Use your own style in production.
- For thousands of points, draw from a GeoJSON source and layers instead of HTML markers. Request
format=geojsonand pass the answer tosource.setData(). - A click on a pin opens the record at once, which is one record open. If you would rather show a small popup first, use the
label,housingStatusandhasPhotoalready on a named pin, and call the records route from a “View record” button.