diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..c946c747 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 39691fcd..04c8bad1 100644 --- a/README.md +++ b/README.md @@ -1 +1,46 @@ -Add a readme for your dashboard here. Include content overview, data citations, and any relevant technical details. \ No newline at end of file +# ADU Query Tool + +Enter a Philadelphia address to see its zoning and whether an Accessory Dwelling Unit (ADU) is permitted. + +## Purpose + +* **Users:** Homeowners and developers +* **Decision:** Can I build an ADU on this property? +* **Data:** Zoning base districts, zoning overlays, historic register, and the L&I ADU requirements + +## How it works + +1. Search an address; the map drops a pin. +2. The popup checks base district, overlays and historic status automatically. +3. It follows the L&I flowchart, asking for structure type and lot size. +4. If permitted, confirm the Additional Code Requirements checklist. + +## Sources + +All data accessed October 16, 2026. + +* **ADU rules:** City of Philadelphia, Department of Licenses and Inspections. (2022). *Accessory Dwelling Unit Checklist* (PZ_004_INF, rev. 3/8/2022). https://www.phila.gov/media/20220308130807/PZ_004_INF_Accessory-Dwelling-Unit-Checklist-rev-3.8.2022.pdf +* **Zoning base districts** (`Zoning_BaseDistricts`): Philadelphia City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org +* **Zoning overlays** (`Zoning_Overlays`): Philadelphia City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org +* **Historic districts** (`historicdistricts_local`): Philadelphia Historical Commission / City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org/datasets/philadelphia-registered-historic-districts (last updated 2017) +* **Historic sites** (`historic_sites_philreg`): Philadelphia Historical Commission / City Planning Commission, via OpenDataPhilly. https://www.opendataphilly.org/datasets/philadelphia-registered-historic-sites (last updated 2017) +* **Basemap and geocoding:** Mapbox +* **Colors** Color Brewer 2.0 + +## Tech + +Mapbox GL JS, vanilla JavaScript, HTML, CSS. + +## Run locally + +1. Add your Mapbox access token in `js/main.js`. +2. Open `index.html` with a local server (e.g. VS Code Live Server). + +## Limits + +* Historic status, structure type and lot size need user confirmation. +* Informational only, not legal advice. L&I makes the final determination. + +## Credits + +Built by Chloe Alimurong. Code written with assistance from Claude (Anthropic). \ No newline at end of file diff --git a/adu-query-tool/.DS_Store b/adu-query-tool/.DS_Store new file mode 100644 index 00000000..39a61bd9 Binary files /dev/null and b/adu-query-tool/.DS_Store differ diff --git a/adu-query-tool/css/styles.css b/adu-query-tool/css/styles.css new file mode 100644 index 00000000..d66e3d88 --- /dev/null +++ b/adu-query-tool/css/styles.css @@ -0,0 +1,101 @@ +html { + font-size: 22px; + height: 100%; +} + +body { + height: 100%; + padding: 0; + margin: 0; + font-family: Didot, serif; +} + +main { + height: 100%; +} + +.map-section { + position: relative; /* lets the search card and legend sit on top of the map */ + height: 100%; +} + +.map-container { + position: absolute; + inset: 0; /* fills .map-section */ +} + +.map { + width: 100%; + height: 100%; +} + +.mapboxgl-popup { + z-index: 20; +} + +/* Search card */ + +.map-search-controls { + position: absolute; + top: 16px; + left: 16px; + z-index: 10; /* above the map */ + width: min(560px, calc(100% - 32px)); + box-sizing: border-box; + margin: 0; + padding: 16px 18px; + background: #fff; + border: 0; + border-radius: 8px; + box-shadow: 0 1px 6px rgb(0 0 0); +} + +.search-title { + margin: 0 0 4px; + font-size: 1.15rem; + line-height: 1.2; +} + +.search-help { + margin: 0 0 8px; + font-size: 0.85rem; + color: #555; +} + +.map-search-controls input { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; /* taller box */ + font-size: 1.1rem; /* bigger typed text */ +} + +.map-search-controls, +.map-search-controls input { + font-family: Didot, "Bodoni MT", serif; +} + +.mapboxgl-popup button, +.mapboxgl-popup input { + font-family: inherit; +} + +/* keeps the legend for screen readers without showing it */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.mapboxgl-popup-content { + padding: 16px 18px; + border-radius: 10px; + box-shadow: 0 4px 16px rgb(0 0 0 / 35%); +} + +.mapboxgl-popup-close-button { + font-size: 22px; + padding: 4px 10px; +} diff --git a/mockup/img/location.png b/adu-query-tool/img/location.png similarity index 100% rename from mockup/img/location.png rename to adu-query-tool/img/location.png diff --git a/mockup/img/location.svg b/adu-query-tool/img/location.svg similarity index 100% rename from mockup/img/location.svg rename to adu-query-tool/img/location.svg diff --git a/adu-query-tool/index.html b/adu-query-tool/index.html new file mode 100644 index 00000000..8eb92dfe --- /dev/null +++ b/adu-query-tool/index.html @@ -0,0 +1,44 @@ + + + + + + + + ADU Query + + + + + + + + +
+
+
+ Address search +

Accessory Dwelling Unit Query Tool

+

+ Enter a Philadelphia address to see its zoning and whether an ADU is permitted. +

