Google Maps
A complete Google Maps page to copy and paste.
Base URL https://api.offendersearch.appWhat 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-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. - Put your Google Maps key in place of
YOUR_GOOGLE_MAPS_KEYinindex.html. - 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 — 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
// ── 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.
- 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. - Advanced markers need a map id.
DEMO_MAP_IDis Google’s test value: create your own map id for production. idlefires 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,housingStatusandhasPhotoalready on a named pin, and call the records route from a “View record” button.