Leaflet
A complete Leaflet map to copy and paste.
Base URL https://api.offendersearch.appWhat this page builds
A complete map with Leaflet: one HTML page and one script.
- 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 — Leaflet</title>
<link rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.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/leaflet@1.9.4/dist/leaflet.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';
// ── Leaflet ────────────────────────────────────────────────
// Test mode has people around this point.
const map = L.map('map').setView([39.9612, -82.9988], 10);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors',
}).addTo(map);
const layer = L.layerGroup().addTo(map);
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.style.position = 'absolute';
dot.style.transform = 'translate(-50%, -50%)'; // centre on the point
const icon = L.divIcon({ html: dot, className: '', iconSize: [0, 0] });
L.marker([lat, lng], { icon }).on('click', onClick).addTo(layer);
}
function clearMarkers() {
layer.clearLayers();
}
function zoomTo(lat, lng, zoom) {
map.setView([lat, lng], 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('moveend', viewChanged);
viewChanged();How it works
The first part of map.js is the only part that knows about Leaflet. Everything from “Load” down is plain JavaScript and is the same in all three library examples.
| Function | What it does with Leaflet |
|---|---|
currentView() | Reads the box from map.getBounds() and the zoom from map.getZoom(). |
place() | Adds an L.marker with an L.divIcon that holds the element, and listens for its click. |
clearMarkers() | Clears the layer group every marker is added to. |
zoomTo() | map.setView() to a centre and a zoom. |
| Start | moveend calls viewChanged(), and it is called once to load the first view. |
| 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 sample uses OpenStreetMap’s public tiles, which are for light use. Use your own tile provider in production.
- 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.