+ +
+ +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/adu-query-tool/js/.RData b/adu-query-tool/js/.RData new file mode 100644 index 00000000..5cf65efc Binary files /dev/null and b/adu-query-tool/js/.RData differ diff --git a/adu-query-tool/js/.Rhistory b/adu-query-tool/js/.Rhistory new file mode 100644 index 00000000..544b09ea --- /dev/null +++ b/adu-query-tool/js/.Rhistory @@ -0,0 +1,111 @@ +#| label: setup +#| echo: false +#| message: false +library(tidyverse) +library(tidycensus) +library(sf) +library(caret) +library(spdep) +library(nngeo) +options(scipen = 999) +#| label: helpers +check_na <- function(formula, df) { +vars <- all.vars(formula) +bad <- vars[sapply(vars, function(v) any(is.na(df[[v]])))] +if (length(bad) > 0) { +stop("These variables have NAs: ", paste(bad, collapse = ", "), +". Fill them or choose another variable (Rule 1).") +} +} +# 10-fold CV RMSE, same folds every time +cv_rmse <- function(formula, data) { +df <- if (inherits(data, "sf")) st_drop_geometry(data) else data +check_na(formula, df) +set.seed(5080) +fit <- train(formula, data = df, method = "lm", +trControl = trainControl(method = "cv", number = 10)) +fit$results$RMSE +} +# Each house's prediction from the fold where it was held out +cv_pred <- function(formula, data) { +df <- if (inherits(data, "sf")) st_drop_geometry(data) else data +check_na(formula, df) +set.seed(5080) +fit <- train(formula, data = df, method = "lm", +trControl = trainControl(method = "cv", number = 10, +savePredictions = "final")) +fit$pred %>% arrange(rowIndex) %>% pull(pred) +} +# Chicago Data Portal (Socrata) -> sf. `where` is a SoQL filter. +socrata_get <- function(dataset, where, select = NULL, limit = 200000) { +where <- str_squish(where) +q <- paste0("$where=", URLencode(where, reserved = TRUE), "&$limit=", limit) +if (!is.null(select)) q <- paste0(q, "&$select=", URLencode(str_squish(select), reserved = TRUE)) +url <- paste0("https://data.cityofchicago.org/resource/", dataset, ".geojson?", q) +tmp <- tempfile(fileext = ".geojson") +resp <- tryCatch(download.file(url, tmp, quiet = TRUE), error = function(e) e) +if (inherits(resp, "error")) stop("Portal request failed. Open this URL in a browser to see why:\n", url) +st_read(tmp, quiet = TRUE) +} +#| label: load +train <- read.csv("~/GitHub/MUSA-5080-portfolio/data/chicago_train.csv") +test <- read.csv("~/GitHub/MUSA-5080-portfolio/data/chicago_test.csv") +all_sales <- bind_rows( +read_csv("~/GitHub/MUSA-5080-portfolio/data/chicago_train.csv", show_col_types = FALSE) %>% mutate(split = "train"), +read_csv("~/GitHub/MUSA-5080-portfolio/data/chicago_test.csv", show_col_types = FALSE) %>% mutate(split = "test") +) %>% +st_as_sf(coords = c("lon", "lat"), crs = 4326) %>% +st_transform(3435) # Illinois East State Plane, feet +all_sales %>% st_drop_geometry() %>% count(split) +#| label: part-1 +train_new <- train +# sale price < $1 +train_sp <- train %>% +filter(sale_price<=1) +# num_fireplace > 4 -> mansion +train_fp <- train %>% +filter(num_fireplaces > 4) +# lot size > 3 acres +train_ls <- train %>% +filter(building_000sf > 3) +# sale price > $ 1 million dollars +train_spm <- train %>% +filter(sale_price>1000000) +# Plot 1: sale price <= $1 +ggplot(train, aes(x = building_000sf, y = sale_price)) + +geom_point(alpha = 0.3, color = "grey60") + +geom_point(data = train_sp, color = "red", size = 2) + +scale_y_log10(labels = scales::dollar) + +labs(x = "Living area (sq ft)", y = "Sale price (log)", +title = "Sales of $1 or less") + +theme_minimal() +# Plot 2: fireplaces > 4 +ggplot(train, aes(x = num_fireplaces, y = sale_price)) + +geom_point(alpha = 0.3, color = "grey60") + +geom_point(data = train_fp, color = "red", size = 2) + +scale_y_continuous(labels = scales::dollar) + +labs(x = "Number of fireplaces", y = "Sale price", +title = "Homes with more than 4 fireplaces") + +theme_minimal() +# Plot 3: lot size > 3 acres +ggplot(train, aes(x = building_000sf, y = sale_price)) + +geom_point(alpha = 0.3, color = "grey60") + +geom_point(data = train_ls, color = "red", size = 2) + +scale_y_continuous(labels = scales::dollar) + +labs(x = "Living area (sq ft)", y = "Sale price", +title = "Lots larger than 3 acres") + +theme_minimal() +# Plot 4: sale price > $1M +ggplot(train, aes(x = building_000sf, y = sale_price)) + +geom_point(alpha = 0.3, color = "grey60") + +geom_point(data = train_spm, color = "red", size = 2) + +scale_y_continuous(labels = scales::dollar) + +labs(x = "Living area (sq ft)", y = "Sale price", +title = "Sales over $1 million") + +theme_minimal() +View(all_sales) +#| label: part-2 +m1 <- lm(sale_price ~ building_000sf + building_age + lotsize_acre) +#| label: part-2 +m1 <- lm(sale_price ~ building_000sf + building_age + lotsize_acre, data = all_sales) +summary(m1) diff --git a/adu-query-tool/js/main.js b/adu-query-tool/js/main.js new file mode 100644 index 00000000..40a833d6 --- /dev/null +++ b/adu-query-tool/js/main.js @@ -0,0 +1,872 @@ +/* global mapboxgl */ +mapboxgl.accessToken = 'pk.eyJ1IjoiY2hsb2VhbGltdXJvbmciLCJhIjoiY210dWR0cjcyMDBiNjM0cHFvN2p4b2RpMiJ9.EHtMZkiC7TklHIn836JPoQ'; + +// Data sources + +const ZONING_URL = + 'https://services.arcgis.com/fLeGjb7u4uXqeF9q/arcgis/rest/services/Zoning_BaseDistricts/FeatureServer/0/query'; +const OVERLAY_URL = + 'https://services.arcgis.com/fLeGjb7u4uXqeF9q/arcgis/rest/services/Zoning_Overlays/FeatureServer/0/query'; +const CARTO_SQL = 'https://phl.carto.com/api/v2/sql'; + +// distanceM: 0 = point-in-polygon (district tables); >0 = search radius in meters (point tables) +// columns: what to SELECT; describe: turns a row into the text shown in the popup +const HISTORIC_TABLES = [ + { + table: 'historicdistricts_local', + columns: 'name', + distanceM: 0, + describe: (r) => `Historic district${r.name ? `: ${r.name}` : ''}`, + }, + { + table: 'historic_sites_philreg', + columns: 'loc, district', + distanceM: 10, + describe: (r) => + `Historic site${r.loc ? `: ${r.loc}` : ''}${r.district ? ` (${r.district} district)` : ''}`, + }, +]; + +const MIN_ZONING_ZOOM = 15; // do not request zoning when zoomed out further than this +let zoningVisible = true; // controlled by the legend checkbox + +// field names confirmed against the live services +const BASE_FIELDS = ['long_code', 'code']; // e.g. 'RSA-5', falling back to 'RSA5' +const OVERLAY_FIELDS = ['overlay_symbol']; // e.g. '/NE'; supplemental controls are '[N/A]' + +// ADU rules from the L&I checklist flowchart +const BLOCK_OVERLAYS_ALWAYS = ['FNE', 'NE']; // not permitted, even for historic structures +const BLOCK_OVERLAYS_OTHER = ['FDO', 'VDO', 'NDO', 'EDO']; // not permitted unless historic +const ALLOWED_BASE = ['RSA-5', 'CMX-1']; +// plain-language names (from the L&I checklist and the Philadelphia zoning code) +const OVERLAY_NAMES = { + FNE: 'Far Northeast', + NE: 'Northeast', + FDO: 'Fourth District', + VDO: 'Fifth District', + NDO: 'Ninth District', + EDO: 'Eighth District', +}; +const BASE_NAMES = { + 'RSA-5': 'Residential Single-Family Attached', + 'CMX-1': 'Neighborhood Commercial Mixed-Use', +}; + +// Map +const FONT = 'Didot, Bodoni MT, serif'; + +const map = new mapboxgl.Map({ + container: 'map', + style: 'mapbox://styles/chloealimurong/cmuyof1fv00l301rwe8m15eoy', + zoom: 10.5, + center: [-75.165222, 39.952583], +}); +map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'bottom-right'); + +const EMPTY = { type: 'FeatureCollection', features: [] }; + +// colors by zoninggroup; anything not listed falls back to gray +const GROUP_COLORS = { + 'Residential/Multi-Family/Residential Mixed-Use': '#a6611a', + 'Commercial/Commercial Mixed-Use': '#dfc27d', + 'Industrial/Industrial Mixed-Use': '#80cdc1', + 'Special Purpose': '#018571', +}; +const FALLBACK_COLOR = '#b0b0b0'; + +const zoningColorExpr = [ + 'match', ['get', 'zoninggroup'], + ...Object.entries(GROUP_COLORS).flat(), + FALLBACK_COLOR, +]; + +// Geocoding + +async function geocode(query) { + const url = new URL('https://api.mapbox.com/search/geocode/v6/forward'); + url.search = new URLSearchParams({ + q: query, + proximity: '-75.165222,39.952583', + bbox: '-75.28,39.86,-74.95,40.14', + types: 'address', + limit: 1, + access_token: mapboxgl.accessToken, + }); + const res = await fetch(url); + if (!res.ok) throw new Error('Geocoding failed: ' + res.status); + const data = await res.json(); + return data.features[0] || null; +} + +// Spatial queries + +// ArcGIS FeatureServer (base districts + overlays) +async function queryService(baseUrl, extraParams) { + const params = new URLSearchParams({ + f: 'geojson', + where: '1=1', + inSR: 4326, + outSR: 4326, + spatialRel: 'esriSpatialRelIntersects', + outFields: '*', + ...extraParams, + }); + const res = await fetch(`${baseUrl}?${params}`); + if (!res.ok) throw new Error(`Query failed: ${res.status}`); + const data = await res.json(); + if (data.type !== 'FeatureCollection') { + throw new Error('Service did not return GeoJSON: ' + JSON.stringify(data).slice(0, 200)); + } + return data; +} + +const pointParams = (lng, lat) => ({ + geometry: `${lng},${lat}`, + geometryType: 'esriGeometryPoint', +}); + +// districts inside the current map view (the colored overlay) +async function loadZoningInView() { + if (!zoningVisible || map.getZoom() < MIN_ZONING_ZOOM) return; + const b = map.getBounds(); + const data = await queryService(ZONING_URL, { + geometry: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','), + geometryType: 'esriGeometryEnvelope', + resultRecordCount: 2000, + }); + map.getSource('zoning').setData(data); +} + +// 1. base district containing the point +async function zoningAtPoint(lng, lat) { + const data = await queryService(ZONING_URL, { ...pointParams(lng, lat), resultRecordCount: 1 }); + return data.features[0] || null; +} + +// 2. overlay codes at the point, e.g. ['NE']; null if the lookup failed +async function overlaysAtPoint(lng, lat) { + try { + const data = await queryService(OVERLAY_URL, pointParams(lng, lat)); + const codes = data.features + .map((f) => firstField(f.properties, OVERLAY_FIELDS)) + .filter((s) => s && s.startsWith('/')) // skips '[N/A]' supplemental controls + .map(normalizeOverlay); + return [...new Set(codes)]; // /CTR etc. come back as many sub-areas + } catch (err) { + console.error('Overlay lookup failed', err); + return null; + } +} + +// 3. historic register hits near the point (CARTO SQL); [] if none, null if a lookup failed +async function historicAtPoint(lng, lat) { + try { + const results = await Promise.all( + HISTORIC_TABLES.map(async (t) => { + const sql = `SELECT ${t.columns} FROM ${t.table} + WHERE ST_DWithin(the_geom::geography, + ST_SetSRID(ST_MakePoint(${Number(lng)}, ${Number(lat)}), 4326)::geography, + ${t.distanceM ?? 0}) + LIMIT 5`; + const res = await fetch(`${CARTO_SQL}?q=${encodeURIComponent(sql)}`); + if (!res.ok) throw new Error(`CARTO query failed (${t.table}): ${res.status}`); + const data = await res.json(); + return data.rows.map(t.describe); + }), + ); + return [...new Set(results.flat())]; + } catch (err) { + console.error('Historic lookup failed', err); + return null; + } +} + +// Helpers + +const firstField = (props, names) => { + for (const n of names) if (props?.[n]) return String(props[n]); + return null; +}; +const normalizeOverlay = (s) => s.trim().replace(/^\//, '').toUpperCase(); +// 'NE' -> 'Northeast Overlay District (/NE)'; codes without a known name fall back to '/CTR overlay district' +const overlayName = (code) => + OVERLAY_NAMES[code] ? `${OVERLAY_NAMES[code]} Overlay District (/${code})` : `/${code} overlay district`; + +// 'RSA-5' -> 'Residential Single-Family Attached (RSA-5)' +const baseName = (code) => (BASE_NAMES[code] ? `${BASE_NAMES[code]} (${code})` : code ?? 'unknown'); + +const joinOr = (items) => items.join(' or '); + +function el(tag, text, props = {}) { + const e = document.createElement(tag); + if (text != null) e.textContent = text; + Object.assign(e, props); + return e; +} + +// Step 1: spatial check (automatic, follows the top of the L&I flowchart) + +// Runs the four map-based checks in flowchart order and stops at the first failure. +// Each check has a status: 'pass' | 'fail' | 'none' | 'skip' | 'unknown'. +function spatialCheck(state) { + const { base, overlays, historic, historicHits } = state; + const hit = (list) => overlays?.filter((o) => list.includes(o)) ?? []; + const names = (list) => hit(list).map(overlayName).join(', '); + + const checks = [ + { + id: 'fne', + label: `Not in the ${joinOr(BLOCK_OVERLAYS_ALWAYS.map(overlayName))}`, + }, + { id: 'historic', label: 'The structure is historic (14-604(11)(d)(.1))' }, + { + id: 'other-overlay', + label: `Not in the ${joinOr(BLOCK_OVERLAYS_OTHER.map(overlayName))}`, + }, + { + id: 'base', + label: `Base zoning district is ${joinOr(ALLOWED_BASE.map(baseName))}`, + }, + ].map((c) => ({ ...c, status: 'skip', detail: 'not reached' })); + const [fne, hist, other, baseCheck] = checks; + + if (overlays === null) { + fne.status = 'unknown'; + fne.detail = 'Overlay districts could not be loaded'; + return { + checks, + outcome: 'unknown', + reason: 'Overlay districts could not be loaded, so this cannot be checked automatically.', + }; + } + + // 1. /FNE or /NE: no exception, not even for historic structures + if (hit(BLOCK_OVERLAYS_ALWAYS).length) { + fne.status = 'fail'; + fne.detail = `The property is in the ${names(BLOCK_OVERLAYS_ALWAYS)}`; + return { + checks, + outcome: 'blocked', + canBeHistoric: false, + reason: `The property is in the ${names(BLOCK_OVERLAYS_ALWAYS)}. ADUs are not allowed there, even for historic structures.`, + }; + } + fne.status = 'pass'; + fne.detail = ''; + + // 2. historic structure skips the remaining spatial checks + if (historicHits === null) hist.detail = 'Register lookup unavailable'; + else if (historicHits.length) hist.detail = `Register: ${historicHits.join('; ')}`; + else hist.detail = 'No register match found'; + + if (historic) { + hist.status = 'pass'; + other.detail = 'skipped (historic structure)'; + baseCheck.detail = 'skipped (historic structure)'; + return { + checks, + outcome: 'pass', + reason: 'The structure is historic, so the remaining overlay and zoning checks are skipped.', + }; + } + hist.status = 'none'; + + // 3. /FDO /VDO /NDO /EDO + if (hit(BLOCK_OVERLAYS_OTHER).length) { + other.status = 'fail'; + other.detail = `The property is in the ${names(BLOCK_OVERLAYS_OTHER)}`; + return { + checks, + outcome: 'blocked', + canBeHistoric: true, + reason: `The property is in the ${names(BLOCK_OVERLAYS_OTHER)}. ADUs are not allowed there unless the structure is historic.`, + }; + } + other.status = 'pass'; + other.detail = ''; + + // 4. base district + if (!ALLOWED_BASE.includes(base)) { + baseCheck.status = 'fail'; + baseCheck.detail = `The base zoning district is ${baseName(base)}`; + return { + checks, + outcome: 'blocked', + canBeHistoric: true, + reason: `The base zoning district is ${baseName(base)}. An ADU needs ${joinOr(ALLOWED_BASE.map(baseName))}.`, + }; + } + baseCheck.status = 'pass'; + baseCheck.detail = baseName(base); + + return { + checks, + outcome: 'pass', + reason: 'The overlay districts and base zoning district allow an ADU. Next, tell us about the property.', + }; +} + +// Step 2: user input (statements the user selects) + +const PROPERTY_ITEMS = [ + { + id: 'structure', + text: 'The principal structure is semi-detached or detached', + blocker: 'Principal structure must be detached or semi-detached', + }, + { + id: 'lot', + text: 'The lot is at least 1,600 sq ft AND no more than one dwelling unit is otherwise permitted', + hint: 'Check your deed or a survey for lot area, and the use table in 14-602 for permitted units.', + blocker: 'Lot must be at least 1,600 sq ft and allow no more than one dwelling unit', + }, +]; + +const REQUIREMENT_ITEMS = [ + { id: 'owner-lives', group: 'Owner occupied', text: 'The owner lives in the principal unit or the ADU' }, + { + id: 'owner-deed', + group: 'Owner occupied', + text: 'Owner will record an affidavit and deed restriction before final occupancy', + }, + { id: 'one-adu', group: 'Maximum of one ADU', text: 'No more than one ADU on the lot' }, + { + id: 'floor-area', + group: 'Floor area', + text: 'The ADU is 800 sq ft or less', + historicText: 'The ADU may exceed 800 sq ft (historic structure)', + }, + { + id: 'entrances', + group: 'Number of entrances', + text: 'Only one entrance on the street-facing front façade, unless the building already had an additional one before the ADU', + }, + { + id: 'location', + group: 'Location', + text: 'The ADU is inside the principal building or an existing detached accessory building (e.g. a detached garage)', + }, +]; + +// the 800 sq ft limit does not apply to historic structures +const itemText = (item, state) => (item.historicText && state.historic ? item.historicText : item.text); + +// Step 3: result + +function evaluate(state) { + const spatial = spatialCheck(state); + const hint = 'Unless the ADU is within a historic structure. You can change this in step 1.'; + + if (spatial.outcome === 'unknown') { + return { status: 'unknown', headline: 'Check incomplete', summary: spatial.reason }; + } + if (spatial.outcome === 'blocked') { + return { + status: 'no', + headline: 'ADU not permitted', + summary: 'You cannot build an ADU on this property.', + reasons: [spatial.reason], + hint: spatial.canBeHistoric ? hint : '', + }; + } + + // structure type and lot size are skipped for historic structures + const reasons = state.historic + ? [] + : PROPERTY_ITEMS.filter((i) => !state.picked[i.id]).map((i) => `${i.blocker} (not selected)`); + if (reasons.length) { + return { + status: 'no', + headline: 'ADU not permitted', + summary: 'You cannot build an ADU on this property as answered.', + reasons, + hint, + }; + } + + const unmet = REQUIREMENT_ITEMS.filter((i) => !state.picked[i.id]).map((i) => itemText(i, state)); + if (unmet.length) { + return { + status: 'pending', + headline: 'ADU permitted, pending additional requirements', + summary: 'You can build an ADU once these requirements are met.', + unmet, + }; + } + + return { + status: 'yes', + headline: 'ADU permitted', + summary: 'You can build an ADU on this property.', + note: 'All checks passed and all additional code requirements are confirmed. L&I verifies these during review.', + }; +} + +// Popup report + +const COLORS = { yes: '#2b8a3e', no: '#c92a2a', unknown: '#868e96' }; + +// 'permitted, pending additional requirements': yellow background, dark text for contrast +const PENDING = { bg: '#ffd43b', text: '#212529' }; + +const STAGE_NAMES = ['Spatial check', 'Your answers', 'Result']; + +const STATUS_STYLE = { + pass: { icon: '✓', bg: '#d3f9d8', color: COLORS.yes }, + fail: { icon: '✕', bg: '#ffe3e3', color: COLORS.no }, + none: { icon: '–', bg: '#f1f3f5', color: COLORS.unknown }, + skip: { icon: '–', bg: '#f1f3f5', color: '#adb5bd' }, + unknown: { icon: '?', bg: '#f1f3f5', color: COLORS.unknown }, +}; + +let popup = null; +let marker = null; + +function button(text, onClick, { primary = false, disabled = false, small = false } = {}) { + const b = el('button', text, { type: 'button', disabled }); + b.style.cssText = + `padding:${small ? '2px 10px' : '7px 16px'};border-radius:999px;font:inherit;` + + `font-size:${small ? '.8em' : '.92em'};font-weight:600;` + + `cursor:${disabled ? 'not-allowed' : 'pointer'};opacity:${disabled ? 0.5 : 1};` + + `border:1.5px solid ${primary ? '#1971c2' : '#ced4da'};` + + `background:${primary ? '#1971c2' : '#fff'};color:${primary ? '#fff' : '#212529'}`; + if (onClick && !disabled) b.addEventListener('click', onClick); + return b; +} + +function actionsRow(...buttons) { + const row = el('div'); + row.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;margin-top:12px'; + row.append(...buttons); + return row; +} + +// small tag shown when the answer differs from what the data suggested +function manualTag() { + const tag = el('span', 'manually set', { + title: 'This answer differs from what the register data suggests', + }); + tag.style.cssText = + 'font-size:.75em;font-weight:600;padding:1px 7px;border-radius:.75em;' + + 'background:#fff4e6;color:#d9480f;border:1px solid #ffa94d'; + return tag; +} + +// the three-step progress bar at the top of the popup +function stepper(state) { + const skipAnswers = spatialCheck(state).outcome !== 'pass'; + const wrap = el('div'); + wrap.style.cssText = 'display:flex;gap:6px;margin:8px 0 12px'; + + STAGE_NAMES.forEach((name, i) => { + const n = i + 1; + const current = n === state.stage; + const skipped = n === 2 && skipAnswers && state.stage === 3; + const done = n < state.stage && !skipped; + + let prefix = `${n}`; + if (done) prefix = '✓'; + if (skipped) prefix = '–'; + + let bg = '#f1f3f5'; + let color = '#868e96'; + if (current) { + bg = '#212529'; + color = '#fff'; + } else if (done) { + bg = '#d3f9d8'; + color = COLORS.yes; + } + + const pill = el('div', `${prefix} ${name}`); + pill.style.cssText = + `flex:1;text-align:center;padding:5px 4px;border-radius:999px;font-size:.8em;font-weight:600;` + + `background:${bg};color:${color}`; + wrap.append(pill); + }); + return wrap; +} + +function checkRow(check, state, rerender) { + const s = STATUS_STYLE[check.status]; + const row = el('div'); + row.style.cssText = + `display:flex;gap:10px;align-items:flex-start;margin:8px 0;opacity:${check.status === 'skip' ? 0.5 : 1}`; + + const icon = el('div', s.icon); + icon.style.cssText = + 'flex:none;width:1.6em;height:1.6em;border-radius:50%;display:flex;align-items:center;' + + `justify-content:center;font-weight:700;font-size:.9em;background:${s.bg};color:${s.color}`; + + const text = el('div'); + const label = el('div', check.label); + label.style.cssText = 'font-weight:600'; + text.append(label); + + if (check.detail) { + const detail = el('div', check.detail); + detail.style.cssText = 'font-size:.85em;color:#666;margin-top:2px'; + text.append(detail); + } + + // the historic answer can be changed by hand if the register data is wrong or out of date + if (check.id === 'historic' && check.status !== 'skip' && check.status !== 'unknown') { + const controls = el('div'); + controls.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:4px;flex-wrap:wrap'; + controls.append( + button( + state.historic ? 'Mark as not historic' : 'Mark as historic', + () => { + state.historic = !state.historic; + rerender(); + }, + { small: true }, + ), + ); + if (state.historic !== state.suggestedHistoric) controls.append(manualTag()); + text.append(controls); + } + + row.append(icon, text); + return row; +} + +function renderSpatial(box, state, rerender, go) { + const spatial = spatialCheck(state); + + const facts = el('p'); + facts.style.cssText = 'font-size:1em;margin:4px 0 8px'; + facts.append( + el('strong', 'Base zoning district: '), baseName(state.base), el('br'), + el('strong', 'Overlay districts: '), + state.overlays === null + ? 'unavailable' + : state.overlays.length ? state.overlays.map(overlayName).join(', ') : 'none', + ); + box.append(facts); + + for (const check of spatial.checks) box.append(checkRow(check, state, rerender)); + + const titles = { + pass: 'Passed the spatial checks', + blocked: 'Stopped by a spatial check', + unknown: 'Spatial check incomplete', + }; + const tone = { pass: COLORS.yes, blocked: COLORS.no, unknown: COLORS.unknown }; + const banner = el('div'); + banner.style.cssText = + `margin:12px 0 0;padding:8px 10px;border-radius:6px;font-size:.92em;color:#fff;background:${tone[spatial.outcome]}`; + banner.append(el('strong', titles[spatial.outcome]), el('br'), spatial.reason); + box.append(banner); + + if (spatial.outcome === 'pass') { + box.append(actionsRow(button('Next: your answers', () => go(2), { primary: true }))); + } else if (spatial.outcome === 'blocked') { + box.append(actionsRow(button('See result', () => go(3), { primary: true }))); + } else { + box.append(actionsRow(button('Next', null, { disabled: true }))); + } +} + +// a statement the user taps; it turns green when selected +function pickRow(item, text, state, rerender) { + const on = Boolean(state.picked[item.id]); + const row = el('button', null, { type: 'button' }); + row.setAttribute('role', 'checkbox'); + row.setAttribute('aria-checked', String(on)); + row.style.cssText = + 'display:block;width:100%;text-align:left;margin:5px 0;padding:8px 10px;border-radius:8px;' + + `cursor:pointer;font:inherit;font-size:.92em;font-weight:${on ? 600 : 400};` + + `border:1.5px solid ${on ? COLORS.yes : '#ced4da'};background:${on ? '#d3f9d8' : '#fff'};` + + `color:${on ? '#1b5e20' : '#212529'}`; + + row.append(el('span', `${on ? '✓ ' : ''}${text}`)); + if (item.hint) { + const hint = el('span', item.hint); + hint.style.cssText = 'display:block;font-weight:400;font-size:.85em;color:#666;margin-top:2px'; + row.append(hint); + } + + row.addEventListener('click', () => { + state.picked[item.id] = !on; + rerender(); + }); + return row; +} + +function sectionHeading(text) { + const h = el('div', text); + h.style.cssText = 'font-weight:700;margin:12px 0 2px'; + return h; +} + +function groupHeading(text) { + const h = el('div', text); + h.style.cssText = 'font-size:.8em;font-weight:600;color:#495057;margin:8px 0 0;text-transform:uppercase'; + return h; +} + +function renderAnswers(box, state, rerender, go) { + const intro = el('p', 'Select each statement that is true for your property. Selected statements turn green.'); + intro.style.cssText = 'font-size:.92em;color:#495057;margin:4px 0 0'; + box.append(intro); + + if (state.historic) { + const skip = el('p', 'Structure type and lot size are skipped for historic structures.'); + skip.style.cssText = 'font-size:.85em;color:#666;margin:8px 0 0'; + box.append(skip); + } else { + box.append(sectionHeading('About the property')); + for (const item of PROPERTY_ITEMS) box.append(pickRow(item, item.text, state, rerender)); + } + + box.append(sectionHeading('Additional code requirements')); + let lastGroup = null; + for (const item of REQUIREMENT_ITEMS) { + if (item.group !== lastGroup) { + box.append(groupHeading(item.group)); + lastGroup = item.group; + } + box.append(pickRow(item, itemText(item, state), state, rerender)); + } + + box.append( + actionsRow( + button('Back', () => go(1)), + button('See result', () => go(3), { primary: true }), + ), + ); +} + +function bulletList(items) { + const ul = el('ul'); + ul.style.cssText = 'margin:6px 0;padding-left:1.2em;font-size:.92em'; + for (const text of items) ul.append(el('li', text)); + return ul; +} + +function renderResult(box, state, rerender, go) { + const r = evaluate(state); + const pending = r.status === 'pending'; + + const banner = el('div'); + banner.style.cssText = + `padding:10px 12px;border-radius:6px;margin:6px 0;background:${pending ? PENDING.bg : COLORS[r.status]};` + + `color:${pending ? PENDING.text : '#fff'}`; + const headline = el('div', r.headline); + headline.style.cssText = 'font-size:1.15em;font-weight:700'; + const summary = el('div', r.summary); + summary.style.cssText = 'font-size:.92em;margin-top:2px'; + banner.append(headline, summary); + box.append(banner); + + if (r.reasons) { + box.append(el('strong', 'Why not:'), bulletList(r.reasons)); + } + if (r.unmet) { + box.append(el('strong', 'Still to meet:'), bulletList(r.unmet)); + } + if (r.hint) { + const hint = el('p', r.hint); + hint.style.cssText = 'font-size:.92em;margin:6px 0'; + box.append(hint); + } + if (r.note) { + const note = el('p', r.note); + note.style.cssText = 'font-size:.92em;margin:6px 0'; + box.append(note); + } + + const footnote = el('p', 'Historic status is confirmed by the Historical Commission during L&I review.'); + footnote.style.cssText = 'font-size:.85em;color:#666;margin:10px 0 0'; + box.append(footnote); + + const backTo = spatialCheck(state).outcome === 'pass' ? 2 : 1; + box.append(actionsRow(button('Back', () => go(backTo)))); +} + +// true when there is room to open the report beside the pin +const hasSideRoom = () => map.getContainer().clientWidth >= 700; + +function showReport({ place, lngLat, zoning, overlays, historicHits }) { + if (popup) popup.remove(); + if (marker) marker.remove(); + + const root = el('div'); + root.style.cssText = + `font-family:${FONT};font-size:17px;width:min(560px, 85vw);max-height:68vh;overflow-y:auto;padding-right:4px`; + + if (!zoning) { + root.append(el('p', place ? 'No zoning district found at that address.' : 'Address not found in Philadelphia.')); + } else { + const suggestedHistoric = Boolean(historicHits?.length); + const state = { + base: firstField(zoning.properties, BASE_FIELDS), + overlays, + historicHits, + suggestedHistoric, // what the register data says + historic: suggestedHistoric, // what the user has chosen (starts at the register answer) + picked: {}, // statement id -> true when the user selects it + stage: 1, + }; + + const progress = el('div'); + const body = el('div'); + + const rerender = () => { + progress.replaceChildren(stepper(state)); + body.replaceChildren(); + if (state.stage === 1) renderSpatial(body, state, rerender, go); + else if (state.stage === 2) renderAnswers(body, state, rerender, go); + else renderResult(body, state, rerender, go); + }; + const go = (stage) => { + state.stage = stage; + rerender(); + root.scrollTop = 0; + }; + + root.append(el('h3', place?.properties?.full_address ?? 'Zoning report'), progress, body); + rerender(); + } + + popup = new mapboxgl.Popup({ + closeOnClick: false, + maxWidth: 'none', + anchor: hasSideRoom() ? 'left' : undefined, // 'left' = box opens to the right of the pin + }).setDOMContent(root); + + if (place) { + // pin on the address, report box opens beside it + marker = new mapboxgl.Marker({ color: '#f1b6da' }) + .setLngLat(lngLat) + .setPopup(popup) + .addTo(map); + marker.togglePopup(); + } else { + // address not found: no pin, just show the message at the map center + popup.setLngLat(lngLat).addTo(map); + } +} + +// Address search + +async function searchAddress(query) { + const place = await geocode(query); + if (!place) { + return showReport({ place: null, lngLat: map.getCenter(), zoning: null }); + } + + const [lng, lat] = place.geometry.coordinates; + map.flyTo({ + center: [lng, lat], + zoom: 18, + padding: { + right: hasSideRoom() ? Math.min(620, Math.round(map.getContainer().clientWidth * 0.55)) : 0, + }, + }); + + const [zoning, overlays, historicHits] = await Promise.all([ + zoningAtPoint(lng, lat), + overlaysAtPoint(lng, lat), + historicAtPoint(lng, lat), + ]); + map.getSource('selected-zoning').setData(zoning || EMPTY); + showReport({ place, lngLat: [lng, lat], zoning, overlays, historicHits }); +} + +const input = document.querySelector('[name=address-search]'); +input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && input.value.trim()) { + searchAddress(input.value.trim()).catch(console.error); + } +}); + +// Map setup + +map.on('load', () => { + // hide road-number shields; they log a Mapbox expression warning on some tiles + map.getStyle().layers.forEach((layer) => { + if (layer.type === 'symbol' && /shield/i.test(layer.id)) { + map.setLayoutProperty(layer.id, 'visibility', 'none'); + } + }); + + // all districts in view + map.addSource('zoning', { type: 'geojson', data: EMPTY }); + map.addLayer({ + id: 'zoning-fill', + type: 'fill', + source: 'zoning', + paint: { 'fill-color': zoningColorExpr, 'fill-opacity': 0.6 }, + }); + map.addLayer({ + id: 'zoning-outline', + type: 'line', + source: 'zoning', + paint: { 'line-color': '#999', 'line-width': 0.5 }, + }); + + // the district found by address search + map.addSource('selected-zoning', { type: 'geojson', data: EMPTY }); + map.addLayer({ + id: 'selected-zoning-outline', + type: 'line', + source: 'selected-zoning', + paint: { 'line-color': '#f1b6da', 'line-width': 3 }, + }); + + map.on('moveend', () => loadZoningInView().catch(console.error)); +}); + +// Legend (with the zoning layer toggle) + +const legend = document.createElement('div'); +legend.style.cssText = + 'position:absolute;bottom:30px;right:50px;background:#fff;padding:.7em .9em;border-radius:6px;' + + `font:14px ${FONT};box-shadow:0 1px 4px rgb(0 0 0 / 30%)`; + +function legendRow(color, label) { + const row = document.createElement('div'); + row.style.cssText = 'display:flex;align-items:center;gap:.6em;margin:.25em 0'; + const swatch = document.createElement('span'); + swatch.style.cssText = `flex:none;width:1em;height:1em;border-radius:2px;background:${color}`; + row.append(swatch, label); + return row; +} + +// checkbox that shows or hides the zoning layer +const toggleLabel = document.createElement('label'); +toggleLabel.style.cssText = 'display:flex;align-items:center;gap:.6em;font-weight:600;cursor:pointer'; +const toggle = document.createElement('input'); +toggle.type = 'checkbox'; +toggle.checked = true; +toggleLabel.append(toggle, 'Zoning districts'); + +const toggleHint = document.createElement('div'); +toggleHint.textContent = 'Zoom in to see districts'; +toggleHint.style.cssText = 'font-size:.85em;color:#666;margin:.15em 0 .3em'; + +// one row per zoning group +const zoningRows = document.createElement('div'); +for (const [name, color] of Object.entries(GROUP_COLORS)) { + zoningRows.append(legendRow(color, name.split('/')[0])); // 'Residential', 'Commercial', ... +} + +function setZoningVisible(visible) { + zoningVisible = visible; + zoningRows.style.display = visible ? '' : 'none'; + if (!map.getLayer('zoning-fill')) return; // map has not finished loading yet + for (const id of ['zoning-fill', 'zoning-outline']) { + map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none'); + } + if (visible) loadZoningInView().catch(console.error); // districts were not fetched while hidden +} +toggle.addEventListener('change', () => setZoningVisible(toggle.checked)); + +// the searched district outline stays visible whether the zoning layer is on or off +legend.append(toggleLabel, toggleHint, zoningRows, legendRow('#f1b6da', 'Base Zoning District Box')); + +map.getContainer().append(legend); + +window.map = map; \ No newline at end of file diff --git a/mockup/css/styles.css b/mockup/css/styles.css deleted file mode 100644 index 829ab01c..00000000 --- a/mockup/css/styles.css +++ /dev/null @@ -1,137 +0,0 @@ -html { - font-size: 24px; - font-family: sans-serif; -} - -body { - padding: 0; - margin: 0; -} - -.map-section { - position: relative; - width: 100%; - height: 50vh; - background-color: silver; -} - -.map-search-controls { - display: flex; - box-sizing: border-box; - margin: 0; - border: none; - padding: 0.5rem; - width: 100%; - gap: 0.5rem; - justify-content: space-between; - position: relative; - z-index: 1; -} - -.map-search-controls input { - box-sizing: border-box; - border: 1px solid gray; - border-radius: 4px; - font-size: 0.9rem; - font-family: monospace; -} - -[name="address-search"] { - background-image: url("../img/location.svg"); - background-size: 1em; - background-repeat: no-repeat; - background-position: 98% center; - padding-right: 1.2em; - width: 60% -} - -[name="time-search"] { - width: 40%; -} - -.map { - position: absolute; - top: 0; - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.bike-search-controls { - border: 0; - padding: 0.5rem; - display: flex; - justify-content: space-between -} - -.station-list { - list-style-type: none; - padding: 0; - margin: 0; -} - -.station-list .station { - display: grid; - grid-template-areas: "name name" - "blank distance" - "bikes-info docks-info" - "drop-off-info pick-up-info" - "details details"; - grid-template-columns: 1fr 1fr; - - padding: 0.5rem; - border-bottom: 1px solid black; -} - -.station-list .station:first-child { - border-top: 1px solid black; -} - -.station .name { - grid-area: name; - font-weight: bold; -} - -.station .distance { - grid-area: distance; - justify-self: end; - - font-size: 0.8em; - font-style: italic; - padding-bottom: 0.5rem -} - -.station .available-bikes { - grid-area: bikes-info; -} - -.station .available-docks { - grid-area: docks-info; - text-align: right; -} - -.station .next-drop-off-est { - grid-area: drop-off-info; -} - -.station .next-pick-up-est { - grid-area: pick-up-info; - text-align: right; -} - -.station .details { - grid-area: details; - justify-self: center; -} - -.station .details .chart { - box-sizing: border-box; - margin-top: 0.5rem; - padding: 0.5rem; - width: 100%; - height: 4rem; - background-color: silver; - -} \ No newline at end of file diff --git a/mockup/index.html b/mockup/index.html deleted file mode 100644 index bef07e29..00000000 --- a/mockup/index.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - Bikeshare Station Availability Dashboard - - - - -
-
-
- - -
- -
Map
-
- -
-
- - - -
- - -
-
- - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 90261a36..70337bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", "@stylistic/stylelint-plugin": "^5.3.0", - "eslint": "^10.9.1", - "globals": "^17.11.0", + "eslint": "^10.13.0", + "globals": "^17.13.0", "stylelint": "^17.14.1", "stylelint-config-standard": "^40.0.0" } @@ -891,9 +891,9 @@ } }, "node_modules/eslint": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", - "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "version": "10.13.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz", + "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==", "dev": true, "license": "MIT", "workspaces": [ @@ -1291,9 +1291,9 @@ } }, "node_modules/globals": { - "version": "17.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", - "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d99db1cd..e8736ba0 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", "@stylistic/stylelint-plugin": "^5.3.0", - "eslint": "^10.9.1", - "globals": "^17.11.0", + "eslint": "^10.13.0", + "globals": "^17.13.0", "stylelint": "^17.14.1", "stylelint-config-standard": "^40.0.0" }