# Google Maps

> A complete Google Maps page to copy and paste: clusters with counts, pins, name labels, a list for many-people pins and a record panel. Starts in test mode.

- **HTML:** https://offendersearch.app/docs/map/google-maps
- **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 the Google Maps JavaScript API: one HTML page and one script. It uses advanced markers, so each marker is an ordinary HTML element.

- 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. Put your Google Maps key in place of `YOUR_GOOGLE_MAPS_KEY` in `index.html`.
4. Serve the folder: `python3 -m http.server 8000`. Open `http://localhost:8000`.
5. 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 — Google Maps</title>
<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="map.js"></script>
  <script async
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_GOOGLE_MAPS_KEY&v=weekly&libraries=marker&callback=initMap"></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';

// ── Google Maps ────────────────────────────────────────────
let map;
let placed = [];

// The Google Maps script calls this when it has loaded.
function initMap() {
  map = new google.maps.Map(document.getElementById('map'), {
    center: { lat: 39.9612, lng: -82.9988 },  // test mode has people here
    zoom: 10,
    mapId: 'DEMO_MAP_ID',  // advanced markers need a map id: use your own
  });
  map.addListener('idle', viewChanged);  // fires when a move has finished
}

function currentView() {
  const b = map.getBounds().toJSON();  // { north, south, east, west }
  return { ...b, zoom: map.getZoom() };
}

function place(lat, lng, dot, onClick) {
  dot.style.position = 'relative';
  dot.style.transform = 'translateY(50%)';  // centre on the point
  const marker = new google.maps.marker.AdvancedMarkerElement({
    map,
    position: { lat, lng },
    content: dot,
    gmpClickable: true,
  });
  marker.addListener('click', onClick);
  placed.push(marker);
}

function clearMarkers() {
  placed.forEach((marker) => { marker.map = null; });
  placed = [];
}

function zoomTo(lat, lng, zoom) {
  map.setCenter({ lat, lng });
  map.setZoom(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.'),
  );
}
```

## How it works

The first part of `map.js` is the only part that knows about Google Maps. Everything from “Load” down is plain JavaScript and is the same in all three library examples.

| Function | What it does with Google Maps |
| --- | --- |
| `initMap()` | Called by the Google Maps script once it has loaded. Creates the map and listens for `idle`. |
| `currentView()` | Reads the box from `map.getBounds().toJSON()` and the zoom from `map.getZoom()`. |
| `place()` | Adds an `AdvancedMarkerElement` with the element as its content, and listens for its click. |
| `clearMarkers()` | Sets `map` to `null` on every marker placed for the last view. |
| `zoomTo()` | `map.setCenter()` and `map.setZoom()`. |

| 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).
- Advanced markers need a map id. `DEMO_MAP_ID` is Google’s test value: create your own map id for production.
- `idle` fires when a pan or a zoom has finished, which is the move-end event for Google Maps.
- 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: [Leaflet](https://offendersearch.app/docs/map/leaflet.md)
- Next: [Billing](https://offendersearch.app/docs/map/billing.md)
- Index: [Map API reference](https://offendersearch.app/docs/map.md)
