Offendersearch
Map API Reference

MapLibre

A complete MapLibre GL map to copy and paste. Also fits Mapbox GL.

Base URL https://api.offendersearch.app

What 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-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 — 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

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.

FunctionWhat 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.
Startload and moveend both call viewChanged().
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 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=geojson and pass the answer to source.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, housingStatus and hasPhoto already on a named pin, and call the records route from a “View record” button.

Product overview