A safety-scored navigation app built on the Offendersearch API.
Enter a start and a destination. SafeRoute pulls several driving alternatives, samples points every ~0.5 mi along each, checks how many registered sex offenders live near each point, and turns that into a 0–100 safety score per route — so you can see the tradeoff and pick "a few minutes slower, much safer." Routes show as ranked cards (Waze-style), color-graded on a full-screen dark map, with a live counter of how many registries are being searched.
Coverage spans all 58 US sex-offender registries — every US state, DC and the territories — behind one API call per sampled point.
Not a consumer report. SafeRoute reports aggregate public-record counts for personal awareness and safety only. It is not for FCRA-covered decisions (housing, employment, credit, insurance) and must never be used to harass, target, or make decisions about any individual.
| Piece | Tech | Key needed? |
|---|---|---|
| Map + basemap | Leaflet + OpenStreetMap / CARTO tiles | No |
| Geocoding (address → lat/lng) | Nominatim | No |
| Driving route | OSRM public demo server | No |
| Offender counts | Offendersearch API | Yes (free) |
The only keyed call is the offender lookup, and it happens on a tiny Node/Express backend so your API key never reaches the browser.
This app runs on the Offendersearch API. The safety score is the Offendersearch data — without a key there are no counts, no scores, and no ranking. SafeRoute talks to the API's exact request and response shape directly (no generic "data provider" layer); it's built to be the shortest path from
git cloneto a working Offendersearch integration you can extend.
Get a free key at offendersearch.app — the free tier includes 25 searches to start, enough to try SafeRoute on a few routes (see the API-call budget below). Need more? Grab a key with higher quota from the dashboard. The app requires this key to function.
# 1. Clone / fork, then:
cd examples/safe-route
npm install
# 2. Add your key
cp .env.example .env
# then edit .env and set:
# OFFENDERSEARCH_API_KEY=os_live_xxxxxxxxxxxxxxxx
# 3. Run
npm startOpen http://localhost:3000.
The app reads the key from the
OFFENDERSEARCH_API_KEYenvironment variable only — there is no key in the source, and.envis gitignored. Never commit a key.
This is the whole integration — one endpoint, one request shape, one response shape. SafeRoute talks to it directly (no wrapper/abstraction), so this section doubles as a complete, copy-pasteable reference for your own code.
POST https://api.offendersearch.app/v1/search
X-API-Key: <your key> # from env; get one at https://offendersearch.app
Content-Type: application/json
A single radius search around a point. Note: perPage goes inside query.
{
"query": {
"lat": 29.76,
"lng": -95.37,
"radiusMiles": 1,
"perPage": 1
}
}| Field | Type | Notes |
|---|---|---|
query.lat / query.lng |
number | Center of the search. |
query.radiusMiles |
number | Search radius in miles. |
query.perPage |
number | Records per page. Use 1 when you only need the count — it keeps responses tiny and, for SafeRoute, avoids pulling individual records at all. |
{
"searchId": "srch_…",
"status": "complete",
"counts": {
"records": 287, // ← total offenders within the radius
"sourcesComplete": 58, // ← all 58 US registries answered
"sourcesQueried": 58
},
"records": [
{ "addresses": [ { "lat": 29.75, "lng": -95.36 } ] }
]
}| Field | Type | What SafeRoute does with it |
|---|---|---|
counts.records |
number | The scoring input. Total registrants within the radius — no paging needed. |
counts.sourcesComplete |
number | How many of the 58 registries answered (coverage check). |
records[].addresses[].lat / .lng |
number | Per-record coordinates. SafeRoute does not use these — it plots aggregate density only, never individual home locations. |
const res = await fetch("https://api.offendersearch.app/v1/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.OFFENDERSEARCH_API_KEY,
},
body: JSON.stringify({ query: { lat, lng, radiusMiles: 1, perPage: 1 } }),
});
const data = await res.json();
const offendersNearby = data.counts.records; // that's the whole thingThat counts.records integer is the entire safety signal. Everything else in
this repo — sampling, alternatives, weighting, the map — is presentation built on
top of that one number. Full reference:
offendersearch.app/docs.
For each sample point along each route, the backend calls the Offendersearch
/v1/search endpoint and reads counts.records — the total number of
registered offenders within the search radius. It never pages through the
individual records (privacy + speed): one call returns the count it needs.
-
Get route alternatives. OSRM is asked for up to 3 driving routes (
alternatives=3) between the two points. -
Sample each route. Every route polyline is divided into points spaced ~0.5 mi apart (5–26 per route).
-
Count nearby offenders. For each point, query offenders within a small radius (default 1 mile). Points that overlap between alternatives (shared start/end corridors) are deduped by rounded coordinates so they're queried once, and progress is streamed to the UI as a live call count.
-
Score each point. A raw count
cbecomes a 0–100 point score on a logarithmic curve, so the first few offenders matter more than the 40th:pointScore = 100 × (1 − min(1, ln(1 + c) / ln(1 + SATURATION_COUNT)))with
SATURATION_COUNT = 100(a point near ~100 offenders scores ≈ 0). -
Blend into a route score. The overall score leans on the average point score but keeps weight on the single worst point, so one dangerous stretch can't hide behind many quiet ones:
overall = 0.7 × mean(pointScores) + 0.3 × min(pointScores) -
Weight it — registry proximity is one optional signal, not a verdict. A 4-way control lets the user decide how much the registry layer counts:
Mode Effect on score Off Registry not factored in, not shown Show only Marked on the map, but not scored Prefer avoiding (default) Moderate penalty (weight 0.5) Strongly avoid Full penalty (weight 1.0) The weight
wtransforms each route's registry-only score:weighted = 100 − w × (100 − registryScore). Because that's affine, changing the toggle recomputes ranking instantly on the client with zero new API calls. A road near registrants is never labeled "dangerous" — it's registry proximity, one input the user chooses to weigh. -
Visualize. The chosen route is drawn segment-by-segment, colored by the worse of its two endpoints (green → yellow → orange → red); other alternatives stay dimmed in their own colors. A sized dot at each sample point shows relative density. Only aggregate counts are ever plotted — never an individual offender's home location.
Both knobs (SATURATION_COUNT, the 0.3 worst-point weight) live at the top of
server.js — tune them to taste.
| Score | Meaning |
|---|---|
| 80–100 | Low exposure |
| 60–79 | Moderate exposure |
| 40–59 | Elevated exposure |
| 0–39 | High exposure |
SafeRoute scores every alternative at ~1 call per sampled point, and sampling every ~0.5 mi means a lot of points — this is the showcase. It's what a real Offendersearch integration looks like under load.
Scoring 3 alternatives ≈ 40–90 API calls for a typical metro trip (each route is sampled densely — roughly every 0.3 mi, 8–40 points per route — so the safety profile is fine-grained). The live loading counter shows the running total as it works, and the card header reports exactly how many calls the run used.
This app is a showcase of the Offendersearch API, so it deliberately keeps the call volume high:
- Minimal caching. Counts are cached only briefly (~45 s) and only to dedupe points within a single scoring pass — every new session re-queries the live API.
- Dense sampling. ~1 sample every 0.3 mi (8–40 points/route) for a detailed per-segment safety profile.
Each call is a real query against every US registry — so a heavier trip means a richer picture. For real use, get a key with more quota at offendersearch.app (the free tier is 25 searches).
On the free 25-search tier that's roughly one full 3-route scoring pass — get a key with more quota at offendersearch.app for real use. That heavy usage is the point: it's a live demo of what the API does at scale.
SafeRoute scores on sex-offender registry data only today. The criminal layer
is the same Offendersearch API — not a third party. When the Offendersearch
criminal-records scope launches, you add a second call to the same
POST /v1/search endpoint with a criminal scope and blend its counts.records
into the same per-segment penalty. One API, one data structure, one key powers
every safety layer. No FBI feed, no city portal, no SpotCrime — everything stays
on Offendersearch infrastructure:
-
Add a second call to the same endpoint. In
offenderCount()inserver.js, issue a parallelPOST /v1/searchwith the criminal scope — identical{ query: { lat, lng, radiusMiles, perPage: 1 } }body, identicalcounts.recordsin the response. Same key, same client. Keep it a separate cache key so the two scopes cache independently. -
Score each independently. Run the existing
pointScore()on eachcounts.recordsto getregistryScoreandcriminalScoreper point. -
Blend into the same penalty. Combine them with a weight you choose, e.g.:
const blended = 0.5 * registryScore + 0.5 * criminalScore;
Then feed
blendedinto the same mean + worst-point aggregation and the same registry-proximity weighting the UI already exposes. -
Surface it in the UI. Extend the existing weight control (registry / criminal / combined). The map coloring already keys off the point score, so it needs no change.
Nothing else moves — routing, sampling, caching, streaming, and visualization all stay the same, because it's the same request/response shape you already speak. See the API reference at offendersearch.app/docs.
This demo scores route alternatives that a router already produced — a pragmatic approach that works with any routing service and shows the tradeoff clearly. But it's only step one.
The production approach is edge-costing. A router like Valhalla on OpenStreetMap data supports dynamic, per-edge (per-road-segment) costing. Instead of scoring a handful of finished routes, you assign every road segment a cost and let the router generate the lowest-cost path from the ground up:
edge_cost = travel_time
+ registry_penalty // Offendersearch offender-proximity layer
+ crime_penalty // future: Offendersearch criminal scope
+ lighting_penalty // future: nighttime / street-lighting layer
+ isolation_penalty // future: low-foot-traffic / seclusion layer
In that model:
- Registry proximity is one weighted layer among several — exactly the framing of the toggle in this demo, but applied at the edge level so the router can route around a hotspot instead of just reporting it.
- The Offendersearch API is the offender-proximity layer (and, as above, the
criminal-records layer too) — the same
/v1/searchcounts, precomputed per edge or fetched along candidate corridors and cached. - Every safety signal is a weight the user controls, never a verdict. The router optimizes a blended cost; the human decides how much each layer matters.
SafeRoute is the on-ramp to that: it proves the Offendersearch data is the safety signal, in a form you can fork today and grow into full edge-costed routing.
- Google Maps instead of Leaflet/OSM. SafeRoute uses Leaflet + OpenStreetMap
so it runs with zero keys and zero cost. To use Google Maps, swap the map init
in
public/app.jsfor the Google Maps JS SDK, replacegetRoute()with the Directions API, andgeocode()with the Geocoding API (both need a Google API key). The scoring backend is unchanged — it only cares about a list of{lat, lng}points. - Different routing/geocoding. Any service that yields a lat/lng list works;
sampleAlong()and/api/score-routesdon't care where the polyline came from.
safe-route/
├── server.js Express proxy + scoring (hides the API key, caches, streams)
├── package.json
├── .env.example Copy to .env and add OFFENDERSEARCH_API_KEY
├── LICENSE MIT
└── public/
├── index.html Navigation-style single-page UI
├── styles.css Styling
└── app.js Geocode → alternatives → sample → score → rank → visualize
| Method | Path | Purpose |
|---|---|---|
POST |
/api/score-routes |
Body { routes: [{ points: [{lat,lng}] }], radiusMiles }. Dedupes points shared across routes, then streams newline-delimited JSON: start → many progress (live call count) → result (per-route counts + blended score) |
GET |
/api/health |
{ ok, hasKey } — used to warn if no key is configured |
The upstream Offendersearch call it proxies:
curl -X POST https://api.offendersearch.app/v1/search \
-H "X-API-Key: $OFFENDERSEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": {"lat": 29.76, "lng": -95.37, "radiusMiles": 1, "perPage": 1}}'
# → { "counts": { "records": 287, "sourcesComplete": 58, ... }, "records": [...] }SafeRoute presents aggregate counts of public-record registry data for personal awareness and safety only. It is not a consumer report, is not governed by and must not be used for any purpose under the Fair Credit Reporting Act (FCRA) — including housing, employment, credit, or insurance decisions — and must never be used to identify, harass, target, or make decisions about any individual. Registry data may be incomplete or out of date; verify anything important with the official source.
