# Leaflet

> A complete Leaflet map to copy and paste: clusters with counts, pins, name labels, a list for many-people pins and a record panel. It runs in test mode as is.

- **HTML:** https://offendersearch.app/docs/map/leaflet
- **Base URL:** https://api.offendersearch.app
- **Authentication:** `X-API-Key` request header
- **Map API reference as markdown:** https://offendersearch.app/docs/map.md

## 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](https://offendersearch.app/docs/map/access.md).
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

```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

```javascript
// ── 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.

| 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](https://offendersearch.app/docs/map/turn-it-on.md).
- 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](https://offendersearch.app/docs/map/access.md).
- 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.

---

## Related

- Previous: [MapLibre](https://offendersearch.app/docs/map/maplibre.md)
- Next: [Google Maps](https://offendersearch.app/docs/map/google-maps.md)
- Index: [Map API reference](https://offendersearch.app/docs/map.md)
