]> jcornell.net Git - ntbd-parcels/.git/commitdiff
Add parcel app that filters by zoning code and size main
authorwww-data <www-data@new.jcornell.net>
Tue, 3 Mar 2026 02:01:09 +0000 (02:01 +0000)
committerwww-data <www-data@new.jcornell.net>
Tue, 3 Mar 2026 03:12:12 +0000 (03:12 +0000)
async_indexed_db.js [new file with mode: 0644]
main.js [new file with mode: 0644]
parcels.html [new file with mode: 0644]

diff --git a/async_indexed_db.js b/async_indexed_db.js
new file mode 100644 (file)
index 0000000..1bc8d46
--- /dev/null
@@ -0,0 +1,119 @@
+// https://github.com/kennygomdori/AsyncIndexedDB
+
+export default class AsyncIndexedDB {
+    // return opened database with schema definitions given.
+    constructor(db_name, schema, version) {
+        this.name = db_name;
+        this.schema = schema; // upgrade callback
+        if (version) this.version = version;
+        this.db = null;
+        Object.seal(this);
+    }
+
+    open() {
+        return new Promise((resolve, reject) => {
+            const db_request = self.indexedDB.open(this.name, this.version);
+            const schema = this.schema;
+            db_request.onerror = (event) => reject(event);
+            db_request.onsuccess = (event) => {
+                this.db = db_request.result;
+                resolve(this);
+            }
+            db_request.onupgradeneeded = function (event) {
+                schema(this.result)
+            };
+        })
+    }
+
+    // returns an ObjectStore proxy whose methods are awaitable.
+    query(objectStoreName, mode = "readwrite", oncomplete, onerror) {
+        // e.g. await this.query().getAll()
+        const transaction = this.db.transaction(objectStoreName, mode);
+        if (oncomplete instanceof Function) transaction.oncomplete = oncomplete;
+        if (onerror instanceof Function) transaction.onerror = onerror;
+        return AsyncIndexedDB.proxy(transaction.objectStore(objectStoreName))
+    }
+
+    static proxy(obj) {
+        // convert an IDBObjectStore and IDBIndex to an ObjectStore proxy whose methods are awaitable.
+        return new Proxy(obj, {
+            get: function (obj, prop) {
+                if (!(obj[prop] instanceof Function)) return obj[prop];
+                return function (...params) {
+                    const request = obj[prop](...params);
+                       if (request instanceof IDBIndex)
+                               return AsyncIndexedDB.proxy(obj.index(...params));
+                    // When a cursor supposed to be returned, return an AsyncIterable instead.
+                    // e.g. for await (let {key, value, primaryKey} of await this.query().openCursor()) { ... }
+                    return new Promise((resolve, reject) => {
+                        request.onsuccess = e => {
+                            let result = request.result;
+                            if (result instanceof IDBCursor)
+                                resolve({
+                                    request, 
+                                       cursor: result,
+                                    [Symbol.asyncIterator]: async function* () {
+                                        let promise;
+                                        while (result) {
+                                            yield {key: result.key, value: result.value, primaryKey: result.primaryKey};
+                                            promise = new Promise((resolve, reject) => {
+                                                request.onsuccess = e => resolve()
+                                                request.onerror = e => reject(e);
+                                            });
+                                            result.continue();
+                                            await promise;
+                                            result = request.result
+                                        }
+                                    }
+                                });
+                            else
+                                   // functions that do not return a cursor or an index are just turned into Promises.
+                                   resolve(result);
+                        }
+                        request.onerror = e => reject(e);
+                    });
+                }
+            },
+        });
+    }
+
+    async export(query, keyRange, count) {
+        // Serialize IndexedDB in [[objectStoreName1, [..objects], [objectStoreName2, [..objects], ...] that can be easily turned into a Map.
+        return JSON.stringify(await Promise.all([...this.db.objectStoreNames].map(
+            async objectStorename => {
+                const query = await this.query(objectStorename);
+                if (query.keyPath === null)
+                    return [objectStorename, query.getAll(keyRange, count)]
+            }
+        )))
+    }
+
+    async import(data, keyPaths) {
+        // Need to back up the original before import in case of error.
+        // data validation may be required.
+        data = JSON.parse(data);
+        await Promise.all(data.map(async ([objectStoreName, entries]) => {
+            let query = this.query(objectStoreName);
+            if (query.keyPath === null) {
+                let keyPath = keyPaths[objectStoreName];
+                if (keyPath === undefined)
+                    throw `ObjectStore '${query.name}' does not have a KeyPath. Call import(data, {[objectStoreName]:[keyPath]}).`
+                for (let obj of entries) {
+                    const key = obj[keyPath];
+                    if (key !== undefined) throw `ObjectStore '${query.name}' entry '${obj}' is missing its key.`
+                    await query.put(obj, key);
+                }
+            } else {
+                for (let obj of entries) await query.put(obj);
+            }
+        }))
+    }
+
+    async clear() {
+        return new Promise((resolve, reject) => {
+            const request = self.indexedDB.deleteDatabase(this.name);
+            request.onsuccess = resolve;
+            request.onerror = reject;
+        })
+    }
+}
diff --git a/main.js b/main.js
new file mode 100644 (file)
index 0000000..2fc1445
--- /dev/null
+++ b/main.js
@@ -0,0 +1,91 @@
+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 PARCEL_LOAD_KEY = "parcelsLoaded";
+
+function transposeCoordinates(coordList) {
+       return coordList.map(
+               poly => poly.map(
+                       component => component.map(
+                               ([lon, lat]) => [lat, lon]
+       )));
+}
+
+async function asyncMain() {
+       const overlay = document.getElementById("loading-overlay");
+
+       const onVersionUpdate = async (db) => {
+               const parcelStore = db.createObjectStore("features", {keyPath: "properties.OBJECTID"});
+       };
+       const db = new AsyncIndexedDB("ntbd_parcels", onVersionUpdate, 1);
+       await db.open();
+
+       if (!(PARCEL_LOAD_KEY in localStorage)) {
+               overlay.innerText = "downloading parcel data";
+               const geoJsonResponse = await fetch(PARCEL_JSON_PATH);
+               if (geoJsonResponse.ok) {
+                       overlay.innerText = "parsing dataset";
+                       const geoJson = await geoJsonResponse.json();
+                       overlay.innerText = "saving parcels to persistent storage";
+                       for (const feature of geoJson.features) {
+                               await db.query("features").put(feature);
+                       }
+                       localStorage[PARCEL_LOAD_KEY] = "1";
+               } else {
+                       console.error(geoJsonResponse);
+                       alert("Parcel data request failed");
+               }
+       }
+
+       const store = db.query("features", "readonly");
+       const PICK_ZONINGS = new Set(["TR-V2", "TR-U1", "TR-U2"]);
+       overlay.innerText = "filtering & rendering parcels";
+       let matchCount = 0;
+       let noZoningCount = 0;
+       let noLotSizeCount = 0;
+       // store.getAll doesn't work for some reason.
+       for await (const {value: feature} of await store.openCursor()) {
+               if (feature.geometry.type == "MultiPolygon") {
+                       const zoningParts = (feature.properties.ZoningAll ?? "").split(", ");
+                       const zoningMatch = zoningParts.length && new Set(zoningParts).intersection(PICK_ZONINGS).size;
+                       const lotSizeAcres = feature.properties.LotSize / 43560;
+                       if (zoningMatch && lotSizeAcres >= 0.5 && lotSizeAcres <= 1.5) {
+                               const leafletCoords = transposeCoordinates(feature.geometry.coordinates);
+                               L.polygon(leafletCoords, {color: "blue", weight: 1}).addTo(map);
+                               matchCount += 1;
+                       }
+                       if (!zoningParts.length) {
+                               noZoningCount += 1;
+                       }
+                       if (!feature.properties.LotSize) {
+                               noLotSizeCount += 1;
+                       }
+               }
+               else {
+                       console.warn("Skipping %s feature geometry", feature.geometry.type);
+               }
+       }
+       console.log(
+               "Searched %d parcels: %d matches, %d with no zoning, %d with no size",
+               await store.count(),
+               matchCount,
+               noZoningCount,
+               noLotSizeCount,
+       );
+
+       overlay.style["display"] = "none";
+}
+
+const map = L.map("map");
+const tileLayer = L.tileLayer(
+       "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+       {maxZoom: 19, attribution: "&copy; OpenStreetMap"},
+);
+tileLayer.addTo(map);
+
+// Snap to a view of Madison.
+map.setView([43.073051, -89.401230], 13);
+
+asyncMain();
diff --git a/parcels.html b/parcels.html
new file mode 100644 (file)
index 0000000..1c338d5
--- /dev/null
@@ -0,0 +1,32 @@
+<!doctype html>
+<html>
+       <head>
+               <title>NTBD Parcels</title>
+               <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+                       integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
+                       crossorigin=""/>
+               <style>
+                       #loading-overlay {
+                               position: absolute;
+                               top: 0;
+                               left: 0;
+                               right: 0;
+                               bottom: 0;
+                               z-index: 1;
+                               background: rgba(0, 0, 0, 0.7);
+                               color: white;
+                               font-size: 2rem;
+                               text-align: center;
+                               padding-top: 3rem;
+                       }
+               </style>
+       </head>
+       <body style="margin: 0; background-color: black;">
+               <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"s
+                       integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
+                       crossorigin=""></script>
+               <div id="map" class="loading" style="width: 100%; height: 100vh; z-index: 0"></div>
+               <div id="loading-overlay">loading</div>
+               <script src="main.js" type="module"></script>
+       </body>
+</html>