-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
216 lines (191 loc) · 7.51 KB
/
Copy pathserver.js
File metadata and controls
216 lines (191 loc) · 7.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
/**
* SafeRoute — backend proxy + scoring service for the Offendersearch API.
*
* Why a backend at all? Two reasons:
* 1. It keeps your OFFENDERSEARCH_API_KEY on the server, never in the browser.
* 2. It caches identical lookups so overlapping routes don't burn quota twice.
*
* Routing and geocoding (OSRM + Nominatim) run in the browser because they need
* no key. The only thing that must stay server-side is the offender lookup.
*
* The main endpoint scores SEVERAL route alternatives at once, dedupes points
* that overlap between them, and STREAMS progress (newline-delimited JSON) so
* the UI can show a live "N registries searched" counter while it works.
*/
import express from "express";
import dotenv from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
dotenv.config();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3000;
const API_KEY = process.env.OFFENDERSEARCH_API_KEY;
const API_URL = "https://api.offendersearch.app/v1/search";
// --- Scoring knobs (documented in the README) ------------------------------
// A sample point with SATURATION_COUNT offenders inside the radius scores ~0.
// The curve is logarithmic so the first few offenders move the needle more
// than the 40th does.
const SATURATION_COUNT = 100;
// The route score blends the average point score with the single worst point,
// so one dangerous stretch can't hide behind a lot of quiet ones.
const WORST_POINT_WEIGHT = 0.3;
app.use(express.json({ limit: "512kb" }));
app.use(express.static(path.join(__dirname, "public")));
// --- Tiny in-memory cache --------------------------------------------------
// Keyed by rounded coordinates + radius. Rounding to ~3 decimals (~110m) means
// nearby sample points, overlapping alternatives, and repeat runs of the same
// route all reuse one API call.
const cache = new Map();
const CACHE_TTL_MS = 1000 * 45; // 45s — only dedupes points within one scoring pass; every session re-queries the live API (this app is an API showcase — we want the calls)
function cacheKey(lat, lng, radiusMiles) {
return `${lat.toFixed(4)},${lng.toFixed(4)},${radiusMiles}`;
}
async function offenderCount(lat, lng, radiusMiles) {
const key = cacheKey(lat, lng, radiusMiles);
const hit = cache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) {
return { count: hit.count, cached: true };
}
const res = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
},
// perPage:1 keeps the response tiny — we only need counts.records, not the
// individual records. That's also a privacy choice: we never pull, store,
// or plot individual offenders' home locations.
body: JSON.stringify({ query: { lat, lng, radiusMiles, perPage: 1 } }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(
`Offendersearch API ${res.status}: ${text.slice(0, 200)}`
);
err.status = res.status;
throw err;
}
const data = await res.json();
const count = data?.counts?.records ?? 0;
cache.set(key, { count, at: Date.now() });
return { count, cached: false };
}
// Convert a raw offender count into a 0-100 point safety score (higher = safer).
function pointScore(count) {
const norm = Math.log1p(count) / Math.log1p(SATURATION_COUNT);
return Math.round(100 * (1 - Math.min(1, norm)));
}
function blendRoute(counts) {
const scores = counts.map(pointScore);
const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
const worst = Math.min(...scores);
const overall = Math.round(
(1 - WORST_POINT_WEIGHT) * mean + WORST_POINT_WEIGHT * worst
);
return { overall, scores };
}
/**
* POST /api/score-routes (streams newline-delimited JSON)
*
* body: { routes: [ { points: [{lat,lng}, ...] }, ... ], radiusMiles?: number }
*
* Emits, one JSON object per line:
* { type:"start", uniquePoints, totalPoints, routes }
* { type:"progress", done, uniquePoints, apiCalls, cached } (per unique pt)
* { type:"result", routes:[{ index, overallScore, segments, totalOffenders }],
* apiCalls, cached }
* { type:"error", error }
*/
app.post("/api/score-routes", async (req, res) => {
const routes = Array.isArray(req.body?.routes) ? req.body.routes : [];
const radiusMiles = Number(req.body?.radiusMiles) || 1;
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Accel-Buffering", "no");
const send = (obj) => res.write(JSON.stringify(obj) + "\n");
if (!API_KEY) {
send({
type: "error",
error:
"No OFFENDERSEARCH_API_KEY set. Copy .env.example to .env and add a key from https://offendersearch.app",
});
return res.end();
}
const totalPoints = routes.reduce(
(a, r) => a + (Array.isArray(r.points) ? r.points.length : 0),
0
);
if (routes.length === 0 || totalPoints === 0 || totalPoints > 200) {
send({ type: "error", error: "Provide 1–200 sample points across routes." });
return res.end();
}
try {
// 1. Collect every point, dedupe by cache key so overlapping alternatives
// (which share the same start/end corridors) don't get queried twice.
const uniques = new Map(); // key -> {lat,lng}
for (const r of routes) {
for (const p of r.points || []) {
const lat = Number(p.lat);
const lng = Number(p.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
uniques.set(cacheKey(lat, lng, radiusMiles), { lat, lng });
}
}
send({
type: "start",
uniquePoints: uniques.size,
totalPoints,
routes: routes.length,
});
// 2. Query each unique point once, streaming progress as we go.
const countByKey = new Map();
let apiCalls = 0;
let cached = 0;
let done = 0;
for (const [key, { lat, lng }] of uniques) {
const { count, cached: wasCached } = await offenderCount(
lat,
lng,
radiusMiles
);
countByKey.set(key, count);
wasCached ? cached++ : apiCalls++;
done++;
send({ type: "progress", done, uniquePoints: uniques.size, apiCalls, cached });
}
// 3. Reassemble per-route results from the shared count map.
const out = routes.map((r, index) => {
const segments = (r.points || [])
.map((p) => {
const lat = Number(p.lat);
const lng = Number(p.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
const count = countByKey.get(cacheKey(lat, lng, radiusMiles)) ?? 0;
return { lat, lng, count, score: pointScore(count) };
})
.filter(Boolean);
const { overall } = blendRoute(segments.map((s) => s.count));
const totalOffenders = segments.reduce((a, s) => a + s.count, 0);
return { index, overallScore: overall, segments, totalOffenders };
});
send({ type: "result", routes: out, radiusMiles, apiCalls, cached });
res.end();
} catch (err) {
send({ type: "error", error: err.message });
res.end();
}
});
// Lightweight health/config probe the frontend uses to warn if no key is set.
app.get("/api/health", (_req, res) => {
res.json({ ok: true, hasKey: Boolean(API_KEY) });
});
app.listen(PORT, () => {
console.log(`\n SafeRoute running at http://localhost:${PORT}`);
if (!API_KEY) {
console.log(
" ⚠ No OFFENDERSEARCH_API_KEY found. Copy .env.example to .env and add one."
);
}
console.log("");
});