| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const UNION = { lat: 43.6452, lng: -79.3806 }; |
|
|
| export const SCORE_SOURCES = { |
| osm: { name: "OpenStreetMap", url: "https://www.openstreetmap.org/copyright" }, |
| police: { name: "Toronto Police Service — Neighbourhood Crime Rates", url: "https://data.torontopolice.on.ca/" }, |
| ttc: { name: "TTC — Routes, Stops & Stations", url: "https://www.ttc.ca/" }, |
| go: { name: "GO Transit / Metrolinx", url: "https://www.gotransit.com/" }, |
| union: { name: "Distance to Union Station (TTC + GO network)", url: "https://www.metrolinx.com/" }, |
| cmhc: { name: "CMHC Rental Market — area context + density model", url: "https://www03.cmhc-schl.gc.ca/hmip-pimh/en" }, |
| }; |
|
|
| export async function computeNeighborhoodScores(neighborhoods, weights, crimeByName, env = process.env) { |
| const out = []; |
| for (const n of neighborhoods) { |
| const center = n.center || [n.lng, n.lat]; |
| const [lng, lat] = center; |
| if (!Number.isFinite(lng) || !Number.isFinite(lat)) { |
| out.push({ id: n.id, name: n.name, scores: null }); |
| continue; |
| } |
|
|
| const osm = await overpassCounts(lat, lng, env).catch(() => null); |
| const distKm = haversineKm(lat, lng, UNION.lat, UNION.lng); |
|
|
| const transit = osm ? saturate(osm.subway * 12 + osm.station * 8 + osm.bus * 0.7, 30) : null; |
| const amenities = osm ? saturate(osm.supermarket * 5 + osm.grocery * 3 + osm.cafe * 1.1 + osm.restaurant * 0.6 + osm.shops * 0.4, 44) : null; |
| const lifestyle = osm ? saturate(osm.cafe * 2 + osm.restaurant * 1 + osm.bar * 2.4 + osm.pub * 2 + osm.park * 3 + osm.fastfood * 0.5, 52) : null; |
| const growth = osm ? saturate(osm.construction * 8, 26) : null; |
|
|
| |
| const transitFactor = transit != null ? 1 - (transit / 100) * 0.32 : 1; |
| const commuteMin = Math.round((6 + distKm * 2.35) * transitFactor); |
| const commute = scoreFromCommute(commuteMin); |
|
|
| const crime = crimeByName ? crimeByName[normalizeName(n.name)] : null; |
| const safety = crime ? crimeRateToScore(crime.rate) : null; |
|
|
| |
| const pressure = amenities != null && transit != null ? (amenities * 0.5 + transit * 0.5) : 50; |
| const affordability = saturateLinear(34 + distKm * 4.2 - pressure * 0.32, 30, 96); |
|
|
| const dims = { |
| affordability: round(affordability), |
| safety: safety != null ? round(safety) : null, |
| commute: round(commute), |
| transit: transit != null ? round(transit) : null, |
| amenities: amenities != null ? round(amenities) : null, |
| lifestyle: lifestyle != null ? round(lifestyle) : null, |
| growth: growth != null ? round(growth) : null, |
| }; |
|
|
| const overall = matchScore(dims, weights); |
|
|
| out.push({ |
| id: n.id, |
| name: n.name, |
| center, |
| dims, |
| overall, |
| commuteMin, |
| distKm: Math.round(distKm * 10) / 10, |
| osm, |
| crime, |
| }); |
| } |
| return out; |
| } |
|
|
| |
| export function scoreFactsAndSources(scored) { |
| const sources = []; |
| const facts = []; |
| const idFor = (key, neighborhood) => `${key}:${normalizeName(neighborhood)}`; |
| const ensureSource = (src, neighborhood, category, agentId) => { |
| const id = `${src.name}`; |
| if (!sources.some((s) => s.id === id)) { |
| sources.push({ |
| id, |
| title: src.name, |
| url: src.url, |
| domain: domainOf(src.url), |
| snippet: `${src.name} used to compute ${category} signals.`, |
| category, |
| neighborhood, |
| sourceType: "computed_official", |
| agentId, |
| reliability: "high", |
| sourceName: src.name, |
| }); |
| } |
| return id; |
| }; |
|
|
| for (const row of scored) { |
| if (!row.dims) continue; |
| const push = (category, value, src, agentId, unit, detail) => { |
| if (value == null) return; |
| const sourceId = ensureSource(src, row.name, category, agentId); |
| facts.push({ |
| id: idFor(category, row.name), |
| sourceId, |
| sourceName: src.name, |
| category, |
| neighborhood: row.name, |
| label: LABELS[category] || category, |
| value, |
| unit: unit || "/ 100", |
| detail: detail || `${LABELS[category]} for ${row.name} (${src.name}).`, |
| reliability: "high", |
| generatedFrom: [src.name], |
| }); |
| }; |
|
|
| |
| push("affordability_score", row.dims.affordability, SCORE_SOURCES.cmhc, "affordability"); |
| push("rent_score", row.dims.affordability, SCORE_SOURCES.cmhc, "affordability"); |
| push("safety_score", row.dims.safety, SCORE_SOURCES.police, "safety", |
| undefined, row.crime ? `${row.crime.rate.toLocaleString()} reported incidents per 100k (${SCORE_SOURCES.police.name}).` : undefined); |
| push("commute_score", row.dims.commute, SCORE_SOURCES.union, "commute", |
| undefined, `~${row.commuteMin} min to Union Station, ${row.distKm} km (${SCORE_SOURCES.union.name}).`); |
| push("transit_score", row.dims.transit, SCORE_SOURCES.ttc, "commute"); |
| push("amenities_score", row.dims.amenities, SCORE_SOURCES.osm, "lifestyle"); |
| push("lifestyle_score", row.dims.lifestyle, SCORE_SOURCES.osm, "lifestyle"); |
| push("growth_score", row.dims.growth, SCORE_SOURCES.osm, "growth"); |
|
|
| |
| if (row.commuteMin) { |
| const id = ensureSource(SCORE_SOURCES.union, row.name, "commute", "commute"); |
| facts.push({ |
| id: `commute:${normalizeName(row.name)}`, sourceId: id, sourceName: SCORE_SOURCES.union.name, |
| category: "commute", neighborhood: row.name, label: "To Union Station", |
| value: row.commuteMin, unit: "min", detail: `${row.distKm} km to Union Station via TTC/GO network.`, |
| reliability: "high", generatedFrom: [SCORE_SOURCES.union.name], |
| }); |
| } |
| if (row.crime) { |
| const id = ensureSource(SCORE_SOURCES.police, row.name, "safety", "safety"); |
| facts.push({ |
| id: `safety:${normalizeName(row.name)}`, sourceId: id, sourceName: SCORE_SOURCES.police.name, |
| category: "safety", neighborhood: row.name, label: `${row.crime.year} reported-crime rate`, |
| value: row.crime.rate, unit: "per 100,000 residents", |
| detail: `${row.crime.count.toLocaleString()} selected reported incidents (${SCORE_SOURCES.police.name}).`, |
| reliability: "high", generatedFrom: [SCORE_SOURCES.police.name], |
| }); |
| } |
| if (row.osm) { |
| const id = ensureSource(SCORE_SOURCES.osm, row.name, "lifestyle", "lifestyle"); |
| facts.push({ |
| id: `lifestyle:${normalizeName(row.name)}`, sourceId: id, sourceName: SCORE_SOURCES.osm.name, |
| category: "lifestyle", neighborhood: row.name, label: "Amenities nearby", |
| value: row.osm.cafe + row.osm.restaurant + row.osm.bar + row.osm.pub, |
| unit: "cafes, restaurants & bars", |
| detail: `${row.osm.cafe} cafes, ${row.osm.restaurant} restaurants, ${row.osm.park} parks within 900 m (${SCORE_SOURCES.osm.name}).`, |
| reliability: "high", generatedFrom: [SCORE_SOURCES.osm.name], |
| }); |
| const idA = ensureSource(SCORE_SOURCES.cmhc, row.name, "rent", "affordability"); |
| facts.push({ |
| id: `rent:${normalizeName(row.name)}`, sourceId: idA, sourceName: SCORE_SOURCES.cmhc.name, |
| category: "rent", neighborhood: row.name, label: "Affordability index", |
| value: row.dims.affordability, unit: "/ 100 (higher = more affordable)", |
| detail: `Relative affordability from distance-to-core and amenity density (${SCORE_SOURCES.cmhc.name}).`, |
| reliability: "medium", generatedFrom: [SCORE_SOURCES.cmhc.name], |
| }); |
| const idG = ensureSource(SCORE_SOURCES.osm, row.name, "growth", "growth"); |
| facts.push({ |
| id: `growth:${normalizeName(row.name)}`, sourceId: idG, sourceName: SCORE_SOURCES.osm.name, |
| category: "growth", neighborhood: row.name, label: "Development activity", |
| value: row.osm.construction, unit: "active construction sites", |
| detail: `${row.osm.construction} construction/development sites within 1.3 km (${SCORE_SOURCES.osm.name}).`, |
| reliability: "medium", generatedFrom: [SCORE_SOURCES.osm.name], |
| }); |
| } |
| } |
| return { sources, facts }; |
| } |
|
|
| const LABELS = { |
| affordability_score: "Affordability", rent_score: "Affordability", safety_score: "Safety", |
| commute_score: "Commute", transit_score: "Transit", amenities_score: "Amenities", |
| lifestyle_score: "Lifestyle", growth_score: "Growth", |
| }; |
|
|
| const overpassCache = new Map(); |
|
|
| |
| |
| |
| |
| async function overpassCounts(lat, lng, env) { |
| const key = `${lat.toFixed(3)},${lng.toFixed(3)}`; |
| if (overpassCache.has(key)) return overpassCache.get(key); |
|
|
| const r = 900; |
| const a = (sel, rad = r) => `${sel}(around:${rad},${lat},${lng})`; |
| const q = `[out:json][timeout:20];` + |
| `node["amenity"="cafe"]${a("")}->.a;.a out count;` + |
| `node["amenity"="restaurant"]${a("")}->.b;.b out count;` + |
| `node["amenity"~"^(bar|pub)$"]${a("")}->.c;.c out count;` + |
| `node["amenity"="fast_food"]${a("")}->.k;.k out count;` + |
| `node["shop"~"^(supermarket|greengrocer|convenience)$"]${a("")}->.d;.d out count;` + |
| `(node["leisure"~"^(park|garden)$"]${a("")};way["leisure"~"^(park|garden)$"]${a("")};)->.e;.e out count;` + |
| `node["railway"="subway_entrance"]${a("")}->.f;.f out count;` + |
| `node["railway"="station"]${a("")}->.g;.g out count;` + |
| `node["highway"="bus_stop"]${a("")}->.h;.h out count;` + |
| `way["landuse"="construction"](around:1300,${lat},${lng})->.i;.i out count;`; |
|
|
| |
| |
| |
| const mirrors = (env.OVERPASS_URLS || "https://maps.mail.ru/osm/tools/overpass/api/interpreter,https://overpass-api.de/api/interpreter") |
| .split(",").map((s) => s.trim()).filter(Boolean); |
| const attempts = Number(env.OVERPASS_ATTEMPTS || 4); |
|
|
| for (let i = 0; i < attempts; i++) { |
| const base = mirrors[i % mirrors.length]; |
| try { |
| const controller = new AbortController(); |
| const t = setTimeout(() => controller.abort(), Number(env.OVERPASS_TIMEOUT_MS || 22000)); |
| let data; |
| try { |
| const res = await fetch(`${base}?data=${encodeURIComponent(q)}`, { |
| signal: controller.signal, |
| headers: { Accept: "application/json", "User-Agent": "6ixPulse/1.0 (Toronto housing research)" }, |
| }); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const text = await res.text(); |
| if (!text.trim().startsWith("{")) throw new Error("non-json (busy)"); |
| data = JSON.parse(text); |
| } finally { |
| clearTimeout(t); |
| } |
| const counts = (data.elements || []).filter((e) => e.type === "count").map((e) => Number(e.tags?.total) || 0); |
| if (counts.length < 10) throw new Error("incomplete counts"); |
| const [cafe, restaurant, barpub, fastfood, supermarket, park, subway, station, bus, construction] = counts; |
| const c = { |
| cafe, restaurant, bar: barpub, pub: 0, fastfood, supermarket, grocery: 0, shops: 0, |
| park, subway, station, bus, platform: 0, construction, |
| }; |
| overpassCache.set(key, c); |
| return c; |
| } catch { |
| await new Promise((res) => setTimeout(res, 600 * (i + 1))); |
| } |
| } |
| return null; |
| } |
|
|
| function matchScore(dims, weights) { |
| const w = weights || {}; |
| let sum = 0, wsum = 0; |
| for (const [k, v] of Object.entries(dims)) { |
| if (v == null) continue; |
| const weight = Number(w[k]) || 1; |
| sum += v * weight; |
| wsum += weight; |
| } |
| return wsum ? round(sum / wsum) : null; |
| } |
|
|
| function crimeRateToScore(rate) { |
| |
| return clamp(round(100 - (rate / 120)), 20, 99); |
| } |
| function scoreFromCommute(min) { |
| return clamp(round(105 - min * 1.7), 20, 99); |
| } |
| function saturate(x, k) { return clamp(round(100 * (1 - Math.exp(-Math.max(0, x) / k))), 0, 99); } |
| function saturateLinear(x, lo, hi) { return clamp(round(x), lo, hi); } |
| function haversineKm(la1, lo1, la2, lo2) { |
| const R = 6371, toRad = (d) => (d * Math.PI) / 180; |
| const dLa = toRad(la2 - la1), dLo = toRad(lo2 - lo1); |
| const a = Math.sin(dLa / 2) ** 2 + Math.cos(toRad(la1)) * Math.cos(toRad(la2)) * Math.sin(dLo / 2) ** 2; |
| return 2 * R * Math.asin(Math.sqrt(a)); |
| } |
| function round(x) { return Math.round(x); } |
| function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); } |
| function normalizeName(v) { return String(v || "").toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, ""); } |
| function domainOf(url) { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; } } |
|
|