From d25175d0104e8907f50ebacfc62fadd92779bf68 Mon Sep 17 00:00:00 2001 From: www-data Date: Tue, 3 Mar 2026 02:01:09 +0000 Subject: [PATCH 1/1] Add parcel app that filters by zoning code and size --- async_indexed_db.js | 119 ++++++++++++++++++++++++++++++++++++++++++++ main.js | 91 +++++++++++++++++++++++++++++++++ parcels.html | 32 ++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 async_indexed_db.js create mode 100644 main.js create mode 100644 parcels.html diff --git a/async_indexed_db.js b/async_indexed_db.js new file mode 100644 index 0000000..1bc8d46 --- /dev/null +++ b/async_indexed_db.js @@ -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 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: "© 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 index 0000000..1c338d5 --- /dev/null +++ b/parcels.html @@ -0,0 +1,32 @@ + + + + NTBD Parcels + + + + + +
+
loading
+ + + -- 2.39.5