From: Jakob Cornell Date: Sun, 31 May 2026 17:28:08 +0000 (-0500) Subject: Add walkability analyzer that lists 10 highest scoring parcels X-Git-Url: https://jcornell.net/gitweb/gitweb.cgi?a=commitdiff_plain;h=d7925d299b7a272e0884f06cefa9e003f07089f9;p=ntbd-parcels.git Add walkability analyzer that lists 10 highest scoring parcels --- diff --git a/main.js b/main.js index 7fde9a3..dc7123c 100644 --- a/main.js +++ b/main.js @@ -3,6 +3,8 @@ import AsyncIndexedDB from "./async_indexed_db.js"; // There's no obvious static URL to use to fetch this from ArcGIS, so use a static mirror of it for now. const PARCEL_JSON_PATH = "//jcornell.net/static/parcels/Tax_Parcels_(Assessor_Property_Information).geojson"; +const AMENITIES_PATH = "//jcornell.net/static/parcels/Madison_amenities.json"; + const PARCEL_LOAD_KEY = "parcelsLoaded"; const PICK_ZONINGS = new Set([ @@ -21,6 +23,23 @@ function transposeCoordinates(coordList) { ))); } +function haversine(angle) { + return (1 - Math.cos(angle)) / 2; +} + +function radians(degrees) { + return degrees / 360 * 2 * Math.PI; +} + +// This gives the central angle in radians of the great-circle arc between the +// points. https://en.wikipedia.org/wiki/Haversine_formula#Example +function arcBetween([latA, lonA], [latB, lonB]) { + // h: haversine of the central angle between the points + const h = haversine(radians(latB - latA)) + + Math.cos(radians(latA)) * Math.cos(radians(latB)) * haversine(radians(lonB - lonA)); + return 2 * Math.asin(Math.sqrt(h)); +} + const map = L.map("map"); const statusSpan = document.querySelector("#status > span"); const progressBar = document.querySelector("#status > progress"); @@ -111,6 +130,11 @@ async function asyncMain() { minLon = Math.min(minLon, ...longitudes); maxLat = Math.max(maxLat, ...latitudes); maxLon = Math.max(maxLon, ...longitudes); + // This is for the walkability analysis later. It doesn't go back in the database. + feature.centerPoint = [ + (Math.min(...latitudes) + Math.max(...latitudes)) / 2, + (Math.min(...longitudes) + Math.max(...longitudes)) / 2 + ]; } flyBounds = L.latLngBounds(L.latLng(minLat, minLon), L.latLng(maxLat, maxLon)); } @@ -118,6 +142,44 @@ async function asyncMain() { if (flyBounds !== null) { map.flyToBounds(flyBounds); } + + progressBar.removeAttribute("value"); + statusSpan.innerText = "analyzing walkability" + const amenitiesResponse = await fetch(AMENITIES_PATH); + if (amenitiesResponse.ok) { + const amenities = await amenitiesResponse.json(); + const featuresAndScores = []; + for (const [i, feature] of matches.entries()) { + progressBar.value = i / matches.length; + const arcLengths = Object.values(amenities).map( + locations => Math.min(...locations.map( + point => arcBetween(feature.centerPoint, point))) + ); + arcLengths.sort(); + // score: median across amenity types of arc length to nearest + const mid = arcLengths.length / 2; + const score = arcLengths.length % 2 == 0 ? + (arcLengths[mid] + arcLengths[mid - 1]) / 2 + : arcLengths[Math.floor(mid)]; + featuresAndScores.push([feature, score]); + } + featuresAndScores.sort(([a, aScore], [b, bScore]) => aScore - bScore); + const listElement = document.getElementById("walkable"); + for (const [feature, _] of featuresAndScores.values().take(10)) { + const entryElement = document.createElement("li"); + const linkElement = document.createElement("a"); + entryElement.append(linkElement); + linkElement.setAttribute( + "href", + `https://www.cityofmadison.com/assessor/property/propertydata.cfm?ParcelN=${feature.properties.Parcel}` + ); + linkElement.append(feature.properties.Address); + listElement.append(entryElement); + } + } else { + console.error(amenitiesResponse); + throw new Error("Amenities request failed"); + } statusSpan.innerText = "ready"; } diff --git a/parcels.html b/parcels.html index c79e8fc..60860e7 100644 --- a/parcels.html +++ b/parcels.html @@ -39,6 +39,8 @@

Lot size

+

Most walkable

+