diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..37246a0
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,51 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(codex exec \"continue to next task\" --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox)",
+ "Bash(powershell.exe:*)",
+ "Bash(dir:*)",
+ "Bash(if not exist logs mkdir logs)",
+ "Bash(test:*)",
+ "Bash(node:*)",
+ "Bash(cat:*)",
+ "Bash(find:*)",
+ "Bash(timeout 10 npm run dev:*)",
+ "Bash(Start-Sleep -Seconds 8)",
+ "Bash($ts = Get-Date -Format \"yyyyMMdd_HHmmss\")",
+ "Bash(Invoke-WebRequest -Uri \"http://localhost:5173/\" -OutFile \"logs/diag_compare_http_$ts.log\" -TimeoutSec 5)",
+ "Bash(Out-String)",
+ "Bash(cmd /c \"dir logs\\*.log /O-D /B\")",
+ "Bash(awk:*)",
+ "Bash(echo $ts)",
+ "Bash(npm run build:*)",
+ "Bash(curl -v http://localhost:4173/)",
+ "Bash(Write-Output $ts)",
+ "Bash(curl -s \"https://phl.carto.com/api/v2/sql?q=SELECT%20dc_dist%2C%20COUNT(*)%20AS%20n%20FROM%20incidents_part1_part2%20WHERE%20dispatch_date_time%20%3E%3D%20%272022-01-01%27%20AND%20dispatch_date_time%20%3C%20%272022-06-01%27%20GROUP%20BY%201%20ORDER%20BY%201%20LIMIT%203&format=json\")",
+ "Bash(curl -s \"https://phl.carto.com/api/v2/sql?q=SELECT%20dc_dist%2C%20COUNT(*)%20AS%20n%20FROM%20incidents_part1_part2%20WHERE%20dispatch_date_time%20%3E%3D%20%272022-01-01%27%20AND%20dispatch_date_time%20%3C%20%272022-06-01%27%20AND%20text_general_code%20IN%20(%27Thefts%27)%20GROUP%20BY%201%20ORDER%20BY%201%20LIMIT%203&format=json\")",
+ "Bash(curl -s \"https://phl.carto.com/api/v2/sql?q=SELECT%20dc_dist%2C%20COUNT(*)%20AS%20n%20FROM%20incidents_part1_part2%20WHERE%20dispatch_date_time%20%3E%3D%20%272022-01-01%27%20AND%20dispatch_date_time%20%3C%20%272022-06-01%27%20AND%20text_general_code%20IN%20(%27Motor%20Vehicle%20Theft%27%2C%27Theft%20from%20Vehicle%27)%20GROUP%20BY%201%20ORDER%20BY%201%20LIMIT%203&format=json\")",
+ "Bash(curl -s \"https://mapservices.pasda.psu.edu/server/rest/services/pasda/CityPhilly/MapServer/28/query?where=1%3D1&outFields=*&f=geojson\")",
+ "Bash(curl -s \"https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Tracts_Blocks/MapServer/0/query?where=STATE%3D%2742%27%20AND%20COUNTY%3D%27101%27&outFields=STATE,COUNTY,GEOID,NAME,BASENAME,ALAND,AWATER&f=geojson\")",
+ "Bash(curl -s \"https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Tracts_Blocks/MapServer/0/query?where=STATE=''''42''''%20AND%20COUNTY=''''101''''&outFields=STATE,COUNTY,GEOID,NAME,BASENAME&returnGeometry=true&f=geojson\")",
+ "Bash(python -m json.tool)",
+ "Bash(curl -s -I http://localhost:4173/)",
+ "Bash(npm run preview)",
+ "Bash(if test -f \"public/data/tracts_phl.geojson\")",
+ "Bash(then ls -lh \"public/data/tracts_phl.geojson\")",
+ "Bash(fi)",
+ "Bash(tree -L 3 -d -I 'node_modules' .)",
+ "Bash(git rm -r --cached dist/)",
+ "Bash(git grep:*)",
+ "Bash(npm run dev:*)",
+ "Bash(python3:*)",
+ "Bash(curl:*)",
+ "Bash(if test -f \"src/data/tract_crime_counts_last12m.json\")",
+ "Bash(then ls -lh \"src/data/tract_crime_counts_last12m.json\")",
+ "Bash(else echo \"tract_crime_counts_last12m.json not found\")",
+ "Bash(if test:*)",
+ "Bash(then ls -lh \"src/data/tract_counts_last12m.json\")",
+ "Bash(else echo \"tract_counts_last12m.json not found\")"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
diff --git a/.gitignore b/.gitignore
index c2658d7..f070f5e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,6 @@
node_modules/
+dist/
+logs/
+.DS_Store
+*.local
+.env*
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..02be578
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "chatgpt.openOnStartup": true
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 39691fc..6f0447e 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,146 @@
-Add a readme for your dashboard here. Include content overview, data citations, and any relevant technical details.
\ No newline at end of file
+# Philadelphia Crime Dashboard
+
+An interactive web dashboard for exploring crime incidents in Philadelphia using MapLibre GL, Chart.js, and the City of Philadelphia's open data APIs.
+
+## How to Use the Dashboard
+
+### Setting Your Area of Interest (Buffer A)
+1. Click **"Select on Map"** button to enter selection mode
+2. Click anywhere on the map to set your buffer center (marker A will appear)
+3. Choose a **radius** (400m, 800m, 1.6km, or 3.2km) to define your area
+4. An orange circle shows your selected buffer zone
+
+### Time Window Controls
+- **Quick Presets:** Click "Last 3mo", "Last 6mo", or "Last 12mo" for recent data
+- **Custom Range:** Use the start month picker + duration dropdown to query historical windows (e.g., Jan 2023 - Jun 2023)
+
+### Filtering by Crime Type
+- **Offense Groups:** Select broad categories (Property, Violent, Vehicle, etc.) from the multi-select
+- **Drilldown:** After selecting groups, the fine-grained codes dropdown populates with specific offense types (e.g., "THEFT", "RETAIL THEFT")
+- Choose specific codes to narrow your analysis further
+
+### Map Layers & Visualization
+- **Admin Level Toggle:** Switch between **Police Districts** and **Census Tracts** views
+- **Display Mode:** Toggle between raw **counts** and **per-10k population** rates
+- **Click districts/tracts** for detailed popup stats (total incidents, per-10k rate, 30-day trends, top-3 offense types)
+- **Hover** over any polygon to see quick stats in the tooltip
+
+### Charts & Compare Card
+- **Monthly Series:** Line chart comparing your buffer (A) vs citywide trends
+- **Top Offenses:** Bar chart showing most frequent crime types in buffer A
+- **7x24 Heatmap:** Hour-of-day and day-of-week patterns
+- **Compare A Card:** Live summary with total incidents, per-10k rate, 30-day change, and top-3 offenses
+
+For detailed control semantics and technical specifications, see [docs/CONTROL_SPEC.md](docs/CONTROL_SPEC.md).
+
+## Quick Start (Dev vs Preview)
+
+> **⚠️ CRITICAL:** Do NOT open `index.html` directly in your browser. The app requires a bundler (Vite) to resolve ES modules and dependencies.
+
+> **📁 Vite Project Structure Rule:** In Vite projects, `index.html` MUST be in the **project root**, not in `public/`. The `public/` directory is for static assets (images, fonts) copied as-is. If you see build errors about "HTML proxy", verify `index.html` is at project root with script tags using absolute paths like `/src/main.js`.
+
+### Development Mode (Recommended)
+```bash
+npm install
+npm run dev
+```
+- Opens at `http://localhost:5173/`
+- Hot module replacement (instant updates on file save)
+- Full dev tools and error reporting
+
+### Production Preview
+```bash
+npm run build
+npm run preview
+```
+- Builds optimized bundle to `dist/`
+- Serves production build at `http://localhost:4173/`
+- Use this to test before deploying
+
+**Current Status (2025-10-15):** Build currently fails due to `vite.config.js` `root: 'public'` configuration. See [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md) for active blockers and [docs/DEPLOY.md](docs/DEPLOY.md) for detailed troubleshooting.
+
+### Basemap & CSS
+
+- The base map uses OpenStreetMap raster tiles: `https://tile.openstreetmap.org/{z}/{x}/{y}.png`.
+- The MapLibre GL CSS is linked via unpkg in `public/index.html` to keep setup simple:
+ ``
+- Attribution: © OpenStreetMap contributors.
+
+### Charts
+
+## How to Use the Dashboard
+
+- Use map selection: click “Select on map?then click the map to set the A center; press Esc or click the button again to cancel. A translucent circle shows the current buffer.
+- Radius: changes the buffer radius used by points, charts, and the compare card; the district choropleth is unaffected by radius.
+- Time window: pick a start month and duration (3/6/12/24). Presets “Last 6m/12m?help jump quickly.
+- Offense grouping & drilldown: pick one or more groups, then optionally drill down into specific codes (the list reflects live audited codes).
+- Admin level: switch between Districts and Tracts; per?0k requires tracts + ACS.
+- Clusters: when too many points are present, clusters are shown with a prompt to zoom in.
+
+- Charts are implemented with Chart.js v4. Before running the app, install dependencies:
+ `npm i`
+- First run may download ~1? MB of packages.
+- Rebuild anytime with `npm run build`.
+ - Requires `npm i` to install chart.js; see `logs/vite_build_*.log` for bundling status.
+
+## Data Sources
+
+- CARTO SQL API (City of Philadelphia): https://phl.carto.com/api/v2/sql
+- Police Districts (GeoJSON):
+ https://policegis.phila.gov/arcgis/rest/services/POLICE/Boundaries/MapServer/1/query?where=1=1&outFields=*&f=geojson
+- Census Tracts (Philadelphia subset, GeoJSON):
+ https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_Census_Tracts/FeatureServer/0/query?where=STATE_FIPS='42'%20AND%20COUNTY_FIPS='101'&outFields=FIPS,STATE_FIPS,COUNTY_FIPS,TRACT_FIPS,POPULATION_2020&f=geojson
+- ACS 2023 5‑Year (population/tenure/income):
+ https://api.census.gov/data/2023/acs/acs5?get=NAME,B01003_001E,B25003_001E,B25003_003E,B19013_001E&for=tract:*&in=state:42%20county:101
+- ACS 2023 5‑Year Subject (poverty rate):
+ https://api.census.gov/data/2023/acs/acs5/subject?get=NAME,S1701_C03_001E&for=tract:*&in=state:42%20county:101
+
+## Limitations
+
+- UCR categories are generalized for reporting and do not reflect full incident coding.
+- Incident locations are rounded to the hundred block; exact addresses are not provided.
+- Counts in this tool may differ from official UCR reports due to methodology and updates.
+
+## Caching & Boundaries
+
+- Police Districts are cached at `public/data/police_districts.geojson` when available.
+- At runtime, the app loads the cached file first; if not present or invalid, it falls back to the live ArcGIS service above.
+
+## Performance Policies
+
+- Never fetch the full incidents table; all requests are constrained by a time window and, when the map is visible, the current map bounding box.
+- If a points query returns more than 20,000 features, the app hides individual points and prompts the user to zoom, showing clusters instead.
+- Clustering is enabled for point sources to improve rendering performance and legibility.
+
+## Compare A/B Semantics
+
+- “A vs B?compares buffer‑based totals around two centers using the same time window and offense filters.
+- Per?0k rates are only computed when the Tracts layer and ACS population are loaded for the relevant geography; otherwise per?0k is omitted.
+
+## Tracts + ACS (per?0k)
+
+- The "Tracts" admin level uses cached tracts geometry and ACS 2023 tract stats.
+- Per?0k rates are computed as (value / population) * 10,000 when population data is available.
+- Tracts with population < 500 are masked from the choropleth to avoid unstable rates.
+
+## Precompute tract counts
+
+- To speed up tracts choropleths for longer windows, you can precompute last?2‑months crime counts per tract:
+ - Run: `node scripts/precompute_tract_counts.mjs`
+ - Output JSON: `src/data/tract_counts_last12m.json`
+ - Logs: `logs/precompute_tract_counts_*.log`
+- Data freshness: re‑run the script periodically to refresh counts. The app will use the precomputed file when present, and fall back to live computations otherwise.
+
+## Technical Documentation
+
+- **Control Specifications:** [docs/CONTROL_SPEC.md](docs/CONTROL_SPEC.md) - Detailed state model, event flows, visual aids, and edge cases for all UI controls
+- **Fix Plan:** [docs/FIX_PLAN.md](docs/FIX_PLAN.md) - Root cause analysis and implementation steps for known UX/logic issues
+- **TODO:** [docs/TODO.md](docs/TODO.md) - Actionable task list with acceptance tests
+- **Deployment Guide:** [docs/DEPLOY.md](docs/DEPLOY.md) - Run modes (dev/preview), why raw file access fails, troubleshooting
+- **Known Issues:** [docs/KNOWN_ISSUES.md](docs/KNOWN_ISSUES.md) - Current blockers, performance issues, workarounds
+- **Changelog:** [docs/CHANGELOG.md](docs/CHANGELOG.md) - Feature history, implementation notes, diagnostic logs
+
+
+Quick Start: index.html is at repo root; use
+pm run dev for local dev or
+pm run preview after a build.
diff --git a/TEMP_main_line.txt b/TEMP_main_line.txt
new file mode 100644
index 0000000..c2af7dd
--- /dev/null
+++ b/TEMP_main_line.txt
@@ -0,0 +1,106 @@
+import './style.css';
+import dayjs from 'dayjs';
+import { initMap } from './map/initMap.js';
+import { getDistrictsMerged } from './map/choropleth_districts.js';
+import { renderDistrictChoropleth } from './map/render_choropleth.js';
+import { drawLegend } from './map/ui_legend.js';
+import { attachHover } from './map/ui_tooltip.js';
+import { wirePoints } from './map/wire_points.js';
+import { updateAllCharts } from './charts/index.js';
+import { store } from './state/store.js';
+import { initPanel } from './ui/panel.js';
+import { refreshPoints } from './map/points.js';
+import { updateCompare } from './compare/card.js';
+
+window.__dashboard = {
+ setChoropleth: (/* future hook */) => {},
+};
+
+window.addEventListener('DOMContentLoaded', async () => {
+ const map = initMap();
+
+ try {
+ // Fixed 6-month window demo
+ const end = dayjs().format('YYYY-MM-DD');
+ const start = dayjs().subtract(6, 'month').format('YYYY-MM-DD');
+
+ // Persist center for buffer-based charts
+ const c = map.getCenter();
+ store.setCenterFromLngLat(c.lng, c.lat);
+ const merged = await getDistrictsMerged({ start, end });
+
+ map.on('load', () => {
+ const { breaks, colors } = renderDistrictChoropleth(map, merged);
+ drawLegend(breaks, colors, '#legend');
+ attachHover(map, 'districts-fill');
+ });
+ } catch (err) {
+ console.warn('Choropleth demo failed:', err);
+ }
+
+ // Wire points layer refresh with fixed 6-month filters for now
+ wirePoints(map, { getFilters: () => store.getFilters() });
+
+ // Charts: use same 6-month window and a default buffer at map center
+ try {
+ const { start, end, types, center3857, radiusM } = store.getFilters();
+ await updateAllCharts({ start, end, types, center3857, radiusM });
+ } catch (err) {
+ console.warn('Charts failed to render:', err);
+ const pane = document.getElementById('charts') || document.body;
+ const status = document.getElementById('charts-status') || (() => {
+ const d = document.createElement('div');
+ d.id = 'charts-status';
+ d.style.cssText = 'position:absolute;right:16px;top:16px;padding:8px 12px;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.1);background:#fff;font:14px/1.4 system-ui';
+ pane.appendChild(d);
+ return d;
+ })();
+ status.innerText = 'Charts unavailable: ' + (err.message || err);
+ }
+
+ // Controls panel
+ function refreshAll() {
+ const { start, end, types } = store.getFilters();
+ getDistrictsMerged({ start, end, types })
+ .then((merged) => {
+ if (map.isStyleLoaded()) {
+ const { breaks, colors } = renderDistrictChoropleth(map, merged);
+ drawLegend(breaks, colors, '#legend');
+ } else {
+ map.once('load', () => {
+ const { breaks, colors } = renderDistrictChoropleth(map, merged);
+ drawLegend(breaks, colors, '#legend');
+ });
+ }
+ })
+ .catch((e) => console.warn('Districts refresh failed:', e));
+
+ refreshPoints(map, { start, end, types }).catch((e) => console.warn('Points refresh failed:', e));
+
+ const f = store.getFilters();
+ updateAllCharts(f).catch((e) => {
+ console.error(e);
+ const pane = document.getElementById('charts') || document.body;
+ const status = document.getElementById('charts-status') || (() => {
+ const d = document.createElement('div');
+ d.id = 'charts-status';
+ d.style.cssText = 'position:absolute;right:16px;top:16px;padding:8px 12px;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.1);background:#fff;font:14px/1.4 system-ui';
+ pane.appendChild(d);
+ return d;
+ })();
+ status.innerText = 'Charts unavailable: ' + (e.message || e);
+ });
+
+ // Compare card (A vs B). Currently only A (store center); B is placeholder until UI for B exists.
+ updateCompare({
+ A: { center3857: store.center3857, radiusM: store.radius },
+ B: null,
+ types,
+ adminLevel: store.adminLevel,
+ timeWindow: store.timeWindowMonths,
+ }).catch((e) => console.warn('Compare update failed:', e));
+ }
+
+ initPanel(store, { onChange: refreshAll, getMapCenter: () => map.getCenter() });
+});
+
diff --git a/TMP_compare_before.txt b/TMP_compare_before.txt
new file mode 100644
index 0000000..d88c7eb
--- /dev/null
+++ b/TMP_compare_before.txt
@@ -0,0 +1,102 @@
+import dayjs from 'dayjs';
+import {
+ fetchMonthlySeriesBuffer,
+ fetchTopTypesBuffer,
+} from '../api/crime.js';
+
+function sumRows(rows) {
+ let s = 0;
+ for (const r of rows || []) s += Number(r.n) || 0;
+ return s;
+}
+
+async function totalInWindow(center3857, radiusM, start, end, types) {
+ const resp = await fetchMonthlySeriesBuffer({ start, end, types, center3857, radiusM });
+ const rows = Array.isArray(resp?.rows) ? resp.rows : resp;
+ return sumRows(rows);
+}
+
+async function top3(center3857, radiusM, start, end) {
+ const resp = await fetchTopTypesBuffer({ start, end, center3857, radiusM, limit: 3 });
+ const rows = Array.isArray(resp?.rows) ? resp.rows : resp;
+ return (rows || []).slice(0, 3).map((r) => ({ name: r.text_general_code, n: Number(r.n) || 0 }));
+}
+
+async function thirtyDayChange(center3857, radiusM, end, types) {
+ const endISO = dayjs(end).format('YYYY-MM-DD');
+ const startRecent = dayjs(endISO).subtract(30, 'day').format('YYYY-MM-DD');
+ const priorEnd = startRecent;
+ const priorStart = dayjs(priorEnd).subtract(30, 'day').format('YYYY-MM-DD');
+
+ const [recent, prior] = await Promise.all([
+ totalInWindow(center3857, radiusM, startRecent, endISO, types),
+ totalInWindow(center3857, radiusM, priorStart, priorEnd, types),
+ ]);
+
+ let pct = null;
+ if (prior > 0) pct = ((recent - prior) / prior) * 100;
+ return { recent, prior, pct };
+}
+
+function renderHTML(resultA, resultB) {
+ const fmt = (v) => (v === null || v === undefined ? '? : Number.isFinite(v) ? Math.round(v).toString() : String(v));
+ const fmtPct = (v) => (Number.isFinite(v) ? `${(v >= 0 ? '+' : '')}${v.toFixed(1)}%` : '?);
+
+ const block = (label, r) => `
+
Computing?/div>';
+ const [resA, resB] = await Promise.all([compute(A), compute(B)]);
+ const html = renderHTML(resA || { total: null, per10k: null, top3: [], delta: {} }, resB);
+ card.innerHTML = html;
+ } catch (e) {
+ card.innerHTML = `
Compare failed: ${e?.message || e}
`;
+ }
+}
+
+
diff --git a/crime_dashboard_codex_plan.txt b/crime_dashboard_codex_plan.txt
new file mode 100644
index 0000000..a24d69e
--- /dev/null
+++ b/crime_dashboard_codex_plan.txt
@@ -0,0 +1,288 @@
+BUILD PLAN — RENTER-ORIENTED CRIME DASHBOARD (JavaScript)
+Authoring target: GitHub Copilot (code assistant)
+Format: plain text (ready to paste into tasks or tickets)
+
+================================================================================
+0) PROJECT ROOT & RUNTIME
+================================================================================
+Project root (Windows):
+C:\Users\44792\Desktop\essay help master\6920Java\dashboard-project-Ryan
+
+Runtime:
+- Node.js LTS (18+)
+- Dev server/bundler: Vite (or similar)
+- Map rendering: MapLibre GL JS (no token required)
+- Spatial utilities: Turf.js (buffering, point-in-polygon), optional H3
+- Date/time: Day.js + timezone plugin (or Luxon)
+
+Base folders:
+ public/
+ index.html
+ src/
+ api/ (all remote fetchers + SQL builders)
+ data/ (optional cached JSON: districts/tracts/ACS)
+ map/ (map modules)
+ charts/ (time-series, top-N, 7x24 heatmap)
+ state/ (global store; debounced queries)
+ utils/ (proj transforms, joins, colors, debounce)
+ README.md
+
+================================================================================
+1) DATASETS, URLS, DOCS (PRIMARY SOURCES)
+================================================================================
+1.1 Crime Incidents (main facts; 2006–present; we use 2015+)
+Dataset landing (OpenDataPhilly):
+https://opendataphilly.org/datasets/crime-incidents/
+
+City visualization page (official disclaimer: locations rounded to 100‑block; UCR categories generalized; counts may not equal UCR reports):
+https://www.phillypolice.com/district/district-gis/
+
+CARTO SQL API (v2) docs:
+https://cartodb.github.io/developers/sql-api/
+(We query the City CARTO instance: https://phl.carto.com/api/v2/sql )
+
+Key fields to use:
+- dispatch_date_time (timestamp)
+- text_general_code (human-readable offense category)
+- ucr_general (generalized UCR code)
+- dc_dist (police district code e.g., “01”, “02”, …)
+- the_geom (Web Mercator geometry; EPSG:3857)
+- location_block (string, 100‑block address)
+
+1.2 Police Districts (boundary; recommended for V1 choropleth)
+ArcGIS MapServer layer (DIST_NUMC, supports GeoJSON output via “query”):
+Service landing:
+https://policegis.phila.gov/arcgis/rest/services/POLICE/Boundaries/MapServer/1
+
+1.3 Census Tracts (statistical boundary; add in V1.1)
+ArcGIS Online FeatureServer (2020 Tracts; filter to state 42, county 101):
+Service landing:
+https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_Census_Tracts/FeatureServer
+
+1.4 ACS 2023 5‑Year (population & renter‑relevant indicators)
+ACS 5‑Year overview:
+https://www.census.gov/data/developers/data-sets/acs-5year.html
+ACS 2023 dataset page:
+https://api.census.gov/data/2023/acs/acs5.html
+ACS API usage overview:
+https://www.census.gov/programs-surveys/acs/data/data-via-api.html
+
+Variables used:
+- Total population (denominator): B01003_001E
+- Tenure: renters B25003_003E, total occupied B25003_001E
+- Median household income: B19013_001E
+- Poverty rate: S1701_C03_001E (subject table)
+(See variable docs via the pages above.)
+
+================================================================================
+2) FULL API URLS (COPY/PASTE), WITH PLACEHOLDERS
+================================================================================
+Notes:
+- Always enforce the historical floor in WHERE: dispatch_date_time >= '2015-01-01'.
+- For a rolling 6‑month window, compute {START} and {END} in JS (UTC ISO).
+- Replace {XMIN},{YMIN},{XMAX},{YMAX} by map bbox in EPSG:3857.
+- For buffer queries, provide {X},{Y} as EPSG:3857 and {RADIUS_M} in meters.
+- Use encodeURIComponent to safely pass SQL in the URL.
+
+2.1 Crime points (GeoJSON) — time window + optional types + optional bbox
+GET https://phl.carto.com/api/v2/sql?format=GeoJSON&q=
+SELECT the_geom, dispatch_date_time, text_general_code, ucr_general, dc_dist, location_block
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ {AND text_general_code IN ('THEFT FROM VEHICLE','ROBBERY FIREARM',...)}
+ {AND the_geom && ST_MakeEnvelope({XMIN},{YMIN},{XMAX},{YMAX}, 3857)}
+
+2.2 Crime monthly series — citywide
+GET https://phl.carto.com/api/v2/sql?q=
+SELECT date_trunc('month', dispatch_date_time) AS m, COUNT(*) AS n
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ {AND text_general_code IN (...)}
+GROUP BY 1 ORDER BY 1
+
+2.3 Crime monthly series — within buffer A
+GET https://phl.carto.com/api/v2/sql?q=
+SELECT date_trunc('month', dispatch_date_time) AS m, COUNT(*) AS n
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ {AND text_general_code IN (...)}
+ AND ST_DWithin(the_geom, ST_SetSRID(ST_Point({X},{Y}), 3857), {RADIUS_M})
+GROUP BY 1 ORDER BY 1
+
+2.4 Crime Top‑N offense types — within buffer A
+GET https://phl.carto.com/api/v2/sql?q=
+SELECT text_general_code, COUNT(*) AS n
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ AND ST_DWithin(the_geom, ST_SetSRID(ST_Point({X},{Y}), 3857), {RADIUS_M})
+GROUP BY 1 ORDER BY n DESC LIMIT 12
+
+2.5 Crime heatmap 7×24 (weekday × hour) — buffer A
+GET https://phl.carto.com/api/v2/sql?q=
+SELECT EXTRACT(DOW FROM dispatch_date_time AT TIME ZONE 'America/New_York') AS dow,
+ EXTRACT(HOUR FROM dispatch_date_time AT TIME ZONE 'America/New_York') AS hr,
+ COUNT(*) AS n
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ {AND text_general_code IN (...)}
+ AND ST_DWithin(the_geom, ST_SetSRID(ST_Point({X},{Y}), 3857), {RADIUS_M})
+GROUP BY 1,2 ORDER BY 1,2
+
+2.6 Aggregation by Police District (fast choropleth path)
+GET https://phl.carto.com/api/v2/sql?q=
+SELECT dc_dist, COUNT(*) AS n
+FROM incidents_part1_part2
+WHERE dispatch_date_time >= '2015-01-01'
+ AND dispatch_date_time >= '{START}' AND dispatch_date_time < '{END}'
+ {AND text_general_code IN (...)}
+GROUP BY 1 ORDER BY 1
+
+Police Districts GeoJSON (one-time fetch at startup):
+https://policegis.phila.gov/arcgis/rest/services/POLICE/Boundaries/MapServer/1/query?where=1=1&outFields=*&f=geojson
+
+2.7 Census Tracts (Philadelphia only; GeoJSON)
+https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_Census_Tracts/FeatureServer/0/query?where=STATE_FIPS='42'%20AND%20COUNTY_FIPS='101'&outFields=FIPS,STATE_FIPS,COUNTY_FIPS,TRACT_FIPS,POPULATION_2020&f=geojson
+
+2.8 ACS 2023 5‑Year (tract stats)
+Population + tenure + income:
+https://api.census.gov/data/2023/acs/acs5?get=NAME,B01003_001E,B25003_001E,B25003_003E,B19013_001E&for=tract:*&in=state:42%20county:101
+
+Poverty rate (subject table):
+https://api.census.gov/data/2023/acs/acs5/subject?get=NAME,S1701_C03_001E&for=tract:*&in=state:42%20county:101
+
+================================================================================
+3) EXECUTION ORDER (WHAT TO BUILD, STEP BY STEP)
+================================================================================
+Step A — Bootstrap & config
+- Initialize project with Vite.
+- Install deps: maplibre-gl, @turf/turf, dayjs + timezone.
+- Create src/state/store.ts: global state { addressA, addressB, radius (400|800), timeWindow (3|6|12 months, default 6), selectedTypes (group + fine), adminLevel (“districts”|“tracts”), mapBbox }.
+- Create src/utils/dates.ts: compute {START, END} for timeWindow; enforce >= 2015‑01‑01.
+- Create src/utils/debounce.ts.
+
+Step B — API layer (pure fetchers; no rendering)
+- crime.fetchPoints({start,end,types,bbox}) → GeoJSON (2.1).
+- crime.fetchMonthlySeriesCity({start,end,types}) → [{m,n}] (2.2).
+- crime.fetchMonthlySeriesBuffer({start,end,types,center3857,radiusM}) → [{m,n}] (2.3).
+- crime.fetchTopTypesBuffer({start,end,center3857,radiusM}) → [{text_general_code,n}] (2.4).
+- crime.fetch7x24Buffer({start,end,types,center3857,radiusM}) → [{dow,hr,n}] (2.5).
+- crime.fetchByDistrict({start,end,types}) → [{dc_dist,n}] (2.6).
+- boundaries.fetchPoliceDistricts() → GeoJSON (2.6 URL).
+- boundaries.fetchTracts() → GeoJSON (2.7 URL).
+- acs.fetchTractStats() → array rows with GEO ctx (2.8 URLs; merge into {GEOID, pop, renter_share, med_income, poverty_pct}).
+
+Step C — Spatial helpers
+- proj.ts: lon/lat ↔ EPSG:3857 conversion helpers for buffers and ST_DWithin.
+- joins.ts: joinDistrictCountsToGeoJSON(districtsGeoJSON, [{dc_dist,n}]) matching dc_dist ⇄ DIST_NUMC.
+- (V1.1) tractsAgg.ts: point-in-tract accumulation for small result sets (fallback if no server-side join).
+
+Step D — Maps
+- Map A (points): source = GeJSON from crime.fetchPoints; include bbox + time window + type filters; cluster at low zoom; single points at high zoom.
+- Map B (choropleth): start with Police Districts; on V1.1 add Tracts (with per‑10k rendering when ACS is loaded).
+
+Step E — Charts
+- Monthly line: buffer A vs citywide overlay (two API calls: 2.3 + 2.2).
+- Top‑N bar: buffer A (2.4).
+- 7×24 heatmap: buffer A (2.5).
+
+Step F — Controls (left panel)
+- Address A (and optional B); radius toggle 400m/800m.
+- Time window 3/6/12 months (default 6).
+- Admin level selector: Citywide overlay + “Police Districts” (default in V1) + “Census Tracts” (V1.1).
+- Offense type selector: 6 groups + drilldown into top text_general_code.
+- Counts vs per‑10k toggle (enabled when “Tracts” is active).
+
+Step G — AB compare
+- Duplicate buffer pipeline for B. Same filters.
+- Output card: Total, per‑10k (tracts), Top‑3 types, last 30 vs prior 30 days deltas.
+
+Step H — README & compliance
+- Cite sources and link docs (dataset landing, CARTO SQL API docs, Police Districts service, Tracts service, ACS docs).
+- Include limitations: “UCR categories are generalized; incident locations are rounded to the 100‑block; counts may not match official UCR reports.”
+
+================================================================================
+4) METRICS, DENOMINATORS, AND JOIN KEYS
+================================================================================
+- Per‑10k rate (tracts):
+ rate_per_10k = 10000 * count / B01003_001E
+ Mask tracts with population < 500 (show “insufficient population”).
+- Renter share (context for A/B card):
+ renter_share = B25003_003E / B25003_001E
+- Median household income: B19013_001E (contextual, not used for rate).
+- Poverty rate: S1701_C03_001E (contextual).
+
+Join keys:
+- District choropleth: dc_dist (crime) ⇄ DIST_NUMC (district polygons).
+- Tract choropleth: census GEOID from ACS (= state:42 + county:101 + tract:*). The ArcGIS Tracts layer exposes TRACT_FIPS and county/state fields; reconcile to GEOID as needed for joining ACS rows to polygons.
+
+================================================================================
+5) PERFORMANCE RULES (MUST)
+================================================================================
+- Never load the full incidents table to the client.
+- All points requests include time window and (when map is visible) the current bbox (ST_MakeEnvelope in 3857).
+- All charts/aggregations executed server‑side (SQL in URL) rather than client filtering.
+- Debounce all control changes (≥ 300 ms).
+- If point count > 20,000, switch to clusters/heat summary and prompt user to zoom.
+- Cache static boundaries in memory (districts, tracts) and ACS rows for the session.
+
+================================================================================
+6) WHAT TO PRE-DOWNLOAD OR CACHE LOCALLY (OPTIONAL)
+================================================================================
+Recommended local copies for offline/dev speed (place in /public/data or /src/data):
+- Police Districts GeoJSON (stable): police_districts.geojson
+ Live: https://policegis.phila.gov/arcgis/rest/services/POLICE/Boundaries/MapServer/1/query?where=1=1&outFields=*&f=geojson
+- Census Tracts (Philadelphia subset) GeoJSON: tracts_phila_2020.geojson
+ Live: see 2.7 URL with state=42 & county=101 filter
+- ACS 2023 5‑Year (Philadelphia tracts) flat JSON: acs_tracts_2023_pa101.json
+ Live: see 2.8 URLs (fetch once at startup and persist to memory or local file)
+
+================================================================================
+7) SANITY-TEST URLS (PASTE IN BROWSER BEFORE CODING)
+================================================================================
+- 6‑month sample (GeoJSON points; replace dates):
+https://phl.carto.com/api/v2/sql?format=GeoJSON&q=SELECT%20the_geom,dispatch_date_time,text_general_code,ucr_general,dc_dist,location_block%20FROM%20incidents_part1_part2%20WHERE%20dispatch_date_time%20%3E%3D%20'2015-01-01'%20AND%20dispatch_date_time%20%3E%3D%20'2025-04-13'%20AND%20dispatch_date_time%20%3C%20'2025-10-14'%20LIMIT%201000
+
+- Police Districts GeoJSON:
+https://policegis.phila.gov/arcgis/rest/services/POLICE/Boundaries/MapServer/1/query?where=1=1&outFields=*&f=geojson
+
+- Tracts GeoJSON (Philadelphia only):
+https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis/rest/services/USA_Census_Tracts/FeatureServer/0/query?where=STATE_FIPS='42'%20AND%20COUNTY_FIPS='101'&outFields=FIPS,STATE_FIPS,COUNTY_FIPS,TRACT_FIPS,POPULATION_2020&f=geojson
+
+- ACS (population/tenure/income):
+https://api.census.gov/data/2023/acs/acs5?get=NAME,B01003_001E,B25003_001E,B25003_003E,B19013_001E&for=tract:*&in=state:42%20county:101
+
+- ACS (poverty rate):
+https://api.census.gov/data/2023/acs/acs5/subject?get=NAME,S1701_C03_001E&for=tract:*&in=state:42%20county:101
+
+================================================================================
+8) VIEW DEFINITIONS (HOW EACH VIEW QUERIES)
+================================================================================
+Points map (A): crime.fetchPoints with {start,end,types,bbox}
+Monthly line: crime.fetchMonthlySeriesBuffer(A) vs crime.fetchMonthlySeriesCity
+Top‑N bar: crime.fetchTopTypesBuffer(A)
+7×24 heatmap: crime.fetch7x24Buffer(A)
+Choropleth (Districts): crime.fetchByDistrict + boundaries.fetchPoliceDistricts + attribute join
+Choropleth (Tracts, V1.1): boundaries.fetchTracts + acs.fetchTractStats + counts per tract (server‑side preferred; client fallback for small sets) + per‑10k rate
+
+================================================================================
+9) UX COPY & DISCLAIMERS (ADD TO README/ABOUT)
+================================================================================
+- Source: “Crime Incidents” via OpenDataPhilly; queried through CARTO SQL API.
+- Limitations (required): UCR categories are generalized; incident locations are rounded to the hundred block; counts may not match official UCR reports.
+- Update cadence: crime incidents update daily; occasional delays or backfills are possible.
+
+================================================================================
+10) DEFAULTS & TUNABLES
+================================================================================
+- Default time window: last 6 months (toggle 3/12).
+- Default radius: 400 m (toggle 800 m).
+- Default admin level for choropleth: Police Districts (V1) → Census Tracts (V1.1).
+- Type selector: 6 renter‑relevant groups + drilldown into top text_general_code.
+- A/B compare: identical filters; display Total, per‑10k (tracts), Top‑3, and recent‑30 vs prior‑30 deltas.
+
+END OF PLAN
diff --git a/docs/ADDRESS_FLOW_AUDIT.md b/docs/ADDRESS_FLOW_AUDIT.md
new file mode 100644
index 0000000..260e1b2
--- /dev/null
+++ b/docs/ADDRESS_FLOW_AUDIT.md
@@ -0,0 +1,468 @@
+# Address/Selection UX Flow Audit
+
+**Date:** 2025-10-20
+**Purpose:** Document current interaction model for area selection (point buffer vs. district/tract polygons)
+**Status:** ⚠️ Significant UX ambiguity and missing features identified
+
+---
+
+## Current Interaction Model
+
+### User Steps (As-Implemented)
+
+1. **Initial Load**
+ - Map shows Philadelphia centered at `[-75.1652, 39.9526]` zoom 11
+ - Districts choropleth renders (all 25 districts visible with color-coded counts)
+ - Controls panel shows:
+ - Address A text input (non-functional)
+ - "Select on map" button
+ - Radius dropdown (400m / 800m)
+ - Admin Level dropdown (Districts / Tracts)
+ - Offense Groups, Time Window, etc.
+
+2. **Point Buffer Selection (Optional)**
+ - User clicks "Select on map" button
+ - Button text changes to "Cancel"
+ - Cursor changes to crosshair
+ - User clicks anywhere on map
+ - Red marker appears at clicked location
+ - Blue buffer circle (radius = 400m default) appears
+ - Charts update to show buffer-based data (monthly series, top-N, heatmap)
+ - Districts choropleth REMAINS VISIBLE (not filtered to buffer)
+
+3. **Admin Level Switch**
+ - User changes dropdown from "Districts" to "Tracts"
+ - Choropleth switches to census tract boundaries
+ - Tract colors based on population (or precomputed counts if available)
+ - Buffer circle and marker (if present) REMAIN VISIBLE
+ - Charts still use buffer (if center was set)
+
+4. **Radius Change**
+ - User changes radius from 400m to 800m
+ - Charts refetch with new `radiusM` parameter
+ - **Bug:** Buffer circle does NOT redraw (visual stays at 400m)
+
+5. **Offense Group / Time Window Changes**
+ - User selects groups or adjusts time window
+ - Choropleth updates (colors recalculate)
+ - Points layer updates (clustering/unclustered points)
+ - Charts update
+ - Buffer circle unaffected (only visual overlay)
+
+---
+
+## State Keys: Current Schema
+
+### Location/Selection State
+
+| Key | Type | Default | Mutated By | Consumed By | Notes |
+|-----|------|---------|------------|-------------|-------|
+| `addressA` | `string \| null` | `null` | [panel.js:38](../src/ui/panel.js#L38) | **NONE** | Dead code; no geocoding |
+| `addressB` | `string \| null` | `null` | **NONE** | **NONE** | Placeholder for future |
+| `selectMode` | `string` | `'idle'` | [panel.js:44, 49](../src/ui/panel.js#L44) | [main.js:128](../src/main.js#L128) map click handler | `'idle'` \| `'point'` |
+| `centerLonLat` | `[number,number] \| null` | `null` | [main.js:130](../src/main.js#L130) | [buffer_overlay.js:4](../src/map/buffer_overlay.js#L4), [main.js:139](../src/main.js#L139) | WGS84 coords |
+| `center3857` | `[number,number] \| null` | `null` | [store.js:59](../src/state/store.js#L59) `setCenterFromLngLat` | [charts/index.js:36-38](../src/charts/index.js#L36-L38) buffer queries | EPSG:3857 |
+| `radius` | `number` | `400` | [panel.js:57](../src/ui/panel.js#L57) | [charts/index.js:36](../src/charts/index.js#L36), [main.js:139](../src/main.js#L139) | Meters |
+| `adminLevel` | `string` | `'districts'` | [panel.js:91](../src/ui/panel.js#L91) | [main.js:70](../src/main.js#L70) | `'districts'` \| `'tracts'` |
+| `per10k` | `boolean` | `false` | [panel.js:96](../src/ui/panel.js#L96) | [main.js:71](../src/main.js#L71) | Rate normalization toggle |
+
+### Missing State Keys (Identified Gaps)
+
+| Key | Type | Purpose | Why Needed |
+|-----|------|---------|------------|
+| `queryMode` | `'buffer' \| 'district' \| 'tract'` | Explicit mode selection | Disambiguate point buffer vs. area selection |
+| `selectedDistrictCode` | `string \| null` | Active district ID (e.g., `'01'`) | Enable single-district filtering |
+| `selectedTractGEOID` | `string \| null` | Active tract ID (e.g., `'42101980100'`) | Enable single-tract filtering |
+
+---
+
+## Code Map: File & Line Pointers
+
+### (a) Address Input Handling
+
+**Controls:**
+- **HTML:** [index.html:34](../index.html#L34)
+ ```html
+
+ ```
+- **Event listener:** [panel.js:37-40](../src/ui/panel.js#L37-L40)
+ ```javascript
+ addrA?.addEventListener('input', () => {
+ store.addressA = addrA.value;
+ onChange();
+ });
+ ```
+- **State mutation:** [panel.js:38](../src/ui/panel.js#L38) `store.addressA = addrA.value`
+- **Consumer:** **NONE** (value never read)
+
+**Status:** ⚠️ Non-functional (no geocoding API integration)
+
+---
+
+### (b) "Select on Map" Toggle
+
+**Controls:**
+- **HTML:** [index.html:35](../index.html#L35)
+ ```html
+
+ ```
+- **Hint text:** [index.html:37](../index.html#L37)
+ ```html
+
Click the map to set A (center). Press Esc to cancel.
+ ```
+
+**Event Flow:**
+
+1. **Button click:** [panel.js:42-54](../src/ui/panel.js#L42-L54)
+ ```javascript
+ useCenterBtn?.addEventListener('click', () => {
+ if (store.selectMode !== 'point') {
+ store.selectMode = 'point';
+ useCenterBtn.textContent = 'Cancel';
+ if (useMapHint) useMapHint.style.display = 'block';
+ document.body.style.cursor = 'crosshair';
+ } else {
+ store.selectMode = 'idle';
+ useCenterBtn.textContent = 'Select on map';
+ if (useMapHint) useMapHint.style.display = 'none';
+ document.body.style.cursor = '';
+ }
+ });
+ ```
+
+2. **Map click (when selectMode === 'point'):** [main.js:127-147](../src/main.js#L127-L147)
+ ```javascript
+ map.on('click', (e) => {
+ if (store.selectMode === 'point') {
+ const lngLat = [e.lngLat.lng, e.lngLat.lat];
+ store.centerLonLat = lngLat;
+ store.setCenterFromLngLat(e.lngLat.lng, e.lngLat.lat);
+
+ // Place marker
+ if (!window.__markerA) {
+ window.__markerA = new maplibregl.Marker({ color: '#ef4444' });
+ }
+ window.__markerA.setLngLat(e.lngLat).addTo(map);
+
+ // Draw buffer circle
+ upsertBufferA(map, { centerLonLat: store.centerLonLat, radiusM: store.radius });
+
+ // Reset mode
+ store.selectMode = 'idle';
+ const btn = document.getElementById('useCenterBtn');
+ if (btn) btn.textContent = 'Select on map';
+ document.getElementById('useMapHint').style.display = 'none';
+ document.body.style.cursor = '';
+
+ refreshAll();
+ }
+ });
+ ```
+
+3. **Buffer circle drawing:** [buffer_overlay.js:3-14](../src/map/buffer_overlay.js#L3-L14)
+ ```javascript
+ export function upsertBufferA(map, { centerLonLat, radiusM }) {
+ if (!centerLonLat) return;
+ const circle = turf.circle(centerLonLat, radiusM, { units: 'meters', steps: 64 });
+ const srcId = 'buffer-a';
+ if (map.getSource(srcId)) {
+ map.getSource(srcId).setData(circle);
+ } else {
+ map.addSource(srcId, { type: 'geojson', data: circle });
+ map.addLayer({ id: 'buffer-a-fill', ... });
+ map.addLayer({ id: 'buffer-a-line', ... });
+ }
+ }
+ ```
+
+**Status:** ✅ Functional (marker + buffer circle work)
+
+**Missing:**
+- Esc key listener to cancel ([main.js](../src/main.js) has no `keydown` handler)
+- Buffer circle doesn't redraw when radius changes
+
+---
+
+### (c) Admin Level Switch
+
+**Controls:**
+- **HTML:** [index.html:60-63](../index.html#L60-L63)
+ ```html
+
+ ```
+
+**Event Flow:**
+
+1. **Dropdown change:** [panel.js:90-93](../src/ui/panel.js#L90-L93)
+ ```javascript
+ adminSel?.addEventListener('change', () => {
+ store.adminLevel = adminSel.value;
+ onChange(); // debounced 300ms → refreshAll()
+ });
+ ```
+
+2. **Rendering branch:** [main.js:70-78](../src/main.js#L70-L78)
+ ```javascript
+ if (store.adminLevel === 'tracts') {
+ const merged = await getTractsMerged({ per10k: store.per10k });
+ const { breaks, colors } = renderTractsChoropleth(map, merged);
+ drawLegend(breaks, colors, '#legend');
+ } else {
+ const merged = await getDistrictsMerged({ start, end, types });
+ const { breaks, colors } = renderDistrictChoropleth(map, merged);
+ drawLegend(breaks, colors, '#legend');
+ }
+ ```
+
+**Data fetching:**
+- **Districts:** [choropleth_districts.js:11-22](../src/map/choropleth_districts.js#L11-L22) → `fetchByDistrict({ start, end, types })`
+- **Tracts:** [tracts_view.js:12-44](../src/map/tracts_view.js#L12-L44) → `fetchTractsCachedFirst()` + `fetchTractStatsCachedFirst()`
+
+**Rendering:**
+- **Districts:** [render_choropleth.js:9-71](../src/map/render_choropleth.js#L9-L71)
+- **Tracts:** [render_choropleth_tracts.js](../src/map/render_choropleth_tracts.js) (similar structure)
+
+**Status:** ✅ Functional (toggle works, choropleth switches)
+
+**Gaps:**
+- No way to select SINGLE district/tract (always shows all)
+- No highlighting of "active area"
+- Buffer circle remains visible in area mode (visually confusing)
+
+---
+
+### (d) Radius Updates
+
+**Controls:**
+- **HTML:** [index.html:42-45](../index.html#L42-L45)
+ ```html
+
+ ```
+
+**Event Flow:**
+
+1. **Dropdown change:** [panel.js:56-62](../src/ui/panel.js#L56-L62)
+ ```javascript
+ const radiusImmediate = () => {
+ store.radius = Number(radiusSel.value) || 400;
+ handlers.onRadiusInput?.(store.radius); // ← CALLBACK NOT DEFINED
+ onChange();
+ };
+ radiusSel?.addEventListener('change', radiusImmediate);
+ radiusSel?.addEventListener('input', radiusImmediate);
+ ```
+
+2. **Callback definition:** [main.js:111](../src/main.js#L111)
+ ```javascript
+ initPanel(store, { onChange: refreshAll, getMapCenter: () => map.getCenter() });
+ ```
+ **Problem:** `onRadiusInput` handler NOT passed (only `onChange` and `getMapCenter`)
+
+3. **Consumer (charts):** [charts/index.js:32-39](../src/charts/index.js#L32-L39)
+ ```javascript
+ const [city, buf, topn, heat] = await Promise.all([
+ fetchMonthlySeriesCity({ start, end, types }),
+ fetchMonthlySeriesBuffer({ start, end, types, center3857, radiusM }), // ← Uses radius
+ fetchTopTypesBuffer({ start, end, center3857, radiusM, limit: 12 }), // ← Uses radius
+ fetch7x24Buffer({ start, end, types, center3857, radiusM }), // ← Uses radius
+ ]);
+ ```
+
+**Status:** ⚠️ Partially functional
+- ✅ Radius value updates in store
+- ✅ Charts refetch with new radius
+- ❌ Buffer circle does NOT redraw (visual stays at old radius)
+- ❌ Radius control shown when irrelevant (districts/tracts mode doesn't use radius for choropleth)
+
+**Fix needed:** Pass `onRadiusInput` handler to redraw buffer circle via `upsertBufferA`
+
+---
+
+## Current Event Flow Diagram
+
+```mermaid
+sequenceDiagram
+ actor User
+ participant Panel as Controls Panel (panel.js)
+ participant Store as State Store (store.js)
+ participant Map as MapLibre Map
+ participant Buffer as buffer_overlay.js
+ participant Refresh as refreshAll() (main.js)
+
+ %% Point Buffer Selection Flow
+ User->>Panel: Click "Select on map" button
+ Panel->>Store: selectMode = 'point'
+ Panel->>Panel: Button text → "Cancel"
+ Panel->>User: Cursor → crosshair, hint visible
+
+ User->>Map: Click location (lngLat)
+ Map->>Store: centerLonLat = [lng, lat]
+ Map->>Store: center3857 = [x, y] (via setCenterFromLngLat)
+ Map->>Map: Place red marker (window.__markerA)
+ Map->>Buffer: upsertBufferA({ centerLonLat, radiusM: 400 })
+ Buffer->>Buffer: Draw blue circle (400m)
+ Map->>Store: selectMode = 'idle'
+ Map->>Panel: Reset button text → "Select on map"
+ Map->>Refresh: refreshAll()
+
+ Refresh->>Refresh: getFilters() → { start, end, types, center3857, radiusM }
+ Refresh->>Refresh: if (adminLevel === 'districts')
+ Refresh->>Refresh: getDistrictsMerged({ start, end, types })
+ Note over Refresh: Choropleth renders ALL districts (no center/radius filter)
+ Refresh->>Refresh: updateAllCharts({ ..., center3857, radiusM })
+ Note over Refresh: Charts use buffer (center + radius)
+
+ %% Admin Level Switch Flow
+ User->>Panel: Change adminLevel → 'tracts'
+ Panel->>Store: adminLevel = 'tracts'
+ Panel->>Refresh: onChange() (debounced 300ms)
+ Refresh->>Refresh: if (adminLevel === 'tracts')
+ Refresh->>Refresh: getTractsMerged({ per10k })
+ Note over Refresh: Tracts choropleth renders ALL tracts (no center/radius filter)
+ Note over Map: Buffer circle remains visible (not hidden)
+
+ %% Radius Change Flow
+ User->>Panel: Change radius → 800m
+ Panel->>Store: radius = 800
+ Panel->>Panel: handlers.onRadiusInput?.(800) ← UNDEFINED
+ Panel->>Refresh: onChange() (debounced 300ms)
+ Refresh->>Refresh: updateAllCharts({ ..., radiusM: 800 })
+ Note over Buffer: Buffer circle NOT redrawn (still shows 400m visually)
+ Note over Refresh: Charts fetch with new radius, but visual doesn't match
+```
+
+---
+
+## Pain Points Summary
+
+### 🔴 Critical Issues
+
+#### 1. Radius Control Shown When Irrelevant
+**Impact:** User confusion
+**Severity:** HIGH
+**Details:**
+- When `adminLevel` is `'districts'` or `'tracts'`, radius control remains visible
+- Changing radius has NO EFFECT on choropleth (only affects charts)
+- User expects choropleth to filter to circle, but it doesn't
+
+**Files:**
+- [index.html:42-45](../index.html#L42-L45) — Radius dropdown always visible
+- [main.js:70-78](../src/main.js#L70-L78) — Choropleth rendering ignores `radiusM`
+
+**Fix:** Hide radius control when `queryMode` is `'district'` or `'tract'` (area-based modes)
+
+---
+
+#### 2. No Explicit "Query Mode" Distinction
+**Impact:** Conceptual confusion
+**Severity:** HIGH
+**Details:**
+- App conflates two use cases:
+ 1. **Point Buffer:** "I want to see incidents within 400m of this point"
+ 2. **Area Selection:** "I want to see incidents in District 03"
+- Current UI shows BOTH simultaneously (buffer circle + all districts)
+- No way to:
+ - Show ONLY the selected district/tract (hide others)
+ - Switch between "buffer mode" and "area mode" explicitly
+
+**Files:**
+- [store.js:23-62](../src/state/store.js#L23-L62) — No `queryMode` state key
+- [main.js:70-78](../src/main.js#L70-L78) — Always renders all districts/tracts
+
+**Fix:** Add explicit `queryMode` dropdown/toggle; show relevant controls per mode
+
+---
+
+### 🟠 High Priority Issues
+
+#### 3. Buffer Circle Doesn't Update on Radius Change
+**Impact:** Visual mismatch
+**Severity:** MEDIUM
+**Details:**
+- User changes radius from 400m → 800m
+- Charts refetch correctly
+- Buffer circle stays at 400m (not redrawn)
+
+**Files:**
+- [panel.js:58](../src/ui/panel.js#L58) — `handlers.onRadiusInput` called but undefined
+- [main.js:111](../src/main.js#L111) — `initPanel` doesn't pass `onRadiusInput` callback
+
+**Fix:** Pass `onRadiusInput: (radius) => upsertBufferA(map, { centerLonLat: store.centerLonLat, radiusM: radius })` to `initPanel`
+
+---
+
+#### 4. Address Input Non-Functional
+**Impact:** Dead feature
+**Severity:** MEDIUM
+**Details:**
+- Text input exists with placeholder "Enter address (placeholder)"
+- No geocoding API integration
+- Value stored in `store.addressA` but never consumed
+
+**Files:**
+- [panel.js:37-40](../src/ui/panel.js#L37-L40) — Event listener exists
+- No geocoding module or API calls
+
+**Fix:** Either (a) integrate geocoding API (Mapbox, Google, Nominatim), or (b) hide/remove input
+
+---
+
+### 🟡 Medium Priority Issues
+
+#### 5. District Click Shows Popup But Doesn't Set Selection
+**Impact:** Missed opportunity for interactivity
+**Severity:** LOW
+**Details:**
+- Clicking a district polygon shows popup with stats
+- Doesn't:
+ - Highlight clicked district
+ - Set as "active area" for filtering
+ - Hide other districts
+
+**Files:**
+- [ui_popup_district.js:8-36](../src/map/ui_popup_district.js#L8-L36) — Click handler shows popup only
+
+**Fix:** Add "Select this district" button in popup → sets `store.selectedDistrictCode`, triggers filter
+
+---
+
+#### 6. Esc Key to Cancel Not Implemented
+**Impact:** Minor UX polish
+**Severity:** LOW
+**Details:**
+- Hint text says "Press Esc to cancel"
+- No `keydown` event listener in [main.js](../src/main.js)
+
+**Fix:** Add global Esc listener to reset `selectMode` to `'idle'`
+
+---
+
+#### 7. No Visual Feedback for Active Area
+**Impact:** User doesn't know which area is "selected"
+**Severity:** LOW
+**Details:**
+- When in tracts mode, no highlight/outline for "active" tract
+- User can't tell which area their filters apply to
+
+**Fix:** Add highlight layer for selected district/tract (stroke-width, fill-opacity change)
+
+---
+
+## Next Steps
+
+See [ADDRESS_FLOW_PLAN.md](./ADDRESS_FLOW_PLAN.md) for:
+- Revised UX spec with explicit "Query Mode" step
+- State contract (new keys, mutually exclusive values)
+- Exact changes (file-by-file function signatures, insertion points)
+- Validation rules per mode
+- Acceptance tests
+
+---
+
+**Status:** ⚠️ Current UX has major ambiguity; redesign recommended
diff --git a/docs/ADDRESS_FLOW_PLAN.md b/docs/ADDRESS_FLOW_PLAN.md
new file mode 100644
index 0000000..0dbe293
--- /dev/null
+++ b/docs/ADDRESS_FLOW_PLAN.md
@@ -0,0 +1,740 @@
+# Address/Selection Flow — Redesign Plan
+
+**Date:** 2025-10-20
+**Purpose:** Implementation plan for codex to clarify "Query Mode" UX
+**Scope:** Add explicit Point Buffer vs. Area Selection modes
+**Effort:** ~30-40 minutes (UI restructure, state additions, conditional rendering)
+
+---
+
+## Revised UX Specification
+
+### Step 1: Query Mode Selection (NEW)
+
+User explicitly chooses how they want to analyze crime data:
+
+```
+┌─────────────────────────────────────┐
+│ Query Mode: │
+│ ○ Point Buffer │
+│ ○ Police District │
+│ ○ Census Tract │
+└─────────────────────────────────────┘
+```
+
+**Default:** `Point Buffer` (preserves current "select on map" behavior)
+
+**Behavior:**
+- Switching mode:
+ - Clears previous selection (marker, buffer circle, or highlighted area)
+ - Shows/hides relevant controls (radius visible ONLY in Point Buffer mode)
+ - Updates available options below
+
+---
+
+### Step 2a: Point Buffer Mode
+
+**Controls visible:**
+- "Select on map" button OR Address geocoder (if implemented)
+- Radius dropdown (400m / 800m)
+- Time window
+- Offense groups / drilldown
+
+**Interaction:**
+1. User clicks "Select on map"
+2. Cursor → crosshair
+3. User clicks location
+4. Red marker appears
+5. Blue circle (radius-based) appears
+6. Charts update (buffer-based queries)
+7. **Choropleth:** Either (a) hidden, or (b) faded/dimmed to emphasize buffer zone
+
+**State:**
+```javascript
+{
+ queryMode: 'buffer',
+ center3857: [x, y],
+ centerLonLat: [lng, lat],
+ radius: 400,
+ selectedDistrictCode: null,
+ selectedTractGEOID: null
+}
+```
+
+---
+
+### Step 2b: Police District Mode
+
+**Controls visible:**
+- District selector dropdown (01, 02, 03, …, 25) OR click-to-select on map
+- Time window
+- Offense groups / drilldown
+
+**Controls hidden:**
+- Radius (not applicable)
+- "Select on map" button (or repurposed to "Click a district on map")
+
+**Interaction (Option A: Dropdown):**
+1. User selects "District 03" from dropdown
+2. Map zooms/pans to District 03
+3. District 03 highlighted (thicker stroke, brighter fill)
+4. Other districts dimmed or hidden
+5. Points layer filtered to `dc_dist = '03'`
+6. Charts update (district-filtered queries)
+
+**Interaction (Option B: Click-to-Select):**
+1. User clicks "Select district on map" button
+2. Cursor → pointer
+3. User clicks a district polygon
+4. Same highlighting/filtering as Option A
+
+**State:**
+```javascript
+{
+ queryMode: 'district',
+ selectedDistrictCode: '03',
+ center3857: null,
+ radius: null,
+ selectedTractGEOID: null
+}
+```
+
+---
+
+### Step 2c: Census Tract Mode
+
+**Controls visible:**
+- Tract selector (text input for GEOID or click-to-select)
+- Time window
+- Offense groups / drilldown
+
+**Controls hidden:**
+- Radius
+
+**Interaction:**
+1. User clicks a tract polygon on map
+2. Tract highlighted
+3. Other tracts dimmed or hidden
+4. Points layer filtered to `ST_Within(the_geom, selected_tract_boundary)`
+5. Charts update (tract-filtered queries)
+
+**State:**
+```javascript
+{
+ queryMode: 'tract',
+ selectedTractGEOID: '42101980100',
+ center3857: null,
+ radius: null,
+ selectedDistrictCode: null
+}
+```
+
+---
+
+### Step 3: Common Filters (All Modes)
+
+- Time window (start month + duration)
+- Offense groups / drilldown
+- Rate toggle (counts vs. per-10k)
+
+**Behavior:** These filters apply AFTER query mode determines the spatial extent.
+
+---
+
+## State Contract
+
+### New State Keys
+
+| Key | Type | Default | Purpose | Mutually Exclusive With |
+|-----|------|---------|---------|-------------------------|
+| `queryMode` | `'buffer' \| 'district' \| 'tract'` | `'buffer'` | Explicit mode selection | — |
+| `selectedDistrictCode` | `string \| null` | `null` | Active district ID (e.g., `'03'`) | `center3857`, `selectedTractGEOID` |
+| `selectedTractGEOID` | `string \| null` | `null` | Active tract ID (e.g., `'42101980100'`) | `center3857`, `selectedDistrictCode` |
+
+### Modified Keys
+
+| Key | Type | Default (OLD) | Default (NEW) | Change |
+|-----|------|---------------|---------------|--------|
+| `adminLevel` | `string` | `'districts'` | **DEPRECATED** (replaced by `queryMode`) | Remove or alias to `queryMode` |
+| `center3857` | `[number,number] \| null` | `null` | `null` | Only set when `queryMode === 'buffer'` |
+| `radius` | `number` | `400` | `400` | Only relevant when `queryMode === 'buffer'` |
+
+### Validation Rules
+
+**When `queryMode === 'buffer'`:**
+- `center3857` must be non-null to render buffer-based data
+- `selectedDistrictCode` and `selectedTractGEOID` must be `null`
+- Radius control visible and enabled
+
+**When `queryMode === 'district'`:**
+- `selectedDistrictCode` must be non-null to filter
+- `center3857`, `radius`, `selectedTractGEOID` must be `null`
+- Radius control hidden
+
+**When `queryMode === 'tract'`:**
+- `selectedTractGEOID` must be non-null to filter
+- `center3857`, `radius`, `selectedDistrictCode` must be `null`
+- Radius control hidden
+
+---
+
+## Exact Changes: File-by-File
+
+### 1. Add Query Mode Control to HTML
+
+**File:** [index.html](../index.html)
+**Location:** Insert BEFORE line 32 (before "Address A" input)
+
+```html
+
+
+```
+
+**Conditional visibility (add wrapper divs):**
+
+Replace lines 32-45 (Address A, Select on map, Radius, Time Window) with:
+
+```html
+
+
+
+
+
+
+
+
Click the map to set buffer center. Press Esc to cancel.
+
+
+
+
+
+
+
+
+
+
Or click a district polygon on the map
+
+
+
+
+
+
+
Click a census tract polygon on the map
+
+```
+
+**Keep existing:** Time Window, Admin Level (deprecate later), Offense Groups, etc.
+
+---
+
+### 2. Update State Store
+
+**File:** [src/state/store.js](../src/state/store.js)
+**Location:** Lines 23-62 (store definition)
+
+**Add new keys:**
+
+```diff
+ export const store = /** @type {Store} */ ({
+ addressA: null,
+ addressB: null,
+ radius: 400,
+ timeWindowMonths: 6,
+ startMonth: null,
+ durationMonths: 6,
+ selectedGroups: [],
+ selectedTypes: [],
+- adminLevel: 'districts',
++ queryMode: 'buffer', // 'buffer' | 'district' | 'tract'
++ selectedDistrictCode: null, // '01', '02', ..., '25'
++ selectedTractGEOID: null, // '42101980100', etc.
+ selectMode: 'idle',
+ centerLonLat: null,
+ per10k: false,
+ mapBbox: null,
+ center3857: null,
+```
+
+**Update getFilters():** [store.js:48-54](../src/state/store.js#L48-L54)
+
+```diff
+ getFilters() {
+ const { start, end } = this.getStartEnd();
+ const types = (this.selectedTypes && this.selectedTypes.length)
+ ? this.selectedTypes.slice()
+ : expandGroupsToCodes(this.selectedGroups || []);
+- return { start, end, types, center3857: this.center3857, radiusM: this.radius };
++ return {
++ start,
++ end,
++ types,
++ queryMode: this.queryMode,
++ center3857: this.center3857,
++ radiusM: this.radius,
++ districtCode: this.selectedDistrictCode,
++ tractGEOID: this.selectedTractGEOID,
++ };
+ },
+```
+
+---
+
+### 3. Wire Query Mode Toggle
+
+**File:** [src/ui/panel.js](../src/ui/panel.js)
+**Location:** Insert after line 29 (after `preset12` declaration)
+
+```javascript
+const queryModeSel = document.getElementById('queryModeSel');
+const bufferModeControls = document.getElementById('bufferModeControls');
+const districtModeControls = document.getElementById('districtModeControls');
+const tractModeControls = document.getElementById('tractModeControls');
+
+queryModeSel?.addEventListener('change', () => {
+ store.queryMode = queryModeSel.value;
+
+ // Show/hide mode-specific controls
+ if (bufferModeControls) bufferModeControls.style.display = store.queryMode === 'buffer' ? 'block' : 'none';
+ if (districtModeControls) districtModeControls.style.display = store.queryMode === 'district' ? 'block' : 'none';
+ if (tractModeControls) tractModeControls.style.display = store.queryMode === 'tract' ? 'block' : 'none';
+
+ // Clear selections from other modes
+ if (store.queryMode !== 'buffer') {
+ store.center3857 = null;
+ store.centerLonLat = null;
+ }
+ if (store.queryMode !== 'district') {
+ store.selectedDistrictCode = null;
+ }
+ if (store.queryMode !== 'tract') {
+ store.selectedTractGEOID = null;
+ }
+
+ onChange();
+});
+```
+
+**Initialize visibility:** Insert after line 106 (after durationSel default)
+
+```javascript
+// Initialize query mode controls visibility
+if (queryModeSel) queryModeSel.value = store.queryMode || 'buffer';
+if (bufferModeControls) bufferModeControls.style.display = store.queryMode === 'buffer' ? 'block' : 'none';
+if (districtModeControls) districtModeControls.style.display = store.queryMode === 'district' ? 'block' : 'none';
+if (tractModeControls) tractModeControls.style.display = store.queryMode === 'tract' ? 'block' : 'none';
+```
+
+---
+
+### 4. Wire District Dropdown
+
+**File:** [src/ui/panel.js](../src/ui/panel.js)
+**Location:** Insert after query mode listener
+
+```javascript
+const districtSel = document.getElementById('districtSel');
+districtSel?.addEventListener('change', () => {
+ store.selectedDistrictCode = districtSel.value || null;
+ onChange();
+});
+```
+
+---
+
+### 5. Add Radius Update Handler
+
+**File:** [src/main.js](../src/main.js)
+**Location:** Line 111 (initPanel call)
+
+**Replace:**
+```javascript
+initPanel(store, { onChange: refreshAll, getMapCenter: () => map.getCenter() });
+```
+
+**With:**
+```javascript
+initPanel(store, {
+ onChange: refreshAll,
+ getMapCenter: () => map.getCenter(),
+ onRadiusInput: (radius) => {
+ if (store.centerLonLat && store.queryMode === 'buffer') {
+ upsertBufferA(map, { centerLonLat: store.centerLonLat, radiusM: radius });
+ }
+ }
+});
+```
+
+**Import:** Add at top of file
+```javascript
+import { upsertBufferA, clearBufferA } from './map/buffer_overlay.js';
+```
+
+---
+
+### 6. Update refreshAll to Handle Query Modes
+
+**File:** [src/main.js](../src/main.js)
+**Location:** Lines 67-109 (refreshAll function)
+
+**Replace choropleth logic:**
+
+```diff
+ async function refreshAll() {
+ const { start, end, types } = store.getFilters();
+ try {
+- if (store.adminLevel === 'tracts') {
+- const merged = await getTractsMerged({ per10k: store.per10k });
+- const { breaks, colors } = renderTractsChoropleth(map, merged);
+- drawLegend(breaks, colors, '#legend');
+- } else {
+- const merged = await getDistrictsMerged({ start, end, types });
+- const { breaks, colors } = renderDistrictChoropleth(map, merged);
+- drawLegend(breaks, colors, '#legend');
+- }
++ if (store.queryMode === 'tract') {
++ const merged = await getTractsMerged({ per10k: store.per10k });
++ const { breaks, colors } = renderTractsChoropleth(map, merged);
++ drawLegend(breaks, colors, '#legend');
++ // TODO: Highlight selected tract if store.selectedTractGEOID is set
++ } else if (store.queryMode === 'district') {
++ const merged = await getDistrictsMerged({ start, end, types });
++ const { breaks, colors } = renderDistrictChoropleth(map, merged);
++ drawLegend(breaks, colors, '#legend');
++ // TODO: Highlight selected district if store.selectedDistrictCode is set
++ } else if (store.queryMode === 'buffer') {
++ // Option A: Show all districts (current behavior)
++ const merged = await getDistrictsMerged({ start, end, types });
++ const { breaks, colors } = renderDistrictChoropleth(map, merged);
++ drawLegend(breaks, colors, '#legend');
++ // Option B: Hide choropleth entirely in buffer mode
++ // clearDistrictLayers(map); clearTractLayers(map);
++ }
+ } catch (e) {
+ console.warn('Boundary refresh failed:', e);
+ }
+```
+
+**Update points refresh:**
+
+```diff
+- refreshPoints(map, { start, end, types }).catch((e) => console.warn('Points refresh failed:', e));
++ const pointFilters = { start, end, types };
++ // Add spatial filter based on query mode
++ if (store.queryMode === 'buffer') {
++ // Points layer uses bbox (current behavior)
++ } else if (store.queryMode === 'district' && store.selectedDistrictCode) {
++ // TODO: Add district filter to points SQL
++ pointFilters.districtCode = store.selectedDistrictCode;
++ } else if (store.queryMode === 'tract' && store.selectedTractGEOID) {
++ // TODO: Add tract filter to points SQL
++ pointFilters.tractGEOID = store.selectedTractGEOID;
++ }
++ refreshPoints(map, pointFilters).catch((e) => console.warn('Points refresh failed:', e));
+```
+
+**Update charts call:**
+
+```diff
+ const f = store.getFilters();
+- if (f.center3857) {
++ if (f.queryMode === 'buffer' && f.center3857) {
+ updateAllCharts(f).catch((e) => { /* error handling */ });
++ } else if (f.queryMode === 'district' && f.districtCode) {
++ // TODO: Call district-specific charts update
++ // updateDistrictCharts({ start, end, types, districtCode });
++ } else if (f.queryMode === 'tract' && f.tractGEOID) {
++ // TODO: Call tract-specific charts update
++ } else {
++ const pane = document.getElementById('charts') || document.body;
++ const status = document.getElementById('charts-status');
++ if (status) status.innerText = 'Select a location/area to show charts';
+ }
+```
+
+---
+
+### 7. Add District Click Handler
+
+**File:** [src/main.js](../src/main.js) OR new file [src/map/wire_area_select.js](../src/map/wire_area_select.js)
+**Location:** Insert after `wirePoints` call (around line 47)
+
+```javascript
+// Wire district selection
+map.on('click', 'districts-fill', (e) => {
+ if (store.queryMode === 'district') {
+ const f = e.features && e.features[0];
+ if (!f) return;
+ const code = String(f.properties?.DIST_NUMC || '').padStart(2, '0');
+ store.selectedDistrictCode = code;
+
+ // Update UI
+ const districtSel = document.getElementById('districtSel');
+ if (districtSel) districtSel.value = code;
+
+ refreshAll();
+ }
+});
+
+// Wire tract selection
+map.on('click', 'tracts-fill', (e) => {
+ if (store.queryMode === 'tract') {
+ const f = e.features && e.features[0];
+ if (!f) return;
+ const geoid = f.properties?.__geoid || f.properties?.GEOID;
+ store.selectedTractGEOID = geoid;
+
+ // Update UI
+ const tractInput = document.getElementById('tractInput');
+ if (tractInput) tractInput.value = geoid;
+
+ refreshAll();
+ }
+});
+```
+
+---
+
+### 8. Add Esc Key Listener
+
+**File:** [src/main.js](../src/main.js)
+**Location:** Insert after `map.on('click', ...)` handlers (around line 150)
+
+```javascript
+// Cancel point selection on Esc key
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && store.selectMode === 'point') {
+ store.selectMode = 'idle';
+ const btn = document.getElementById('useCenterBtn');
+ if (btn) btn.textContent = 'Select on map';
+ const hint = document.getElementById('useMapHint');
+ if (hint) hint.style.display = 'none';
+ document.body.style.cursor = '';
+ }
+});
+```
+
+---
+
+## Acceptance Tests
+
+### Test 1: Point Buffer Mode (Default)
+
+**Steps:**
+1. Open app (fresh load)
+2. Verify Query Mode dropdown shows "Point Buffer" selected
+3. Verify "Select on map" button visible
+4. Verify Radius dropdown visible (400m selected)
+5. Click "Select on map"
+6. Click map location
+7. Verify:
+ - Red marker appears
+ - Blue circle (400m) appears
+ - Charts update (monthly, top-N, heatmap)
+ - `store.queryMode === 'buffer'`
+ - `store.center3857` is non-null
+ - `store.selectedDistrictCode === null`
+8. Change radius to 800m
+9. Verify:
+ - Buffer circle redraws to 800m
+ - Charts refetch with new radius
+ - Visual circle matches selected radius
+
+**Expected:** ✅ All buffer features work, radius updates visual
+
+---
+
+### Test 2: Switch to District Mode
+
+**Steps:**
+1. Set Query Mode to "Police District"
+2. Verify:
+ - Radius dropdown hidden
+ - "Select on map" button hidden (or repurposed)
+ - District dropdown visible
+3. Select "District 03" from dropdown
+4. Verify:
+ - Map shows District 03 highlighted (TODO: implement highlight)
+ - Points layer filtered to District 03 (TODO: implement filter)
+ - `store.selectedDistrictCode === '03'`
+ - `store.center3857 === null`
+5. Click a different district polygon on map
+6. Verify:
+ - Dropdown updates to clicked district code
+ - `store.selectedDistrictCode` updates
+ - Highlight moves to new district
+
+**Expected:** ✅ District mode works, radius hidden, selection state updates
+
+---
+
+### Test 3: Switch to Tract Mode
+
+**Steps:**
+1. Set Query Mode to "Census Tract"
+2. Verify:
+ - Radius dropdown hidden
+ - Tract input visible (read-only)
+3. Click a tract polygon on map
+4. Verify:
+ - Tract input shows GEOID (e.g., "42101980100")
+ - `store.selectedTractGEOID` set
+ - `store.center3857 === null`
+ - Tract highlighted (TODO: implement)
+
+**Expected:** ✅ Tract mode works, click-to-select functional
+
+---
+
+### Test 4: Switch Between Modes
+
+**Steps:**
+1. Set to Point Buffer, select a point
+2. Verify buffer circle visible, `center3857` set
+3. Switch to District mode
+4. Verify:
+ - Buffer circle cleared (TODO: implement clear)
+ - `center3857 === null`
+ - `selectedDistrictCode === null` (no district selected yet)
+5. Select District 05
+6. Switch back to Point Buffer
+7. Verify:
+ - `selectedDistrictCode === null`
+ - Previous buffer circle NOT restored (user must re-select)
+
+**Expected:** ✅ Mode switches clear incompatible selections
+
+---
+
+### Test 5: Esc Key Cancels Point Selection
+
+**Steps:**
+1. Set to Point Buffer mode
+2. Click "Select on map" (cursor → crosshair)
+3. Press Esc key
+4. Verify:
+ - Cursor returns to normal
+ - Button text → "Select on map"
+ - Hint text hidden
+ - `store.selectMode === 'idle'`
+
+**Expected:** ✅ Esc key cancels selection
+
+---
+
+### Test 6: Radius Control Visibility
+
+**Steps:**
+1. Set to Point Buffer → verify radius visible
+2. Set to District → verify radius hidden
+3. Set to Tract → verify radius hidden
+4. Set back to Point Buffer → verify radius visible again
+
+**Expected:** ✅ Radius only shown in buffer mode
+
+---
+
+## Phased Implementation
+
+### Phase 1: Minimal (MVP)
+
+**Goal:** Add query mode dropdown and conditional visibility
+**Files:** index.html, panel.js, store.js
+**Features:**
+- Query mode dropdown (buffer / district / tract)
+- Show/hide radius based on mode
+- State keys added (queryMode, selectedDistrictCode, selectedTractGEOID)
+
+**Validation:** Radius hidden in district/tract modes
+
+---
+
+### Phase 2: District Selection
+
+**Goal:** Enable district dropdown and click-to-select
+**Files:** main.js (add district click handler), panel.js (district dropdown wiring)
+**Features:**
+- District dropdown functional
+- Click district polygon → sets selectedDistrictCode
+- Dropdown updates when polygon clicked
+
+**Validation:** District code stored in state, dropdown syncs with map clicks
+
+---
+
+### Phase 3: Visual Feedback
+
+**Goal:** Highlight selected district/tract
+**Files:** render_choropleth.js, render_choropleth_tracts.js
+**Features:**
+- Add highlight layer for selected area
+- Dim/hide non-selected areas (optional)
+
+**Validation:** Selected district has thicker stroke, others faded
+
+---
+
+### Phase 4: Filtering & Charts
+
+**Goal:** Filter points and charts to selected area
+**Files:** sql.js (add district/tract WHERE clauses), charts/index.js (district charts)
+**Features:**
+- Points SQL: `AND dc_dist = '03'` when district selected
+- Charts: district-specific aggregations
+- Tract filtering (requires spatial query)
+
+**Validation:** Points and charts only show data for selected area
+
+---
+
+## Rollback Plan
+
+If redesign causes issues:
+
+1. **Revert HTML changes:** Restore original controls (remove queryModeSel, wrapper divs)
+2. **Revert store.js:** Remove `queryMode`, `selectedDistrictCode`, `selectedTractGEOID`
+3. **Revert panel.js:** Remove query mode listener
+4. **Keep fixes:** Radius update handler (onRadiusInput), Esc key listener (if no conflicts)
+
+**Rollback command:**
+```bash
+git checkout HEAD -- index.html src/state/store.js src/ui/panel.js src/main.js
+```
+
+---
+
+## Summary
+
+**Key Changes:**
+1. Add `queryMode` dropdown (buffer / district / tract)
+2. Conditional control visibility (radius only in buffer mode)
+3. Add district/tract selection state keys
+4. Wire district dropdown + map click handlers
+5. Update `refreshAll()` to branch on `queryMode`
+6. Add `onRadiusInput` handler to redraw buffer circle
+7. Add Esc key listener
+
+**Benefits:**
+- Explicit user intent (buffer vs. area selection)
+- Reduced confusion (radius hidden when irrelevant)
+- Foundation for single-district/tract filtering
+- Visual consistency (radius slider matches buffer circle)
+
+**Effort:** 30-40 minutes for Phase 1+2; additional time for Phase 3+4 (highlighting, filtering)
+
+---
+
+**Status:** ✅ READY FOR CODEX IMPLEMENTATION
+**Priority:** Implement Phase 1 first (minimal viable UX improvement)
diff --git a/docs/AGENT.md b/docs/AGENT.md
new file mode 100644
index 0000000..06377a0
--- /dev/null
+++ b/docs/AGENT.md
@@ -0,0 +1,15 @@
+# Automation Tasks & TODO.md (Single Source of Truth)
+
+- Use a single file `docs/TODO.md` to track phased tasks.
+- Sections: **Pending**, **In Progress**, **Completed**, **Blocked**.
+- Item format: `- [ ]
(ID: ) (owner: codex|human)`
+- Status is changed ONLY by moving the line between sections and toggling the box.
+
+## Execution Rules for codex
+1) Before coding, consult `crime_dashboard_codex_plan.txt`.
+2) Always pick the TOP Pending item owned by `codex`.
+3) After finishing, move it to **Completed** and append a one-line outcome.
+4) If blocked, move to **Blocked** with reason + suggestion.
+5) Keep long logs in `logs/`, not in chat.
+
+Stop conditions: Pending is empty OR `# STOP` appears in `docs/TODO.md`.
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
new file mode 100644
index 0000000..e10a2a3
--- /dev/null
+++ b/docs/AGENTS.md
@@ -0,0 +1,12 @@
+# Agents Orchestration Guide (Claude Code Manager)
+
+When the user says "continue":
+1) Launch a FRESH child agent (Task Tool) to avoid context bloat.
+2) Child agent must:
+ a) Read `docs/TODO.md`
+ b) Run shell: codex exec "continue to next task" --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox
+ c) Monitor output; when task ends, close process.
+ d) Re-open a NEW child agent and repeat b) until Pending is empty or `# STOP`.
+
+Stall rule: if no TODO change + no new log lines for 15 min, kill and relaunch.
+After each cycle, post a 3-line summary: {did, next, risks}.
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
new file mode 100644
index 0000000..bb356fb
--- /dev/null
+++ b/docs/CHANGELOG.md
@@ -0,0 +1,463 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## 2025-10-23 11:03 — Census Tract Comprehensive Audit: UI, Visibility, Analytics ✅
+
+**Status:** ✅ Audit complete — Read-only analysis of tract implementation
+
+### Scope
+Three-part audit of census tract functionality:
+- **Task A**: Standalone Census Tracts Data Map (ACS demographic visualization)
+- **Task B**: Layer visibility (why tracts not visible when overlay enabled)
+- **Task C**: Online tract analytics path (CARTO queries, chart wiring)
+
+### Key Findings
+
+**Task A — Standalone Data Map**: ❌ **NOT IMPLEMENTED**
+- Designed but not built (see [TRACTS_DATA_INVENTORY_AND_PLAN_20251022_160826.md](../logs/TRACTS_DATA_INVENTORY_AND_PLAN_20251022_160826.md))
+- Missing files: `metrics_registry.js`, `city_averages.js`, `tracts_data_map.js`
+- Missing UI: No metric picker, no data mode toggle, no legend subtitle for city averages
+- Missing state: `dataMode`, `selectedMetric`, `cityAverages` properties not in store
+- **Impact**: Users cannot visualize ACS demographics (population, income, poverty) on tract choropleth
+
+**Task B — Layer Visibility**: ⚠️ **DESIGNED GATING + MISSING DATA**
+- Two-layer architecture: `tracts-outline-line` (outlines) + `tracts-fill` (choropleth)
+- Overlay checkbox controls ONLY outline layer (by design)
+- Fill layer gated by `adminLevel === 'tracts'` (default: `'districts'`)
+- Precomputed crime data files missing: `tract_crime_counts_last12m.json`, `tract_counts_last12m.json`
+- **Impact**: Checking "Show tracts overlay" only shows thin gray outlines, no data choropleth
+
+**Task C — Online Analytics Path**: ✅ **FULLY OPERATIONAL**
+- Click handler wired to `tracts-fill` layer
+- 4 parallel CARTO queries triggered: citywide baseline, tract monthly, tract top-N, tract 7×24
+- All SQL builders implemented: `buildMonthlyTractSQL`, `buildTopTypesTractSQL`, `buildHeatmap7x24TractSQL`
+- All API functions implemented: `fetchMonthlySeriesTract`, `fetchTopTypesTract`, `fetch7x24Tract`
+- Tract geometry helper implemented: `getTractPolygonAndBboxByGEOID` (robust GEOID extraction, coordinate truncation)
+- Data reaches all 3 charts: `renderMonthly`, `renderTopN`, `render7x24`
+- 90-second client-side cache, graceful empty data handling
+- **Impact**: Tract analytics work perfectly when `queryMode = 'tract'` AND `adminLevel = 'tracts'`
+
+### Immediate Actions Recommended (15 minutes)
+
+1. **Fix UX confusion** (2 min):
+ - Rename "Show tracts overlay" → "Show tract boundaries (outlines)"
+ - Add tooltip: "For data choropleth, change Admin Level to 'Tracts'"
+
+2. **Generate precomputed data** (5 min):
+ - Run: `node scripts/precompute_tract_crime.mjs > src/data/tract_crime_counts_last12m.json`
+
+3. **Update README** (5 min):
+ - Document tract visualization usage
+ - Document precomputation script requirement
+
+4. **Update CHANGELOG** (3 min):
+ - ✅ This entry
+
+### Future Enhancements (Optional)
+
+- **Auto-sync admin level** (10 min): When query mode = 'tract', auto-set admin level to 'tracts'
+- **Standalone data map** (3-4 hours): Implement ACS demographic visualization (11 tasks from design doc)
+- **Automated precomputation** (30 min): GitHub Actions workflow to regenerate tract crime counts monthly
+
+### Audit Reports
+- [TRACT_UI_AUDIT_20251023_104527.md](../logs/TRACT_UI_AUDIT_20251023_104527.md) — Task A (Standalone Data Map)
+- [TRACT_LAYER_AUDIT_20251023_105700.md](../logs/TRACT_LAYER_AUDIT_20251023_105700.md) — Task B (Layer Visibility)
+- [TRACT_ONLINE_AGG_TEST_20251023_110033.md](../logs/TRACT_ONLINE_AGG_TEST_20251023_110033.md) — Task C (Analytics Path)
+- [TRACT_COMPREHENSIVE_AUDIT_20251023_110307.md](../logs/TRACT_COMPREHENSIVE_AUDIT_20251023_110307.md) — Executive Summary
+
+### Files Examined (25)
+`src/main.js`, `src/state/store.js`, `src/ui/panel.js`, `src/map/tracts_layers.js`, `src/map/tracts_view.js`, `src/map/render_choropleth_tracts.js`, `src/map/legend.js`, `src/charts/index.js`, `src/api/crime.js`, `src/api/boundaries.js`, `src/utils/sql.js`, `src/utils/tract_geom.js`, `src/utils/classify.js`, `index.html`, and data files.
+
+### Summary
+Tract analytics path is fully functional but visibility UX is confusing. Quick fixes: rename checkbox label + generate precomputed data. Standalone demographic data map remains unimplemented (deferred unless user demand exists).
+
+---
+
+## 2025-10-21 10:43 — Structure Cleanup: Single Root Entry, Dist Ignored ✅
+
+**Status:** ✅ Repository standardized to Vite best practices
+
+### Changes
+- **Single Entry Point**: Verified `/index.html` as sole entry (no duplicates in `/public` or active elsewhere)
+- **.gitignore Updated**: Added `dist/`, `logs/`, `.DS_Store`, `*.local`, `.env*`
+- **Git Tracking Fixed**: Untracked 6 build artifacts from `dist/` via `git rm -r --cached dist/`
+- **Config Verified**: `vite.config.js` already in canonical form (`build.outDir: 'dist'`, no `root:`)
+- **Script Tag**: Confirmed absolute path `/src/main.js` (not relative)
+- **Static Assets**: Verified proper separation (`/public/data` for GeoJSON, `/src/data` for imported JSON)
+
+### Verification
+- Build: ✅ SUCCESS (463 modules, 5.40s)
+- Preview: ✅ HTTP 200 OK on localhost:4173
+- Logs: [STRUCTURE_SWEEP_20251021_1030.md](../logs/STRUCTURE_SWEEP_20251021_1030.md), [build_structure_pass_20251021_104330.log](../logs/build_structure_pass_20251021_104330.log), [preview_http_structure_pass_20251021_104330.log](../logs/preview_http_structure_pass_20251021_104330.log)
+
+### Documentation
+- Created: [docs/STRUCTURE_FINAL.md](STRUCTURE_FINAL.md) — Comprehensive structure guide with directory tree, asset organization, and "how to add" checklist
+
+### Summary
+Repository now follows Vite SPA conventions: one HTML entry at root, build artifacts ignored, absolute script paths, public-only static assets.
+
+---
+
+## 2025-10-20 22:24 — Tract Charts Stubs + Tract Overlay Toggle (P1) ✅
+
+**Status:** ✅ Task C complete — Tract chart entry points staged for implementation
+
+### Task C: Tract Charts Entry Points (Stubs)
+Created infrastructure for future tract-level chart implementation:
+
+**New Files**:
+- **[scripts/tract_sql_samples.mjs](../scripts/tract_sql_samples.mjs)** — Sample SQL queries demonstrating ST_Intersects pattern (monthly, topN, 7x24)
+- **[logs/TRACT_SQL_2025-10-21T0224.log](../logs/TRACT_SQL_2025-10-21T0224.log)** — Generated SQL samples with implementation notes
+
+**Stub Functions Added**:
+- [src/utils/sql.js](../src/utils/sql.js) — Lines 378-427: `buildMonthlyTractSQL`, `buildTopTypesTractSQL`, `buildHeatmap7x24TractSQL`
+- [src/api/crime.js](../src/api/crime.js) — Lines 248-300: `fetchMonthlySeriesTract`, `fetchTopTypesTract`, `fetch7x24Tract`
+- [src/charts/index.js](../src/charts/index.js) — Line 79: Updated tract mode message to "ready for implementation"
+
+**Implementation Strategy**: Client-side geometry embedding (load GeoJSON, extract polygon, embed in ST_Intersects query)
+**Estimated Effort**: ~2 hours (see logs/TRACT_SQL_2025-10-21T0224.log)
+
+---
+
+## 2025-10-20 22:22 — Tract Overlay Toggle Restored (P1) ✅
+
+**Status:** ✅ Task B complete — Census tract boundaries now toggleable with correct z-order
+
+### Task B: Census Tract Overlay Feature
+Restored and hardened tract boundary overlays with three-scenario support:
+
+**Implementation**:
+- **Toggle Control**: Added "Show tracts overlay" checkbox in control panel ([index.html](../index.html) lines 89-94)
+- **State Management**: Added `overlayTractsLines` boolean to [src/state/store.js](../src/state/store.js) line 46
+- **Event Wiring**: Panel checkbox syncs with layer visibility ([src/ui/panel.js](../src/ui/panel.js) lines 131-134, 202)
+- **Handler**: [src/main.js](../src/main.js) lines 209-213 — `onTractsOverlayToggle` updates MapLibre layer visibility
+- **Z-Order Fix**: [src/map/tracts_layers.js](../src/map/tracts_layers.js) lines 31-41 — Corrected insertion to `beforeId = 'districts-label'`
+
+**Correct Layer Stack** (bottom → top):
+1. districts-fill (choropleth colors)
+2. **tracts-outline-line** (0.5px gray, toggleable)
+3. districts-line (1px dark boundaries)
+4. districts-label (codes/names)
+
+**Three Scenarios**:
+- **District only**: Unchecked (default) → tracts hidden
+- **District + overlay**: Checked → tracts visible as fine-grained grid
+- **Tract only**: Tracts always visible (overlay toggle irrelevant)
+
+**GeoJSON Data**: public/data/tracts_phl.geojson (1.4 MB, 408 features) ✅ Verified
+
+**Logs**: [logs/TRACTS_OVERLAY_ACCEPT_20251020_222234.md](../logs/TRACTS_OVERLAY_ACCEPT_20251020_222234.md)
+
+---
+
+## 2025-10-20 22:01 — P0 Drilldown Bug Fixed ✅
+
+**Status:** ✅ Critical bug patched — Drilldown list now functional
+
+### Fix Applied
+- **Issue**: Drilldown list always returned empty (zero rows) regardless of offense groups or time window selected
+- **Root Cause**: Typo in [src/api/crime.js:223](../src/api/crime.js#L223) — `endIso = start` instead of `end`, creating zero-length time window
+- **Fix**: One-line change: `const endIso = end;`
+- **Impact**: Feature now 100% functional (was 0% before)
+
+### Verification
+- Build: ✅ SUCCESS (472 modules, 3.93s)
+- Preview: ✅ Server responds 200 OK
+- SQL: ✅ Now generates correct time window predicate
+- Logs: [logs/DRILLDOWN_FIX_20251020_215817.md](../logs/DRILLDOWN_FIX_20251020_215817.md), [logs/build_20251020_220132.log](../logs/build_20251020_220132.log), [logs/preview_http_20251020_220132.log](../logs/preview_http_20251020_220132.log)
+
+### Files Modified
+- [src/api/crime.js](../src/api/crime.js) — Line 223 (1 character change)
+
+---
+
+## 2025-10-20 19:44 — Manager Audit: Three User-Visible Issues Diagnosed 📋
+
+**Status:** 🔍 Diagnosis complete, ready for Codex implementation
+
+### Issues Audited
+
+1. **Drilldown Empty List (P0 — Critical Bug)**
+ - **Symptom**: Drilldown list always shows "No sub-codes in this window" even with valid offense groups and time window
+ - **Root Cause**: Typo in [src/api/crime.js:223](../src/api/crime.js#L223) — `endIso = start` instead of `end`, creating zero-length time window
+ - **Impact**: Feature completely broken, 0% success rate
+ - **Fix Effort**: 1 minute (1-character change)
+ - **Diagnosis Log**: [logs/DRILLDOWN_DIAG_20251020_194408.md](../logs/DRILLDOWN_DIAG_20251020_194408.md)
+ - **Fix Plan**: [docs/DRILLDOWN_FIX_PLAN.md](../docs/DRILLDOWN_FIX_PLAN.md)
+
+2. **Tract Charts Disabled (P1 — Feature Gap)**
+ - **Symptom**: Tract mode shows "charts are disabled" message, only citywide series visible
+ - **Root Cause**: No polygon-based SQL queries implemented for tract geometry intersection
+ - **Solution**: Live SQL with `ST_Intersects` (Option 1, recommended) or precomputed aggregations (Option 2)
+ - **Fix Effort**: 1.5-2 hours for Option 1 (3 SQL builders, 3 API wrappers, chart wiring)
+ - **Plan**: [docs/TRACTS_CHARTS_PLAN.md](../docs/TRACTS_CHARTS_PLAN.md)
+
+3. **Charts Panel Cramped (P2 — UX Issue)**
+ - **Symptom**: Fixed pixel heights (140/160/180px) cause cramped layout on 768p displays, potential scrollbars
+ - **Root Cause**: No responsive height strategy, canvas elements use fixed `height` attributes
+ - **Solution**: CSS Grid with flex-basis percentages + min/max constraints (Option A, recommended) or JavaScript height calc (Option B)
+ - **Fix Effort**: 20-35 minutes for Option A (CSS only)
+ - **Plan**: [docs/CHARTS_RESPONSIVE_PLAN.md](../docs/CHARTS_RESPONSIVE_PLAN.md)
+
+### Deliverables Created
+
+**Logs**:
+- [logs/DRILLDOWN_DIAG_20251020_194408.md](../logs/DRILLDOWN_DIAG_20251020_194408.md) — Root cause analysis with SQL evidence
+
+**Fix Plans (Codex-Ready)**:
+- [docs/DRILLDOWN_FIX_PLAN.md](../docs/DRILLDOWN_FIX_PLAN.md) — P0 fix + P1/P2 enhancements, 5 acceptance tests
+- [docs/TRACTS_CHARTS_PLAN.md](../docs/TRACTS_CHARTS_PLAN.md) — Two implementation options with sample SQL, 5 acceptance tests
+- [docs/CHARTS_RESPONSIVE_PLAN.md](../docs/CHARTS_RESPONSIVE_PLAN.md) — CSS Grid strategy with media queries, 5 acceptance tests
+
+**TODO Updates**:
+- [docs/TODO.md](../docs/TODO.md) — Added 3 tasks: DATA-drilldown, CHARTS-tracts, CHARTS-responsive
+
+### Files Analyzed (Read-Only)
+
+- src/ui/panel.js — Drilldown UI handlers
+- src/api/crime.js — fetchAvailableCodesForGroups (buggy line identified)
+- src/state/store.js — Time window calculation (working correctly)
+- src/utils/http.js — Cache behavior (60s TTL, LRU + sessionStorage)
+- src/charts/index.js — Tract mode short-circuit
+- index.html — Charts container structure (fixed heights)
+
+### Next Actions for Codex
+
+1. **Immediate (P0)**: Fix drilldown typo in crime.js:223 (`endIso = end`)
+2. **High Priority (P1)**: Implement tract charts with live SQL (Option 1)
+3. **Medium Priority (P2)**: Add responsive charts CSS Grid
+
+**Estimated Total Effort**: ~2-3 hours for all three fixes
+
+---
+
+## 2025-10-20 18:43 — About Panel Added ✅
+
+**Status:** ✅ Collapsible info panel with smooth animation
+
+### New Features
+- ✅ **About Button:** Top-right `?` button (28px circle, z-index 1200)
+- ✅ **Slide Animation:** 250ms ease transition (`translateY(-100%)` → `0`)
+- ✅ **Content Sections:** Purpose, How to use, Data sources, Important notes
+- ✅ **Keyboard Support:** Esc key closes panel
+- ✅ **Accessibility:** ARIA attributes (`aria-expanded`, `aria-hidden`, `role="dialog"`)
+- ✅ **Responsive:** Mobile-friendly (full-width, reduced padding on small screens)
+
+### Implementation Details
+- **New module:** `src/ui/about.js` — Panel initialization, styles injection, event handlers
+- **Integration:** `src/main.js` — Import and call `initAboutPanel()` in map.on('load')
+
+### Logs
+- Acceptance: [logs/about_accept_20251020_184353.md](../logs/about_accept_20251020_184353.md)
+
+---
+
+## 2025-10-20 18:41 — Drilldown (Child Offense Codes) COMPLETE ✅
+
+**Status:** ✅ All acceptance criteria met — End-to-end drilldown pipeline implemented
+
+### New Features
+- ✅ **Time-Window Filtering:** Drilldown list shows only codes with incidents in current `[start, end)` window
+- ✅ **Drilldown Override:** Selected drilldown codes take precedence over parent group expansion
+- ✅ **API Integration:** `fetchAvailableCodesForGroups()` queries CARTO for available codes (60s cache)
+- ✅ **Empty States:** Hints for no groups, no codes in window, API errors
+- ✅ **Consistent Filtering:** Drilldown applies to points, districts choropleth, monthly line, Top-N, 7×24 heatmap
+
+### Implementation Details
+- **New API:** `src/api/crime.js` — `fetchAvailableCodesForGroups({ start, end, groups })`
+- **State:** `src/state/store.js` — Added `selectedDrilldownCodes[]`, updated `getFilters()` to return drilldownCodes
+- **SQL:** `src/utils/sql.js` — All 8 builders accept and use `drilldownCodes` (overrides `types` when present)
+- **UI:** `src/ui/panel.js` — Async group handler calls API, drilldown handler updates `selectedDrilldownCodes`
+
+### Behavioral Changes
+- **Before:** Drilldown showed all codes for groups (not filtered by time), overwrote `selectedTypes` directly
+- **After:** Drilldown filtered by time window, stored separately, overrides parent groups in SQL
+
+### Logs
+- Audit: [logs/drilldown_audit_20251020_183620.md](../logs/drilldown_audit_20251020_183620.md)
+- Acceptance: [logs/drilldown_accept_20251020_184113.md](../logs/drilldown_accept_20251020_184113.md)
+
+---
+
+## 2025-10-20 18:34 — Legend Relocated to Bottom-Right ✅
+
+**Status:** ✅ Fixed overlap with compare card
+
+### Changes
+- **Position:** Moved from bottom-left to bottom-right (`left: 12px` → `right: 12px`)
+- **Z-Index:** Increased from 10 to 1010 (stays above compare card z-index 18)
+- **Mobile:** Added media query to nudge legend up (`bottom: 72px` on screens ≤768px)
+- **Visual:** Slightly increased padding, border-radius, updated shadow
+
+### Implementation
+- **File:** `index.html` — Updated `#legend` CSS (lines 11-20)
+
+### Logs
+- Details: [logs/legend_move_20251020_183459.md](../logs/legend_move_20251020_183459.md)
+
+---
+
+## 2025-10-20 17:44 — Census Tracts Implementation COMPLETE ✅
+
+**Status:** ✅ All acceptance criteria met
+
+### New Features
+- ✅ **Tract Geometry Cache:** `public/data/tracts_phl.geojson` (408 tracts, 1.4 MB)
+- ✅ **Always-On Outlines:** Thin dark-gray tract boundaries visible in all modes
+- ✅ **Reusable Legend:** Bottom-right control for both districts and tracts choropleths
+- ✅ **Conditional Choropleth:** Tracts fill visible only when precomputed counts exist
+- ✅ **Robust Fetcher:** 3 fallback endpoints (PASDA, TIGERweb Tracts_Blocks, config)
+
+### Implementation Details
+- **New modules:**
+ - `src/map/tracts_layers.js` — Outline + fill layer management
+ - `src/map/legend.js` — Reusable legend control (replaces drawLegend)
+- **Enhanced modules:**
+ - `scripts/fetch_tracts.mjs` — PASDA + TIGERweb endpoints, GEOID derivation
+ - `src/api/boundaries.js` — Runtime fallback with same endpoints
+ - `src/map/render_choropleth.js` — Integrated legend updates
+ - `src/map/render_choropleth_tracts.js` — Conditional fill + outlines-only banner
+ - `src/main.js` — Initialize legend, load tract outlines on map load
+
+### Test Results
+- ✅ Build succeeds (9.19s, 462 modules)
+- ✅ Preview serves correctly (HTTP 200)
+- ✅ Tract outlines visible in all modes (z-order correct)
+- ✅ Districts legend updates on filter changes
+- ✅ Tracts show outlines-only banner when no counts JSON
+- ✅ No console errors
+
+### Logs
+- Audit: [logs/tracts_audit_20251020_172105.md](../logs/tracts_audit_20251020_172105.md)
+- Fetch: [logs/fetch_tracts_2025-10-20T2124.log](../logs/fetch_tracts_2025-10-20T2124.log)
+- Acceptance: [logs/tracts_accept_20251020_174405.md](../logs/tracts_accept_20251020_174405.md)
+
+### Next Steps
+- Optional: Run `node scripts/precompute_tract_counts.mjs` to enable tract choropleth fill
+- Optional: Add UI checkbox "Show Tract Outlines" for user control
+
+---
+
+2025-10-20 14:20 — Attempted tracts cache generation; endpoints returned 400/invalid GeoJSON; runtime fallback remains; see logs/fetch_tracts_2025-10-20T1820.log and logs/fetch_tracts_20251020_141950.log
+2025-10-20 14:25 — Short dev check completed (HTTP 200); see logs/dev_http_20251020_142456.log
+2025-10-20 14:25 — Build succeeded; see logs/build_20251020_142514.log
+2025-10-20 14:25 — Preview served (HTTP 200); see logs/preview_http_20251020_142550.log
+2025-10-20 14:24 — npm install completed; see logs/npm_install_20251020_142409.log
+2025-10-20 16:42 — Added queryMode + selectedDistrictCode/selectedTractGEOID to store; UI wires Query Mode selector and hides buffer-only controls when not in buffer; Esc exits selection; clear button added.
+2025-10-20 16:42 — District-scoped filtering for series/topN/7x24 and points; buffer charts guarded until center; see logs/area_sql_*.log and logs/mode_switch_smoke_*.log
+2025-10-20 16:42 — Drilldown auto-clears when groups change; dev console shows cache HIT/MISS lines (development only); empty-window banner reinforced.
+2025-10-22 14:48 — fix(tracts): start hidden and sync initial visibility with store.overlayTractsLines; see logs/TRACTS_OVERLAY_SYNC_*.md
+2025-10-22 14:48 — fix(drilldown): normalize group keys (snake/lower/pascal) and populate on init; see logs/DRILLDOWN_KEYS_NORMALIZE_*.md
+2025-10-22 15:21 — feat(tract): charts wired (monthly/TopN/7×24); GEOID extraction fixed; see logs/TRACT_WIRING_IMPL_*.md
+2025-10-22 15:21 — feat(choropleth): add classification controls (method/bins/palette/opacity/custom); classifier module; legend integrates; defaults preserved
+2025-10-22 16:28 — feat(tract): online CARTO fetchers for monthly/topN/7×24; wire charts in tract mode; see logs/TRACT_CRIME_E2E_IMPL_*.md
+2025-10-22 16:28 — feat(tract): static last-12-months snapshot for citywide tract crime choropleth (optional fallback); script scripts/precompute_tract_crime.mjs
+2025-10-22 17:05 — fix(ui): clarify tract overlay vs data fill; auto-set adminLevel=tracts on first Tract mode switch; add status HUD in panel; wire snapshot-only fill and legend subtitle; see logs/TRACT_P0_UX_*.md and logs/TRACT_P1A_SNAPSHOT_*.md
+
+## 2025-10-20 11:07 — Acceptance Test PASS
+
+**Status:** ✅ All blockers resolved, production deployment ready
+
+### Tests Passed
+- ✅ **Dev mode:** `npm run dev` → Server starts, HTTP 200 OK ([logs/acc_dev_20251020_110731.log](../logs/acc_dev_20251020_110731.log))
+- ✅ **Build:** `npm run build` → Succeeds without errors ([logs/acc_build_20251020_110731.log](../logs/acc_build_20251020_110731.log))
+- ✅ **Preview:** `npm run preview` → Server starts, HTTP 200 OK ([logs/acc_http_preview_20251020_110731_retry.log](../logs/acc_http_preview_20251020_110731_retry.log))
+
+### Structure Verified
+- ✅ `/index.html` at project root (moved from public/)
+- ✅ `public/` contains only static assets (police_districts.geojson)
+- ✅ `vite.config.js` simplified to `{ build: { outDir: 'dist' } }` (no root override)
+- ✅ Script tag uses absolute path `/src/main.js`
+
+### Code Verified
+- ✅ `offense_groups.json` — All values are arrays (Property: ["Thefts"])
+- ✅ Point guard active — `MAX_UNCLUSTERED = 20000` with "Too many points" banner
+- ✅ Buffer overlay — `turf.circle` creates immediate visual feedback
+- ✅ Panel debounce — 300ms delay on data refresh
+
+### Artifacts Status
+- ⚠️ `public/data/tracts_phl.geojson` — Not present (remote fallback +2-3s)
+- ⚠️ `src/data/tract_counts_last12m.json` — Not present (live aggregation slower)
+- **Recommendation:** Run `node scripts/fetch_tracts.mjs` and `node scripts/precompute_tract_counts.mjs` periodically
+
+### Updated Documentation
+- [docs/KNOWN_ISSUES.md](KNOWN_ISSUES.md) — Moved Vite blocker to Resolved, updated timestamp
+- [docs/CHANGELOG.md](CHANGELOG.md) — This entry
+
+---
+
+## 2025-10-15 15:26 local — Diagnostic Re-Check + Blocker Update
+
+### Summary
+Re-validated the dashboard after initial blocker fixes were attempted. Found that while `offense_groups.json` structure is now correct and duplicate `index.html` removed, a **new blocker emerged**: Vite's `root: 'public'` configuration causes HTML inline proxy failures during build.
+
+### Fixes Already Applied (Between First and Second Diagnostic)
+1. ✅ **offense_groups.json structure normalized** — "Property" key changed from STRING to ARRAY `["Thefts"]` (line 10-12)
+2. ✅ **Root index.html removed** — Duplicate `/index.html` deleted, only `/public/index.html` remains
+3. ⚠️ **vite.config.js added** — Configured `root: 'public'` to accommodate index.html location, but this causes build failures
+
+### Current Blocker (Active)
+**Build still fails** with HTML inline proxy error:
+```
+[vite:html-inline-proxy] Could not load .../public/index.html?html-proxy&inline-css&index=0.css
+```
+
+**Root Cause:** Vite's `root: 'public'` configuration is incompatible with HTML inline style processing. The `public/` directory is intended for static assets copied as-is, not processed source files.
+
+**Evidence:** [logs/blocker_vite_structure_20251015_152614.md](../logs/blocker_vite_structure_20251015_152614.md)
+
+**Fix Required:** Remove `vite.config.js` `root` setting and move `/public/index.html` → `/index.html` (project root). Update script path from `../src/main.js` to `/src/main.js`.
+
+### Documentation Updates (This Session)
+- **logs/blocker_vite_structure_20251015_152614.md** — Detailed evidence of Vite structure blocker with file locations, error messages, and fix steps
+- **logs/fixes_already_applied_20251015_152614.md** — Status report on offense_groups.json and duplicate HTML fixes
+- **logs/diag_build_20251015_152614.log** — Build failure log showing HTML proxy error
+- **docs/CHANGELOG.md** — Updated with current blocker status and fix timeline
+
+### Links to Logs
+- Build failure: [logs/diag_build_20251015_152614.log](../logs/diag_build_20251015_152614.log)
+- Vite structure blocker: [logs/blocker_vite_structure_20251015_152614.md](../logs/blocker_vite_structure_20251015_152614.md)
+- Fixes timeline: [logs/fixes_already_applied_20251015_152614.md](../logs/fixes_already_applied_20251015_152614.md)
+
+---
+
+## 2025-10-15 16:04 — Static Repository Audit
+
+**Type:** Read-only structural analysis (no code execution, no source edits)
+
+### Deliverables
+- **[docs/STRUCTURE_AUDIT.md](STRUCTURE_AUDIT.md)** — Comprehensive audit report: Vite structure verdict (3 blockers), subsystem mapping (controls/maps/charts/API/SQL), risks table, data artifact validation, call paths
+- **[docs/FILE_MAP.md](FILE_MAP.md)** — Quick reference "What to Edit" index for common changes (offense groups, colors, TTLs, legends, SQL, controls, etc.)
+- **[docs/EDIT_POINTS.md](EDIT_POINTS.md)** — Step-by-step how-to guide with 12 example scenarios (add group, change colors, adjust cache, add popup field, etc.) — all patches are suggestions, not applied
+- **[logs/STATIC_AUDIT_20251015_160419.md](../logs/STATIC_AUDIT_20251015_160419.md)** — Raw audit notes: inventory, trees, grep results, JSON validation, orphan module checks
+
+### Key Findings
+- ✅ offense_groups.json valid (all arrays)
+- ✅ ACS tract data loaded (381 tracts)
+- ✅ SQL SRID consistent (EPSG:3857 throughout)
+- 🔴 3 BLOCKERS: Vite structure violated (`root: 'public'`, HTML in wrong location, relative script path)
+- ⚠️ Missing: tracts GeoJSON cache, precomputed tract counts
+
+**No source files modified in this session.**
+
+---
+
+## 2025-10-15 12:19 — Attempted Build Fixes
+
+2025-10-15 16:13:00Z - Added offense groups fixer/validator; normalized JSON.
+2025-10-15T12:14:13 - Removed root index.html; added vite.config.js for public/ root; updated public/index.html script path.
+2025-10-15T12:16:53 - Fixed invalid optional chaining in main.js; added instant radius overlay via buffer_overlay; panel radius input wired.
+2025-10-15T12:19:45 - Removed root index.html; configured Vite root=public; build succeeded(?); preview logs captured.
+2025-10-15T12:19:45 - Added buffer_overlay and panel radius input handler for instant circle updates.
+2025-10-20T11:01:59.5319407-04:00 - Vite structure fixed (single /index.html at root; simplified vite.config.js).
+2025-10-20T11:02:28.1260704-04:00 - Build PASS with root index.html; preview check to follow.
+2025-10-20T11:03:28.7172823-04:00 - Tracts cache fetch attempted; endpoints flaky (no local cache written).
+2025-10-20T11:03:50.1000618-04:00 - Precompute script ran; output missing or partial (see logs).
+2025-10-20T11:04:10.8518999-04:00 - Added >20k points guard constant and banner message; prevents freezes when zoomed out.
+2025-10-20T11:04:25.7628502-04:00 - README Quick Start updated for root index.html and dev/preview steps.
+2025-10-20T11:04:44.9790206-04:00 - Added docs/DEPLOY.md with Quick Start note.
+2025-10-20T12:08:43.8832032-04:00 - Coverage probe script added and executed; coverage log written.
+2025-10-20T12:09:39.6438250-04:00 - Default time window aligned to dataset coverage (auto from MAX date).
+2025-10-20T12:09:39.6468266-04:00 - Charts guarded until center is chosen (status tip shown).
+2025-10-20T12:09:39.6491974-04:00 - Districts empty-window banner implemented.
diff --git a/docs/CHARTS_RESPONSIVE_PLAN.md b/docs/CHARTS_RESPONSIVE_PLAN.md
new file mode 100644
index 0000000..02d32b5
--- /dev/null
+++ b/docs/CHARTS_RESPONSIVE_PLAN.md
@@ -0,0 +1,566 @@
+# Charts Panel Responsive Height — Layout Audit + Fix Plan
+
+**Status**: Ready for implementation
+**Priority**: P2 (UX enhancement, not blocking)
+**Issue**: Top line chart cramped on smaller viewports, charts panel needs self-adjusting heights
+
+---
+
+## Current State Analysis
+
+### Container Structure
+
+**File**: [index.html:132-143](../index.html#L132-L143)
+
+```html
+
+
Charts
+
+
+
+
+
+
+
+
+
+
+```
+
+**Properties**:
+- **Container**: `max-height: 80vh; overflow: auto;`
+- **Canvas Heights**: Fixed pixels (140, 160, 180 = 480px total + padding/margins)
+- **Width**: Fixed 420px
+
+**Problem**: On a 768p display (1366×768):
+- Available height: ~768px × 0.80 = 614px
+- Required height: 480px + 60px (padding/margins/title) = 540px
+- **Result**: Charts fit, but cramped (no breathing room, scrollbar on smaller screens)
+
+---
+
+### Chart.js Configuration
+
+**Files**:
+- [src/charts/line_monthly.js:40](../src/charts/line_monthly.js#L40)
+- [src/charts/bar_topn.js:26](../src/charts/bar_topn.js#L26)
+- [src/charts/heat_7x24.js:46](../src/charts/heat_7x24.js#L46)
+
+All charts already have:
+```javascript
+maintainAspectRatio: false,
+```
+
+**Status**: ✅ Charts **can** resize dynamically (aspect ratio not locked).
+
+---
+
+## Recommended Solution: CSS Grid with Dynamic Heights
+
+### Strategy
+
+Use CSS Grid to distribute available height proportionally among charts, with sensible min/max constraints.
+
+### Implementation
+
+---
+
+#### A) Refactor Container to CSS Grid
+
+**File**: `index.html` (lines 132-143)
+
+**Before**:
+```html
+
+```
+
+**After**:
+```html
+
+```
+
+**Add to `
+
+
+
+
+
+
+
Controls
+
+
+
+
Buffer mode: click “Select on map”, then click map to set center.
+
+
+
+
+
+
+
+
Click the map to set A (center). Press Esc to cancel.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Help
+
+ - Pick a location (Select on map) and radius for buffer A.
+ - Time window uses start month + duration.
+ - Offense groups & drilldown control which codes are included.
+ - Districts vs Tracts affects choropleth; rate toggle uses ACS for per-10k.
+ - Clusters aggregate dense points; zoom in to see incidents.
+ - See README for data sources & disclaimers.
+
+
+
+
+
Choropleth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Compare (A vs B)
+
Waiting for data…
+
+
+
Charts
+
+
+
+
+
+
+
+
+
+
+
+
+