Offendersearch
Map API Reference

Leaflet

A complete Leaflet map to copy and paste.

Base URL https://api.offendersearch.app

What 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-Match so an unchanged view is free.
  • Draws clusters with their counts. A click on a cluster fits the map to its bbox at its expansionZoom.
  • Draws pins from zoom 13, and name labels from zoom 15. It reads mode and label from 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 notice sentence 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

  1. Save the two files below as index.html and map.js in one folder.
  2. Create a map browser key in the dashboard and add http://localhost:8000 to its allowed sites. Put the key in KEY at the top of map.js. See Access & keys.
  3. Serve the folder: python3 -m http.server 8000. Open http://localhost:8000.
  4. 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

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

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: '&copy; 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.

FunctionWhat 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.
Startmoveend calls viewChanged(), and it is called once to load the first view.
FunctionWhat 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 sandbox line marked TEST MODE.
  • Use a browser key whose allowed sites include your site, or point API at your own server and clear KEY. 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, housingStatus and hasPhoto already on a named pin, and call the records route from a “View record” button.

Product overview