]> jcornell.net Git - ntbd-parcels.git/commitdiff
Add walkability analyzer that lists 10 highest scoring parcels main
authorJakob Cornell <jakob+gpg@jcornell.net>
Sun, 31 May 2026 17:28:08 +0000 (12:28 -0500)
committerJakob Cornell <jakob+gpg@jcornell.net>
Sun, 31 May 2026 17:28:08 +0000 (12:28 -0500)
main.js
parcels.html

diff --git a/main.js b/main.js
index 7fde9a3d49e384d8c2cf4cbb335c923839a73f49..dc7123cc88244e26a1a9c23d91ab3b24cfd0d6fb 100644 (file)
--- 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";
 
 // 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([
 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");
 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);
                        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));
        }
                }
                flyBounds = L.latLngBounds(L.latLng(minLat, minLon), L.latLng(maxLat, maxLon));
        }
@@ -118,6 +142,44 @@ async function asyncMain() {
        if (flyBounds !== null) {
                map.flyToBounds(flyBounds);
        }
        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";
 }
 
        statusSpan.innerText = "ready";
 }
 
index c79e8fc988fb5c4fb73c18955f61c8fbca634f51..60860e7e9b1c459b0422ef8219dd465aa23d929c 100644 (file)
@@ -39,6 +39,8 @@
                                        <p id="picked-zones"></p>
                                        <h2>Lot size</h2>
                                        <p id="lot-size"></p>
                                        <p id="picked-zones"></p>
                                        <h2>Lot size</h2>
                                        <p id="lot-size"></p>
+                                       <h2>Most walkable</h2>
+                                       <ol id="walkable"></ol>
                                </div>
                                <div id="status">
                                        <span style="display: block"></span>
                                </div>
                                <div id="status">
                                        <span style="display: block"></span>