--- /dev/null
+// 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;
+ })
+ }
+}
--- /dev/null
+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();