diff --git a/accispace/HANDOFF.md b/accispace/HANDOFF.md new file mode 100644 index 0000000..b9362d3 --- /dev/null +++ b/accispace/HANDOFF.md @@ -0,0 +1,106 @@ +# ACCI Handoff + +Last updated: 2026-07-19 + +## Current state + +ACCI, the Austin Crash Context Interface, is live at [acci.handprotocol.org](https://acci.handprotocol.org). It is a standalone static Netlify app that renders recent fatal and suspected serious-injury crashes from the City of Austin Vision Zero crash-level dataset. + +The production Netlify project is `acci-handprotocol`, project ID `92b38e69-7254-4e2d-aed6-4f6795379b22`. The custom domain is managed through the existing `handprotocol.org` Netlify DNS zone and is covered by its wildcard TLS certificate. + +## Product behavior + +- The initial map loads the latest five calendar years of fatal and suspected serious-injury crashes. +- WebGL terrain, point markers, and severity-weighted hexagonal columns show spatial concentrations of harm. +- Year, travel mode, severity, and terrain controls update the map and totals. +- Address and landmark search is bounded to Austin through Photon. +- A selected address summarizes matching crashes within 80 meters. +- “Show accidents near location” expands the investigation radius to one kilometer. +- Double-clicking the map reverse-searches the selected point and shows a five-second “Search this pin” confirmation. +- Top-harm arrows step through the 20 highest severity-weighted concentrations under the active filters. +- The target control selects a random crash under the active filters. +- Tapping a crash opens its date, harm, speed, involved units, location, and source-record link. + +## Mobile interaction rules + +Mobile uses one active surface at a time. Search results, Explore Data, location summaries, crash details, about content, and pin confirmation must not stack. + +- Explore Data opens from the bottom-left settings control. +- Explore Data has an internal X close control. +- Crash and location details open as bottom sheets with safe-area padding. +- Crash markers retain their small visual dots but have 36 to 44 pixel invisible hit regions. +- Opening a new surface closes competing surfaces. +- Closing crash details restores the active location summary when one exists. + +## Files + +- `index.html`: semantic application shell, controls, summaries, prompts, and source notes. +- `style.css`: full visual system, responsive layout, bottom sheets, and reduced-motion behavior. +- `app.js`: map setup, City data query, filtering, spatial aggregation, search, reverse lookup, hotspot navigation, random targeting, and overlay state. +- `netlify.toml`: static publish configuration, security headers, and revalidated asset caching. +- `favicon.svg`: ACCI application mark. +- `README.md`: short local-development and data-source guide. + +## External services + +- Crash data: `https://data.austintexas.gov/resource/y2wy-tgr5.geojson` +- Geocoding and reverse lookup: `https://photon.komoot.io` +- Basemap: `https://tiles.openfreemap.org/styles/dark` +- Terrain tiles: AWS Terrain Tiles, Terrarium encoding +- Map runtime: MapLibre GL JS 5.24.0 from unpkg +- Fonts: Inter and JetBrains Mono from Google Fonts + +The app has no build step and no stored API secrets. External-service availability is a runtime dependency. + +## Local development + +```bash +cd accispace +python3 -m http.server 4173 +``` + +Open `http://localhost:4173`. + +## Validation + +Run before deployment: + +```bash +node --check app.js +npx --yes html-validate@10 index.html +git diff --check -- . +``` + +The latest browser verification covered: + +- Loading 2,063 live crash records +- Ranked hotspot navigation +- Random crash targeting +- Address and nearby-crash summaries +- Double-click reverse lookup and countdown dismissal +- Real crash-marker pointer selection +- Exclusive mobile overlays at 390 by 844 pixels +- Mobile Explore Data open and close behavior +- No browser exceptions during the verified flows + +## Deploy + +The directory is linked locally to the Netlify project through `.netlify/state.json`, which is intentionally ignored by git. Deploy manually with: + +```bash +netlify deploy --prod --dir . --message "Describe the ACCI update" +``` + +After deployment, verify both `https://acci.handprotocol.org` and the browser-level live data load. JavaScript and CSS use `max-age=0, must-revalidate` because the filenames are not content-hashed. + +## Known constraints and next moves + +- There is no bundled crash-data fallback if the City API is unavailable. +- Photon, OpenFreeMap, terrain tiles, fonts, and MapLibre are external runtime dependencies. +- The word “accident” appears in user-facing discovery controls because that is the requested language. City data and explanatory copy otherwise use “crash.” +- A future reliability pass could vendor MapLibre, self-host fonts, and create a scheduled crash-data snapshot. +- A future analysis pass could add accessible crash lists and permalinked filter state. + +## Repository safety + +ACCI work is confined to `accispace/`. The adjacent `wxl/` working changes belong to a separate workstream and must not be staged or modified as part of ACCI work. diff --git a/accispace/README.md b/accispace/README.md new file mode 100644 index 0000000..4aff589 --- /dev/null +++ b/accispace/README.md @@ -0,0 +1,17 @@ +# ACCI + +Austin Crash Context Interface is an experimental spatial view of fatal and serious-injury crashes in Austin. It reads the City of Austin's public Vision Zero crash-level dataset directly in the browser and renders severity-weighted harm cells over a WebGL terrain map. + +## Run locally + +```bash +python3 -m http.server 4173 +``` + +Open `http://localhost:4173`. + +## Data + +Source: [Austin Crash Report Data, Crash Level Records](https://data.austintexas.gov/Transportation-and-Mobility/Austin-Crash-Report-Data-Crash-Level-Records/y2wy-tgr5) + +The app requests fatal and suspected serious-injury records for the latest five calendar years. Data can be delayed, revised, incomplete, or imprecisely located. Recent totals are especially likely to change. diff --git a/accispace/app.js b/accispace/app.js new file mode 100644 index 0000000..097ba77 --- /dev/null +++ b/accispace/app.js @@ -0,0 +1,808 @@ +const DATASET_ID = "y2wy-tgr5"; +const DATASET_URL = `https://data.austintexas.gov/resource/${DATASET_ID}.geojson`; +const SOURCE_PAGE = `https://data.austintexas.gov/resource/${DATASET_ID}`; +const DEFAULT_VIEW = { center: [-97.7431, 30.2672], zoom: 10.35, pitch: 57, bearing: -18 }; +const CURRENT_YEAR = new Date().getFullYear(); +const FIRST_YEAR = Math.max(2016, CURRENT_YEAR - 4); +const ADDRESS_RADIUS_METERS = 80; +const NEARBY_RADIUS_METERS = 1000; + +const state = { + features: [], + firstYear: FIRST_YEAR, + mode: "all", + showFatal: true, + showSerious: true, + terrain: true, + selectedLocation: null, + locationRadius: null, + hotspots: [], + hotspotIndex: -1, + hotspotSignature: "", + navigationFeatures: [], +}; + +const elements = { + dataStatus: document.querySelector("#data-status"), + loading: document.querySelector("#loading-screen"), + loadingDetail: document.querySelector("#loading-detail"), + yearRange: document.querySelector("#year-range"), + yearOutput: document.querySelector("#year-output"), + statCrashes: document.querySelector("#stat-crashes"), + statLives: document.querySelector("#stat-lives"), + statInjuries: document.querySelector("#stat-injuries"), + showFatal: document.querySelector("#show-fatal"), + showSerious: document.querySelector("#show-serious"), + detailCard: document.querySelector("#detail-card"), + controls: document.querySelector("#controls"), + mobileToggle: document.querySelector("#mobile-toggle"), + aboutPanel: document.querySelector("#about-panel"), + placeSearch: document.querySelector("#place-search"), + placeQuery: document.querySelector("#place-query"), + searchResults: document.querySelector("#search-results"), + locationCard: document.querySelector("#location-card"), + pinPrompt: document.querySelector("#pin-prompt"), + pinConfirm: document.querySelector("#pin-confirm"), + hotspotPosition: document.querySelector("#hotspot-position"), + hotspotPrev: document.querySelector("#hotspot-prev"), + hotspotNext: document.querySelector("#hotspot-next"), + randomTarget: document.querySelector("#random-target"), +}; + +let searchMarker; +let candidateMarker; +let searchTimer; +let searchAbortController; +let pinCountdownTimer; +let pendingPin; + +elements.yearRange.min = FIRST_YEAR; +elements.yearRange.max = CURRENT_YEAR; +elements.yearRange.value = FIRST_YEAR; + +const map = new maplibregl.Map({ + container: "map", + style: "https://tiles.openfreemap.org/styles/dark", + ...DEFAULT_VIEW, + maxPitch: 72, + minZoom: 8, + maxZoom: 17, + hash: false, + attributionControl: false, + antialias: true, +}); + +map.doubleClickZoom.disable(); + +map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "bottom-right"); +map.addControl(new maplibregl.AttributionControl({ compact: true }), "bottom-right"); + +map.on("load", async () => { + softenBaseMap(); + addTerrain(); + addDataLayers(); + await loadCrashData(); +}); + +function softenBaseMap() { + const style = map.getStyle(); + style.layers.forEach((layer) => { + if (layer.type === "background") map.setPaintProperty(layer.id, "background-color", "#070604"); + if (layer.type === "symbol" && layer.layout?.["text-field"]) { + map.setPaintProperty(layer.id, "text-color", "#ad9270"); + map.setPaintProperty(layer.id, "text-halo-color", "#070604"); + map.setPaintProperty(layer.id, "text-halo-width", 1); + } + if (layer.type === "line") { + const id = layer.id.toLowerCase(); + if (id.includes("road") || id.includes("street") || id.includes("highway")) { + map.setPaintProperty(layer.id, "line-color", id.includes("motorway") ? "#74501e" : "#392812"); + map.setPaintProperty(layer.id, "line-opacity", 0.72); + } + } + }); + if (typeof map.setFog === "function") { + map.setFog({ color: "#070604", "high-color": "#2a1806", "horizon-blend": 0.16, "space-color": "#020201", "star-intensity": 0.14 }); + } +} + +function addTerrain() { + map.addSource("terrain", { + type: "raster-dem", + tiles: ["https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"], + tileSize: 256, + encoding: "terrarium", + maxzoom: 15, + }); + map.setTerrain({ source: "terrain", exaggeration: 1.65 }); +} + +function addDataLayers() { + map.addSource("harm-cells", { type: "geojson", data: emptyCollection() }); + map.addSource("crashes", { type: "geojson", data: emptyCollection() }); + + map.addLayer({ + id: "harm-cell-glow", + source: "harm-cells", + type: "fill-extrusion", + paint: { + "fill-extrusion-color": ["interpolate", ["linear"], ["get", "score"], 1, "#ffd36a", 8, "#ff9f1a", 20, "#ff573b"], + "fill-extrusion-height": ["interpolate", ["linear"], ["get", "score"], 1, 80, 25, 1350], + "fill-extrusion-base": 10, + "fill-extrusion-opacity": 0.18, + "fill-extrusion-vertical-gradient": true, + }, + }); + + map.addLayer({ + id: "harm-cells", + source: "harm-cells", + type: "line", + paint: { + "line-color": ["interpolate", ["linear"], ["get", "score"], 1, "#ffe6a8", 8, "#ffb000", 20, "#ff573b"], + "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.5, 13, 1.4], + "line-opacity": 0.72, + }, + }); + + map.addLayer({ + id: "crash-halo", + source: "crashes", + type: "circle", + paint: { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 3, 14, 10], + "circle-color": ["case", [">", ["to-number", ["get", "death_cnt"]], 0], "#ff573b", "#ffd05a"], + "circle-opacity": 0.12, + "circle-blur": 0.85, + }, + }); + + map.addLayer({ + id: "crash-points", + source: "crashes", + type: "circle", + paint: { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 1.4, 14, 4.5], + "circle-color": ["case", [">", ["to-number", ["get", "death_cnt"]], 0], "#ff573b", "#ffd05a"], + "circle-stroke-color": "#fff6e8", + "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 10, 0, 14, 0.8], + "circle-opacity": 0.92, + }, + }); + + map.addLayer({ + id: "crash-hit-targets", + source: "crashes", + type: "circle", + paint: { + "circle-radius": ["interpolate", ["linear"], ["zoom"], 9, 18, 14, 22], + "circle-color": "#ffffff", + "circle-opacity": 0.001, + }, + }); + + map.on("mouseenter", "crash-hit-targets", () => { map.getCanvas().style.cursor = "pointer"; }); + map.on("mouseleave", "crash-hit-targets", () => { map.getCanvas().style.cursor = "grab"; }); + map.on("click", "crash-hit-targets", (event) => showCrashDetail(event.features[0])); + map.on("click", (event) => { + const hits = map.queryRenderedFeatures(event.point, { layers: ["crash-hit-targets"] }); + if (!hits.length && !elements.detailCard.hidden) closeCrashDetail(); + }); + map.on("dblclick", handleMapDoubleClick); +} + +async function loadCrashData() { + const fields = [ + "id", "crash_timestamp_ct", "latitude", "longitude", "crash_sev_id", "death_cnt", + "sus_serious_injry_cnt", "units_involved", "rpt_block_num", "rpt_street_name", + "rpt_street_sfx", "crash_speed_limit", "pedestrian_death_count", + "pedestrian_serious_injury_count", "bicycle_death_count", "bicycle_serious_injury_count", + "motorcycle_death_count", "motorcycle_serious_injury_count", "micromobility_death_count", + "micromobility_serious_injury_count", "point", + ].join(","); + const where = `is_deleted=false AND crash_timestamp_ct >= '${FIRST_YEAR}-01-01T00:00:00' AND (crash_sev_id=1 OR crash_sev_id=4) AND point IS NOT NULL`; + const params = new URLSearchParams({ "$select": fields, "$where": where, "$limit": "5000", "$order": "crash_timestamp_ct DESC" }); + + try { + elements.loadingDetail.textContent = "Mapping fatal and serious-injury records"; + const response = await fetch(`${DATASET_URL}?${params}`); + if (!response.ok) throw new Error(`Data request returned ${response.status}`); + const data = await response.json(); + state.features = data.features.filter(isAustinCoordinate); + renderData(); + elements.dataStatus.textContent = `${state.features.length.toLocaleString()} records synced`; + window.setTimeout(() => elements.loading.classList.add("done"), 500); + } catch (error) { + console.error(error); + elements.dataStatus.textContent = "Live data unavailable"; + elements.loadingDetail.textContent = "The City data service did not respond. Please refresh to try again."; + elements.loading.querySelector("p").textContent = "Connection interrupted"; + } +} + +function isAustinCoordinate(feature) { + const [lng, lat] = feature.geometry?.coordinates || []; + return lng > -98.05 && lng < -97.45 && lat > 30.05 && lat < 30.58; +} + +function renderData() { + const scopeFiltered = state.features.filter(matchesFilters); + const filtered = state.selectedLocation && state.locationRadius + ? scopeFiltered.filter((feature) => distanceMeters(feature.geometry.coordinates, state.selectedLocation.geometry.coordinates) <= state.locationRadius) + : scopeFiltered; + const scopeCells = buildHexCells(scopeFiltered); + const filterSignature = `${state.firstYear}:${state.mode}:${state.showFatal}:${state.showSerious}`; + if (filterSignature !== state.hotspotSignature) { + state.hotspotIndex = -1; + state.hotspotSignature = filterSignature; + } + state.navigationFeatures = scopeFiltered; + state.hotspots = [...scopeCells.features].sort((a, b) => b.properties.score - a.properties.score).slice(0, 20); + map.getSource("crashes").setData({ type: "FeatureCollection", features: filtered }); + map.getSource("harm-cells").setData(filtered === scopeFiltered ? scopeCells : buildHexCells(filtered)); + + const totals = filtered.reduce((sum, feature) => { + sum.deaths += number(feature.properties.death_cnt); + sum.injuries += number(feature.properties.sus_serious_injry_cnt); + return sum; + }, { deaths: 0, injuries: 0 }); + + animateNumber(elements.statCrashes, filtered.length); + animateNumber(elements.statLives, totals.deaths); + animateNumber(elements.statInjuries, totals.injuries); + elements.yearOutput.textContent = `${state.firstYear} to present`; + updateLocationCard(scopeFiltered, filtered); + updateDiscoveryControls(); +} + +function matchesFilters(feature) { + const p = feature.properties; + const year = new Date(p.crash_timestamp_ct).getFullYear(); + const isFatal = number(p.death_cnt) > 0 || number(p.crash_sev_id) === 4; + const severityMatches = (isFatal && state.showFatal) || (!isFatal && state.showSerious); + if (year < state.firstYear || !severityMatches) return false; + if (state.mode === "walking") return number(p.pedestrian_death_count) + number(p.pedestrian_serious_injury_count) > 0; + if (state.mode === "bicycle") return number(p.bicycle_death_count) + number(p.bicycle_serious_injury_count) > 0; + if (state.mode === "motorcycle") return number(p.motorcycle_death_count) + number(p.motorcycle_serious_injury_count) > 0; + return true; +} + +function buildHexCells(features) { + const radius = 0.0062; + const vertical = radius * Math.sqrt(3); + const cells = new Map(); + + features.forEach((feature) => { + const [lng, lat] = feature.geometry.coordinates; + const row = Math.round((lat - 30.05) / vertical); + const offset = row % 2 ? radius * 0.75 : 0; + const column = Math.round((lng + 98.05 - offset) / (radius * 1.5)); + const key = `${column}:${row}`; + const score = Math.max(1, number(feature.properties.death_cnt) * 4 + number(feature.properties.sus_serious_injry_cnt)); + const existing = cells.get(key) || { lng: column * radius * 1.5 - 98.05 + offset, lat: row * vertical + 30.05, score: 0, crashes: 0 }; + existing.score += score; + existing.crashes += 1; + cells.set(key, existing); + }); + + return { + type: "FeatureCollection", + features: [...cells.values()].map((cell) => ({ + type: "Feature", + properties: { score: cell.score, crashes: cell.crashes, lng: cell.lng, lat: cell.lat }, + geometry: { type: "Polygon", coordinates: [hexagon(cell.lng, cell.lat, radius * 0.83)] }, + })), + }; +} + +function hexagon(lng, lat, radius) { + const points = []; + for (let i = 0; i < 6; i += 1) { + const angle = (Math.PI / 180) * (60 * i + 30); + points.push([lng + radius * Math.cos(angle), lat + radius * Math.sin(angle) * 0.86]); + } + points.push(points[0]); + return points; +} + +function showCrashDetail(feature) { + setExploreOpen(false); + closeSearchResults(); + if (!elements.aboutPanel.hidden) toggleAbout(false); + if (!elements.pinPrompt.hidden) dismissPinPrompt(true, false); + const p = feature.properties; + const date = new Date(p.crash_timestamp_ct); + const deaths = number(p.death_cnt); + const injuries = number(p.sus_serious_injry_cnt); + const address = [p.rpt_block_num, p.rpt_street_name, p.rpt_street_sfx].filter(Boolean).join(" "); + document.querySelector("#detail-severity").textContent = deaths ? "Fatal crash" : "Serious-injury crash"; + document.querySelector("#detail-severity").style.color = deaths ? "var(--coral)" : "var(--amber)"; + document.querySelector("#detail-location").textContent = address || "Location recorded in Austin"; + document.querySelector("#detail-date").textContent = date.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); + document.querySelector("#detail-harm").textContent = [deaths ? `${deaths} ${deaths === 1 ? "death" : "deaths"}` : "", injuries ? `${injuries} serious ${injuries === 1 ? "injury" : "injuries"}` : ""].filter(Boolean).join(", ") || "Not specified"; + document.querySelector("#detail-speed").textContent = number(p.crash_speed_limit) ? `${p.crash_speed_limit} mph` : "Not recorded"; + document.querySelector("#detail-mode").textContent = p.units_involved || "Not recorded"; + document.querySelector("#detail-source").href = `${SOURCE_PAGE}/${p.id}`; + elements.locationCard.hidden = true; + elements.detailCard.hidden = false; +} + +function closeCrashDetail() { + elements.detailCard.hidden = true; + if (state.selectedLocation && elements.aboutPanel.hidden && elements.pinPrompt.hidden && !elements.controls.classList.contains("open")) { + elements.locationCard.hidden = false; + } +} + +function updateLocationCard(scopeFeatures, visibleFeatures) { + if (!state.selectedLocation || !state.locationRadius) { + elements.locationCard.hidden = true; + return; + } + const properties = state.selectedLocation.properties || {}; + const isNearby = state.locationRadius === NEARBY_RADIUS_METERS; + const nearbyCount = scopeFeatures.filter((feature) => distanceMeters(feature.geometry.coordinates, state.selectedLocation.geometry.coordinates) <= NEARBY_RADIUS_METERS).length; + const totals = visibleFeatures.reduce((sum, feature) => { + sum.deaths += number(feature.properties.death_cnt); + sum.injuries += number(feature.properties.sus_serious_injry_cnt); + return sum; + }, { deaths: 0, injuries: 0 }); + document.querySelector("#location-scope").textContent = isNearby ? "Near this location" : "At this location"; + document.querySelector("#location-name").textContent = placeName(properties); + document.querySelector("#location-context").textContent = placeContext(properties); + document.querySelector("#location-crashes").textContent = visibleFeatures.length.toLocaleString(); + document.querySelector("#location-deaths").textContent = totals.deaths.toLocaleString(); + document.querySelector("#location-injuries").textContent = totals.injuries.toLocaleString(); + document.querySelector("#location-note").textContent = isNearby + ? "Showing records within 1 kilometer of this point. Current year, mode, and severity filters still apply." + : visibleFeatures.length + ? "Showing records located within 80 meters of this address. Current filters still apply." + : "No severe crashes match within 80 meters. Widen the view to inspect the surrounding area."; + document.querySelector("#show-nearby").textContent = `Show ${nearbyCount.toLocaleString()} accidents near location`; + document.querySelector("#show-nearby").hidden = isNearby; + document.querySelector("#show-address").textContent = properties.name === "Top harm concentration" ? "Narrow to this point" : "Return to this address"; + document.querySelector("#show-address").hidden = !isNearby; + elements.locationCard.hidden = false; + elements.detailCard.hidden = true; +} + +function placeName(properties = {}) { + const streetAddress = properties.housenumber && properties.street ? `${properties.housenumber} ${properties.street}` : ""; + return streetAddress || properties.name || properties.street || properties.district || "Selected Austin location"; +} + +function placeContext(properties = {}) { + return [properties.street, properties.city || "Austin", properties.state || "Texas"] + .filter((value, index, values) => value && value !== placeName(properties) && values.indexOf(value) === index) + .join(", ") || "Austin, Texas"; +} + +function distanceMeters([lngA, latA], [lngB, latB]) { + const toRadians = (degrees) => degrees * Math.PI / 180; + const earthRadius = 6371000; + const latDelta = toRadians(latB - latA); + const lngDelta = toRadians(lngB - lngA); + const a = Math.sin(latDelta / 2) ** 2 + + Math.cos(toRadians(latA)) * Math.cos(toRadians(latB)) * Math.sin(lngDelta / 2) ** 2; + return earthRadius * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function updateDiscoveryControls() { + const ready = state.hotspots.length > 0 && state.navigationFeatures.length > 0; + elements.hotspotPrev.disabled = !ready; + elements.hotspotNext.disabled = !ready; + elements.randomTarget.disabled = !ready; + if (!ready) { + elements.hotspotPosition.textContent = "No harm sites"; + } else if (state.hotspotIndex >= 0) { + elements.hotspotPosition.textContent = `Top ${String(state.hotspotIndex + 1).padStart(2, "0")} / ${state.hotspots.length}`; + } else { + elements.hotspotPosition.textContent = "Top harm sites"; + } +} + +function navigateHotspot(direction) { + if (!state.hotspots.length) return; + if (state.hotspotIndex < 0) state.hotspotIndex = direction > 0 ? 0 : state.hotspots.length - 1; + else state.hotspotIndex = (state.hotspotIndex + direction + state.hotspots.length) % state.hotspots.length; + const hotspot = state.hotspots[state.hotspotIndex]; + const coordinates = [number(hotspot.properties.lng), number(hotspot.properties.lat)]; + const nearest = nearestCrash(coordinates, state.navigationFeatures); + const street = nearest ? crashAddress(nearest.properties) : "Austin street network"; + const feature = { + type: "Feature", + geometry: { type: "Point", coordinates }, + properties: { name: "Top harm concentration", street, city: "Austin", state: "Texas" }, + }; + selectPlace(feature, NEARBY_RADIUS_METERS, 13.35); + updateDiscoveryControls(); +} + +function targetRandomCrash() { + if (!state.navigationFeatures.length) return; + const randomIndex = Math.floor(Math.random() * state.navigationFeatures.length); + const crash = state.navigationFeatures[randomIndex]; + const feature = { + type: "Feature", + geometry: crash.geometry, + properties: { + name: "Random crash target", + street: crashAddress(crash.properties), + city: "Austin", + state: "Texas", + }, + }; + state.hotspotIndex = -1; + selectPlace(feature, ADDRESS_RADIUS_METERS, 15.3); + elements.hotspotPosition.textContent = "Random target"; +} + +function nearestCrash(coordinates, features) { + return features.reduce((nearest, feature) => { + const distance = distanceMeters(coordinates, feature.geometry.coordinates); + return !nearest || distance < nearest.distance ? { feature, distance } : nearest; + }, null)?.feature; +} + +function crashAddress(properties = {}) { + const address = [properties.rpt_block_num, properties.rpt_street_name, properties.rpt_street_sfx].filter(Boolean).join(" "); + return address && !address.toLowerCase().includes("not reported") ? address : "Recorded Austin location"; +} + +function number(value) { return Number(value) || 0; } +function emptyCollection() { return { type: "FeatureCollection", features: [] }; } + +function animateNumber(element, value) { + const start = Number(element.textContent.replace(/\D/g, "")) || 0; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + element.textContent = value.toLocaleString(); + return; + } + const started = performance.now(); + const duration = 350; + function frame(now) { + const progress = Math.min(1, (now - started) / duration); + const eased = 1 - Math.pow(1 - progress, 3); + element.textContent = Math.round(start + (value - start) * eased).toLocaleString(); + if (progress < 1) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); +} + +elements.yearRange.addEventListener("input", (event) => { + state.firstYear = Number(event.target.value); + renderData(); +}); + +document.querySelectorAll("#mode-filter button").forEach((button) => { + button.addEventListener("click", () => { + document.querySelectorAll("#mode-filter button").forEach((item) => { + item.classList.toggle("active", item === button); + item.setAttribute("aria-pressed", String(item === button)); + }); + state.mode = button.dataset.mode; + renderData(); + }); +}); + +elements.showFatal.addEventListener("change", () => { + state.showFatal = elements.showFatal.checked; + if (!state.showFatal && !state.showSerious) { + elements.showSerious.checked = true; + state.showSerious = true; + } + renderData(); +}); + +elements.showSerious.addEventListener("change", () => { + state.showSerious = elements.showSerious.checked; + if (!state.showFatal && !state.showSerious) { + elements.showFatal.checked = true; + state.showFatal = true; + } + renderData(); +}); + +document.querySelector("#reset-view").addEventListener("click", () => map.flyTo({ ...DEFAULT_VIEW, duration: 900, essential: true })); +document.querySelector("#toggle-terrain").addEventListener("click", (event) => { + state.terrain = !state.terrain; + map.setTerrain(state.terrain ? { source: "terrain", exaggeration: 1.65 } : null); + event.currentTarget.classList.toggle("active", state.terrain); + event.currentTarget.setAttribute("aria-pressed", String(state.terrain)); + event.currentTarget.lastChild.textContent = state.terrain ? " Terrain on" : " Terrain off"; +}); + +document.querySelector("#detail-close").addEventListener("click", closeCrashDetail); + +document.querySelector("#show-nearby").addEventListener("click", () => { + state.locationRadius = NEARBY_RADIUS_METERS; + renderData(); + map.flyTo({ center: state.selectedLocation.geometry.coordinates, zoom: 13.35, pitch: 57, duration: 700, essential: true }); +}); + +document.querySelector("#show-address").addEventListener("click", () => { + state.locationRadius = ADDRESS_RADIUS_METERS; + renderData(); + map.flyTo({ center: state.selectedLocation.geometry.coordinates, zoom: 15.3, pitch: 62, duration: 700, essential: true }); +}); + +document.querySelector("#location-close").addEventListener("click", clearSelectedLocation); + +elements.hotspotPrev.addEventListener("click", () => navigateHotspot(-1)); +elements.hotspotNext.addEventListener("click", () => navigateHotspot(1)); +elements.randomTarget.addEventListener("click", targetRandomCrash); + +function setExploreOpen(open) { + if (open) { + closeSearchResults(); + elements.detailCard.hidden = true; + elements.locationCard.hidden = true; + if (!elements.aboutPanel.hidden) toggleAbout(false); + if (!elements.pinPrompt.hidden) dismissPinPrompt(true, false); + } + elements.controls.classList.toggle("open", open); + elements.mobileToggle.setAttribute("aria-expanded", String(open)); + elements.mobileToggle.querySelector("span").textContent = "Explore data"; +} + +elements.mobileToggle.addEventListener("click", () => setExploreOpen(!elements.controls.classList.contains("open"))); +document.querySelector("#controls-close").addEventListener("click", () => setExploreOpen(false)); + +function toggleAbout(open) { + if (open) { + setExploreOpen(false); + closeSearchResults(); + elements.detailCard.hidden = true; + elements.locationCard.hidden = true; + if (!elements.pinPrompt.hidden) dismissPinPrompt(true, false); + } + elements.aboutPanel.hidden = !open; + document.querySelector("#about-toggle").setAttribute("aria-expanded", String(open)); + if (open) document.querySelector("#about-close").focus(); +} + +document.querySelector("#about-toggle").addEventListener("click", () => toggleAbout(elements.aboutPanel.hidden)); +document.querySelector("#about-close").addEventListener("click", () => toggleAbout(false)); + +elements.placeSearch.addEventListener("submit", async (event) => { + event.preventDefault(); + await searchPlaces(elements.placeQuery.value, true); +}); + +elements.placeQuery.addEventListener("input", () => { + window.clearTimeout(searchTimer); + const query = elements.placeQuery.value.trim(); + if (query.length < 3) { + closeSearchResults(); + return; + } + searchTimer = window.setTimeout(() => searchPlaces(query, false), 260); +}); + +elements.placeQuery.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + const first = elements.searchResults.querySelector("button"); + if (first) { + event.preventDefault(); + first.focus(); + } + } +}); + +document.addEventListener("click", (event) => { + if (!elements.placeSearch.contains(event.target)) closeSearchResults(); +}); + +async function searchPlaces(rawQuery, selectFirst) { + const query = rawQuery.trim(); + if (query.length < 2) return; + setExploreOpen(false); + elements.detailCard.hidden = true; + elements.locationCard.hidden = true; + if (!elements.aboutPanel.hidden) toggleAbout(false); + if (!elements.pinPrompt.hidden) dismissPinPrompt(true, false); + if (searchAbortController) searchAbortController.abort(); + searchAbortController = new AbortController(); + elements.searchResults.hidden = false; + elements.searchResults.replaceChildren(makeStatusItem("Searching Austin...")); + elements.placeQuery.setAttribute("aria-expanded", "true"); + + try { + const params = new URLSearchParams({ + q: `${query}, Austin, Texas`, + limit: "6", + bbox: "-98.05,30.05,-97.45,30.58", + lang: "en", + }); + const response = await fetch(`https://photon.komoot.io/api/?${params}`, { signal: searchAbortController.signal }); + if (!response.ok) throw new Error(`Location search returned ${response.status}`); + const data = await response.json(); + const results = data.features.filter(isAustinCoordinate).slice(0, 6); + if (selectFirst && results[0]) { + selectPlace(results[0]); + return; + } + renderSearchResults(results); + } catch (error) { + if (error.name === "AbortError") return; + console.error(error); + const localResults = findCrashStreets(query); + if (selectFirst && localResults[0]) selectPlace(localResults[0]); + else renderSearchResults(localResults, "Location service unavailable. Showing crash streets."); + } +} + +function findCrashStreets(query) { + const needle = query.toLowerCase(); + const seen = new Set(); + return state.features.filter((feature) => { + const street = [feature.properties.rpt_street_name, feature.properties.rpt_street_sfx].filter(Boolean).join(" "); + if (!street.toLowerCase().includes(needle) || seen.has(street)) return false; + seen.add(street); + feature.properties.name = street; + feature.properties.city = "Austin"; + feature.properties.state = "Texas"; + return true; + }).slice(0, 6); +} + +function renderSearchResults(results, note = "") { + elements.searchResults.replaceChildren(); + if (note) elements.searchResults.append(makeStatusItem(note)); + if (!results.length) { + elements.searchResults.append(makeStatusItem("No Austin location found. Try a street, park, or landmark.")); + return; + } + results.forEach((feature) => { + const properties = feature.properties || {}; + const name = properties.name || properties.street || properties.district || "Austin location"; + const context = [properties.street, properties.city, properties.state].filter((value, index, values) => value && value !== name && values.indexOf(value) === index).join(", "); + const item = document.createElement("li"); + const button = document.createElement("button"); + const strong = document.createElement("strong"); + const span = document.createElement("span"); + button.type = "button"; + strong.textContent = name; + span.textContent = context || "Austin, Texas"; + button.append(strong, span); + button.addEventListener("click", () => selectPlace(feature)); + button.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") item.nextElementSibling?.querySelector("button")?.focus(); + if (event.key === "ArrowUp") (item.previousElementSibling?.querySelector("button") || elements.placeQuery).focus(); + }); + item.append(button); + elements.searchResults.append(item); + }); +} + +function makeStatusItem(text) { + const item = document.createElement("li"); + item.className = "search-empty"; + item.textContent = text; + return item; +} + +function selectPlace(feature, initialRadius = ADDRESS_RADIUS_METERS, zoom = 15.3) { + const coordinates = feature.geometry.coordinates; + const name = placeName(feature.properties); + setExploreOpen(false); + elements.detailCard.hidden = true; + if (!elements.aboutPanel.hidden) toggleAbout(false); + elements.placeQuery.value = name; + closeSearchResults(); + if (searchMarker) searchMarker.remove(); + if (candidateMarker) { + candidateMarker.remove(); + candidateMarker = null; + } + const markerElement = document.createElement("div"); + markerElement.style.cssText = "width:18px;height:18px;border:2px solid #ffe0a3;border-radius:2px;background:#070604;box-shadow:0 0 0 6px rgba(255,176,0,.18),0 0 24px #ffb000;transform:rotate(45deg)"; + searchMarker = new maplibregl.Marker({ element: markerElement }).setLngLat(coordinates).addTo(map); + state.selectedLocation = feature; + state.locationRadius = initialRadius; + renderData(); + map.flyTo({ center: coordinates, zoom, pitch: 62, bearing: -12, duration: 1100, essential: true }); +} + +function clearSelectedLocation() { + state.selectedLocation = null; + state.locationRadius = null; + elements.locationCard.hidden = true; + elements.detailCard.hidden = true; + elements.placeQuery.value = ""; + if (searchMarker) { + searchMarker.remove(); + searchMarker = null; + } + renderData(); +} + +function closeSearchResults() { + elements.searchResults.hidden = true; + elements.placeQuery.setAttribute("aria-expanded", "false"); +} + +async function handleMapDoubleClick(event) { + event.originalEvent.preventDefault(); + setExploreOpen(false); + closeSearchResults(); + elements.detailCard.hidden = true; + elements.locationCard.hidden = true; + if (!elements.aboutPanel.hidden) toggleAbout(false); + const coordinates = [event.lngLat.lng, event.lngLat.lat]; + if (candidateMarker) candidateMarker.remove(); + const markerElement = document.createElement("div"); + markerElement.style.cssText = "width:20px;height:20px;border:1px solid #ffb000;background:rgba(7,6,4,.78);box-shadow:0 0 0 8px rgba(255,176,0,.12),0 0 28px #ffb000;transform:rotate(45deg)"; + candidateMarker = new maplibregl.Marker({ element: markerElement }).setLngLat(coordinates).addTo(map); + pendingPin = { + type: "Feature", + geometry: { type: "Point", coordinates }, + properties: { name: "Selected map point", city: "Austin", state: "Texas" }, + }; + window.clearInterval(pinCountdownTimer); + elements.pinPrompt.hidden = false; + elements.pinConfirm.disabled = true; + document.querySelector("#pin-prompt-label").textContent = "Locating this point"; + document.querySelector("#pin-prompt-context").textContent = `${coordinates[1].toFixed(5)}, ${coordinates[0].toFixed(5)}`; + document.querySelector("#pin-countdown").textContent = "..."; + + try { + const params = new URLSearchParams({ lon: coordinates[0], lat: coordinates[1], lang: "en" }); + const response = await fetch(`https://photon.komoot.io/reverse?${params}`); + if (!response.ok) throw new Error(`Reverse location search returned ${response.status}`); + const data = await response.json(); + const result = data.features?.[0]; + if (result) pendingPin.properties = result.properties || pendingPin.properties; + } catch (error) { + console.error(error); + } + + document.querySelector("#pin-prompt-label").textContent = placeName(pendingPin.properties); + document.querySelector("#pin-prompt-context").textContent = placeContext(pendingPin.properties); + elements.pinConfirm.disabled = false; + startPinCountdown(); +} + +function startPinCountdown() { + let seconds = 5; + const countdown = document.querySelector("#pin-countdown"); + countdown.textContent = seconds; + countdown.setAttribute("aria-label", `Closes in ${seconds} seconds`); + window.clearInterval(pinCountdownTimer); + pinCountdownTimer = window.setInterval(() => { + seconds -= 1; + countdown.textContent = seconds; + countdown.setAttribute("aria-label", `Closes in ${seconds} seconds`); + if (seconds <= 0) dismissPinPrompt(); + }, 1000); +} + +function dismissPinPrompt(removeCandidate = true, restoreLocation = true) { + window.clearInterval(pinCountdownTimer); + elements.pinPrompt.hidden = true; + if (removeCandidate && candidateMarker) { + candidateMarker.remove(); + candidateMarker = null; + } + if (removeCandidate) pendingPin = null; + if (restoreLocation && state.selectedLocation && elements.detailCard.hidden && elements.aboutPanel.hidden && !elements.controls.classList.contains("open")) { + elements.locationCard.hidden = false; + } +} + +elements.pinConfirm.addEventListener("click", () => { + if (!pendingPin) return; + const selectedPin = pendingPin; + dismissPinPrompt(false); + selectPlace(selectedPin); + pendingPin = null; +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + elements.detailCard.hidden = true; + if (!elements.pinPrompt.hidden) dismissPinPrompt(); + if (!elements.aboutPanel.hidden) toggleAbout(false); + setExploreOpen(false); + } +}); diff --git a/accispace/favicon.svg b/accispace/favicon.svg new file mode 100644 index 0000000..4a5d8d5 --- /dev/null +++ b/accispace/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/accispace/index.html b/accispace/index.html new file mode 100644 index 0000000..c47c49e --- /dev/null +++ b/accispace/index.html @@ -0,0 +1,262 @@ + + + + + + + + + + + ACCI | Austin Crash Context Interface + + + + + + + + + + +
+
+ + + +
+ + + + ACCI + Austin Crash Context Interface + + + +
+ + +
+ + Connecting to Austin open data +
+
+ + + + +
+ + + + + + + + + + + + + + + +
+ +

Reading Austin's street-safety record

+ Opening the public dataset +
+
+ + + + + diff --git a/accispace/netlify.toml b/accispace/netlify.toml new file mode 100644 index 0000000..dd70fbe --- /dev/null +++ b/accispace/netlify.toml @@ -0,0 +1,20 @@ +[build] + publish = "." + +[[headers]] + for = "/*" + [headers.values] + X-Frame-Options = "DENY" + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" + Permissions-Policy = "camera=(), microphone=(), geolocation=()" + +[[headers]] + for = "/*.css" + [headers.values] + Cache-Control = "public, max-age=0, must-revalidate" + +[[headers]] + for = "/*.js" + [headers.values] + Cache-Control = "public, max-age=0, must-revalidate" diff --git a/accispace/style.css b/accispace/style.css new file mode 100644 index 0000000..7145c21 --- /dev/null +++ b/accispace/style.css @@ -0,0 +1,464 @@ +:root { + color-scheme: dark; + --bg: #070604; + --panel: rgba(13, 10, 6, 0.94); + --panel-soft: rgba(24, 17, 8, 0.86); + --line: rgba(255, 194, 82, 0.2); + --line-strong: rgba(255, 194, 82, 0.5); + --text: #fff8e9; + --muted: #c6bca8; + --faint: #8f8675; + --mint: #ffb000; + --mint-bright: #ffe0a3; + --amber: #ffd05a; + --coral: #ff573b; + --font-sans: "Inter", system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; margin: 0; overflow: hidden; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; +} + +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } + +button:focus-visible, a:focus-visible, input:focus-visible { + outline: 2px solid var(--mint-bright); + outline-offset: 3px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: fixed; + z-index: 1000; + top: 0.75rem; + left: 0.75rem; + padding: 0.7rem 1rem; + border-radius: 999px; + background: var(--mint-bright); + color: #070604; + font-weight: 700; + transform: translateY(-180%); + transition: transform 150ms ease; +} + +.skip-link:focus { transform: translateY(0); } + +.app-shell, #map { position: fixed; inset: 0; } + +#map { background: radial-gradient(circle at 60% 45%, #2a1806, var(--bg) 58%); } + +.scan-lines, .map-vignette { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 2; +} + +.scan-lines { + opacity: 0.08; + background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 3px, rgba(255, 190, 69, 0.15) 4px); + background-size: 100% 4px; +} + +.map-vignette { + background: radial-gradient(circle at 60% 48%, transparent 25%, rgba(7, 4, 1, 0.2) 62%, rgba(3, 2, 1, 0.82) 100%); +} + +.topbar { + position: fixed; + z-index: 10; + top: 0; + left: 0; + right: 0; + height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0 1.5rem; + border-bottom: 1px solid var(--line); + background: linear-gradient(to bottom, rgba(9, 7, 4, 0.96), rgba(9, 7, 4, 0.76)); + backdrop-filter: blur(14px); +} + +.brand { display: flex; align-items: center; gap: 0.8rem; color: var(--text); text-decoration: none; } +.brand__mark { width: 40px; height: 40px; color: var(--mint); } +.brand__mark svg { width: 100%; height: 100%; stroke: currentColor; stroke-width: 1.25; } +.brand strong { display: block; font-size: 1.02rem; letter-spacing: 0.18em; } +.brand small { display: block; margin-top: 0.2rem; color: var(--muted); font: 0.61rem/1 var(--font-mono); letter-spacing: 0.08em; text-transform: uppercase; } + +.topbar__status { + display: flex; + align-items: center; + gap: 0.55rem; + color: var(--muted); + font: 0.67rem/1 var(--font-mono); + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.topbar__tools { display: flex; align-items: center; gap: 1rem; margin-left: auto; } +.hotspot-nav { display: flex; align-items: center; height: 42px; overflow: hidden; border: 1px solid var(--line); border-radius: 2px; background: rgba(12, 9, 5, 0.86); } +.hotspot-nav button { height: 100%; min-width: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.65rem; border: 0; border-left: 1px solid var(--line); background: transparent; color: var(--muted); cursor: pointer; } +.hotspot-nav button:first-child { border-left: 0; } +.hotspot-nav button:hover:not(:disabled) { background: rgba(255, 176, 0, 0.1); color: var(--mint-bright); } +.hotspot-nav button:disabled { cursor: wait; opacity: 0.35; } +.hotspot-nav svg { width: 17px; height: 17px; flex: 0 0 auto; stroke: currentColor; stroke-width: 1.5; } +.hotspot-nav__rank { min-width: 122px !important; justify-content: space-between !important; } +.hotspot-nav__rank span { color: var(--mint-bright); font: 0.57rem/1 var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; } + +.place-search { + position: absolute; + left: 50%; + top: 15px; + width: min(380px, 32vw); + height: 46px; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0 0.35rem 0 0.85rem; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(12, 9, 5, 0.9); + transform: translateX(-50%); +} + +.place-search:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px rgba(255, 176, 0, 0.09); } +.place-search > svg { width: 17px; flex: 0 0 auto; stroke: var(--muted); stroke-width: 1.6; } +.place-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 0.72rem; } +.place-search input::placeholder { color: var(--faint); } +.place-search > button { min-width: 54px; height: 34px; border: 0; border-radius: 2px; background: rgba(255, 176, 0, 0.14); color: var(--mint-bright); font-size: 0.62rem; font-weight: 600; cursor: pointer; } +.place-search > button:hover { background: rgba(255, 176, 0, 0.24); } + +.search-results { + position: absolute; + z-index: 20; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + max-height: 300px; + overflow: auto; + margin: 0; + padding: 0.4rem; + list-style: none; + border: 1px solid var(--line-strong); + border-radius: 2px; + background: #120d06; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); +} +.search-results[hidden] { display: none; } +.search-results li { margin: 0; } +.search-results button { width: 100%; display: block; padding: 0.7rem 0.75rem; border: 0; border-radius: 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; } +.search-results button:hover, .search-results button:focus-visible { background: rgba(255, 176, 0, 0.1); } +.search-results strong { display: block; overflow: hidden; color: var(--text); font-size: 0.7rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.search-results span { display: block; margin-top: 0.25rem; overflow: hidden; color: var(--faint); font: 0.55rem var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.search-results .search-empty { padding: 0.8rem; color: var(--muted); font-size: 0.66rem; } + +.signal { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--mint); + box-shadow: 0 0 0 5px rgba(255, 176, 0, 0.1), 0 0 18px var(--mint); +} + +.icon-button, .mobile-toggle { + border: 1px solid var(--line-strong); + background: rgba(18, 12, 5, 0.85); + color: var(--text); + border-radius: 999px; + min-height: 44px; + padding: 0 0.9rem; + cursor: pointer; +} + +.mobile-toggle { display: none; align-items: center; gap: 0.5rem; font-size: 0.75rem; font-weight: 600; } +.mobile-toggle svg { width: 18px; stroke: currentColor; stroke-width: 1.5; } + +.control-panel { + position: fixed; + z-index: 8; + left: 1.5rem; + top: 100px; + bottom: 50px; + width: min(330px, calc(100vw - 3rem)); + display: flex; + flex-direction: column; + overflow: auto; + padding: 1.4rem; + border: 1px solid var(--line); + border-radius: 2px; + background: var(--panel); + box-shadow: 10px 12px 0 rgba(0, 0, 0, 0.24), 0 28px 70px rgba(0, 0, 0, 0.42); + scrollbar-width: thin; + scrollbar-color: var(--line-strong) transparent; +} + +.controls-close { display: none; } + +.eyebrow { + margin: 0 0 0.65rem; + color: var(--mint); + font: 600 0.62rem/1.3 var(--font-mono); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.panel-heading h1 { + margin: 0; + max-width: 10ch; + font-size: clamp(1.8rem, 3vw, 2.55rem); + line-height: 0.98; + letter-spacing: -0.045em; +} + +.panel-heading > p:last-child { margin: 0.85rem 0 0; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 1.35rem 0; + padding: 1rem 0; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); +} + +.stats div + div { border-left: 1px solid var(--line); padding-left: 0.85rem; } +.stats span { display: block; font: 600 1.22rem/1 var(--font-mono); color: var(--mint-bright); } +.stats small { display: block; margin-top: 0.42rem; color: var(--faint); font: 0.56rem/1.35 var(--font-mono); text-transform: uppercase; } + +.filter-group { margin: 0 0 1.15rem; padding: 0; border: 0; } +.filter-group legend, .filter-label { width: 100%; margin-bottom: 0.65rem; color: var(--muted); font: 500 0.64rem/1 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; } +.filter-label { display: flex; justify-content: space-between; } +.filter-label output { color: var(--mint); text-transform: none; } + +input[type="range"] { width: 100%; height: 18px; margin: 0; accent-color: var(--mint); cursor: pointer; } +.range-labels { display: flex; justify-content: space-between; color: var(--faint); font: 0.55rem var(--font-mono); } + +.segment-control { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: rgba(0, 0, 0, 0.18); } +.segment-control button { min-height: 34px; padding: 0.35rem; border: 0; border-radius: 7px; background: transparent; color: var(--muted); font-size: 0.65rem; cursor: pointer; } +.segment-control button:hover { color: var(--text); } +.segment-control button.active { background: rgba(255, 176, 0, 0.14); color: var(--mint-bright); box-shadow: inset 0 0 0 1px rgba(255, 176, 0, 0.18); } + +.severity-filter { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; } +.severity-filter legend { grid-column: 1 / -1; } +.severity-filter label { display: flex; align-items: center; gap: 0.45rem; color: var(--muted); font-size: 0.68rem; cursor: pointer; } +.severity-filter input { position: absolute; opacity: 0; pointer-events: none; } +.legend-dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; box-shadow: 0 0 9px currentColor; } +.legend-dot--fatal { color: var(--coral); background: var(--coral); } +.legend-dot--serious { color: var(--amber); background: var(--amber); } +.severity-filter input:not(:checked) + .legend-dot { background: transparent; box-shadow: inset 0 0 0 1px currentColor; opacity: 0.45; } + +.view-actions { display: flex; gap: 0.5rem; margin-top: auto; padding-top: 0.35rem; } +.text-button { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 40px; padding: 0 0.7rem; border: 1px solid var(--line); border-radius: 999px; background: transparent; color: var(--muted); font-size: 0.65rem; cursor: pointer; } +.text-button:hover, .text-button.active { border-color: var(--line-strong); color: var(--mint-bright); background: rgba(255, 176, 0, 0.07); } +.text-button svg { width: 15px; stroke: currentColor; stroke-width: 1.5; } +.interaction-hint { display: flex; gap: 0.45rem; margin: 0.85rem 0 0; color: var(--faint); font-size: 0.61rem; line-height: 1.45; } + +.detail-card { + position: fixed; + z-index: 9; + right: 1.5rem; + top: 100px; + width: min(320px, calc(100vw - 3rem)); + padding: 1.35rem; + border: 1px solid var(--line-strong); + border-radius: 2px; + background: var(--panel); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); +} + +.detail-card[hidden] { display: none; } +.detail-card__close, .about-panel__close { position: absolute; top: 0.8rem; right: 0.9rem; width: 36px; height: 36px; border: 0; background: transparent; color: var(--muted); font-size: 1.5rem; cursor: pointer; } +.detail-card h2 { max-width: 13ch; margin: 0 0 1rem; font-size: 1.35rem; letter-spacing: -0.025em; } +.detail-card dl { margin: 0; border-top: 1px solid var(--line); } +.detail-card dl div { display: grid; grid-template-columns: 0.9fr 1.2fr; gap: 0.75rem; padding: 0.7rem 0; border-bottom: 1px solid var(--line); } +.detail-card dt { color: var(--faint); font: 0.59rem var(--font-mono); text-transform: uppercase; } +.detail-card dd { margin: 0; color: var(--text); font-size: 0.7rem; line-height: 1.35; text-align: right; } +.detail-card a { display: inline-block; margin-top: 1rem; color: var(--mint); font-size: 0.68rem; text-decoration: none; } +.detail-card a:hover { color: var(--mint-bright); } + +.location-card { + position: fixed; + z-index: 9; + right: 1.5rem; + top: 100px; + width: min(330px, calc(100vw - 3rem)); + padding: 1.35rem; + border: 1px solid var(--line-strong); + border-radius: 2px; + background: var(--panel); + box-shadow: 10px 12px 0 rgba(0, 0, 0, 0.24), 0 24px 60px rgba(0, 0, 0, 0.42); +} + +.location-card[hidden] { display: none; } +.location-card__close { position: absolute; top: 0.8rem; right: 0.9rem; width: 36px; height: 36px; border: 0; background: transparent; color: var(--muted); font-size: 1.5rem; cursor: pointer; } +.location-card h2 { max-width: 15ch; margin: 0; font-size: 1.35rem; letter-spacing: -0.025em; } +.location-card__context { margin: 0.4rem 0 1rem; color: var(--faint); font: 0.57rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; } +.location-summary { display: grid; grid-template-columns: repeat(3, 1fr); margin: 0; padding: 1rem 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.location-summary div { padding: 0 0.65rem; } +.location-summary div:first-child { padding-left: 0; } +.location-summary div + div { border-left: 1px solid var(--line); } +.location-summary dt { color: var(--faint); font: 0.52rem/1.35 var(--font-mono); text-transform: uppercase; } +.location-summary dd { margin: 0.45rem 0 0; color: var(--mint-bright); font: 600 1.15rem/1 var(--font-mono); } +.location-card__note { margin: 0.85rem 0; color: var(--muted); font-size: 0.65rem; line-height: 1.5; } +.location-card__actions { display: grid; gap: 0.5rem; } +.location-card__actions button { min-height: 42px; padding: 0.65rem 0.8rem; border: 1px solid var(--line-strong); border-radius: 2px; background: rgba(255, 176, 0, 0.13); color: var(--mint-bright); font-size: 0.66rem; font-weight: 600; text-align: left; cursor: pointer; } +.location-card__actions button:hover { background: rgba(255, 176, 0, 0.22); } +.location-card__actions button[hidden] { display: none; } + +.pin-prompt { + position: fixed; + z-index: 24; + left: 50%; + bottom: 3.25rem; + width: min(520px, calc(100vw - 3rem)); + min-height: 70px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.85rem; + padding: 0.65rem 0.7rem 0.65rem 0.9rem; + border: 1px solid var(--line-strong); + border-radius: 2px; + background: #120d06; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.56); + transform: translateX(-50%); +} + +.pin-prompt[hidden] { display: none; } +.pin-prompt__target { position: relative; width: 28px; height: 28px; border: 1px solid var(--mint); transform: rotate(45deg); } +.pin-prompt__target::before, .pin-prompt__target::after { content: ""; position: absolute; background: var(--mint); opacity: 0.5; } +.pin-prompt__target::before { top: 50%; left: -7px; right: -7px; height: 1px; } +.pin-prompt__target::after { left: 50%; top: -7px; bottom: -7px; width: 1px; } +.pin-prompt__target i { position: absolute; inset: 9px; background: var(--mint-bright); box-shadow: 0 0 12px var(--mint); } +.pin-prompt p { margin: 0; overflow: hidden; color: var(--text); font-size: 0.7rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.pin-prompt > div:nth-child(2) span { display: block; margin-top: 0.25rem; overflow: hidden; color: var(--faint); font: 0.53rem var(--font-mono); text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; } +.pin-prompt button { min-height: 42px; padding: 0 0.85rem; border: 1px solid var(--line-strong); border-radius: 2px; background: var(--mint); color: #171005; font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; cursor: pointer; } +.pin-prompt button:disabled { cursor: wait; opacity: 0.55; } +.pin-prompt__countdown { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--line); border-radius: 50%; color: var(--muted); font: 0.65rem var(--font-mono); } + +.map-key { position: fixed; z-index: 7; right: 1.5rem; bottom: 3.2rem; display: flex; align-items: center; gap: 0.55rem; color: var(--faint); font: 0.52rem var(--font-mono); letter-spacing: 0.06em; } +.map-key i { display: block; width: 90px; height: 4px; border-radius: 999px; background: linear-gradient(90deg, var(--mint), var(--amber), var(--coral)); box-shadow: 0 0 12px rgba(242, 169, 59, 0.28); } + +.source-note { position: fixed; z-index: 8; left: 1.5rem; right: 1.5rem; bottom: 0; min-height: 42px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; color: var(--faint); font: 0.55rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.05em; } +.source-note button { min-height: 32px; padding: 0; border: 0; border-bottom: 1px solid var(--line-strong); background: transparent; color: var(--muted); font: inherit; text-transform: inherit; cursor: pointer; } +.source-note button:hover { color: var(--mint-bright); } + +.about-panel { + position: fixed; + z-index: 30; + left: 50%; + bottom: 1.5rem; + width: min(920px, calc(100vw - 3rem)); + max-height: calc(100vh - 3rem); + overflow: auto; + padding: clamp(1.5rem, 4vw, 3rem); + border: 1px solid var(--line-strong); + border-radius: 2px; + background: #120d06; + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.65); + transform: translateX(-50%); +} +.about-panel[hidden] { display: none; } +.about-panel h2 { margin: 0 0 2rem; font-size: clamp(1.8rem, 4vw, 3rem); letter-spacing: -0.04em; } +.about-grid { display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: clamp(1.2rem, 3vw, 2.5rem); } +.about-grid h3 { margin: 0 0 0.6rem; font-size: 0.8rem; } +.about-grid p { margin: 0; color: var(--muted); font-size: 0.76rem; line-height: 1.65; } +.about-links { display: flex; gap: 1.5rem; margin-top: 2rem; padding-top: 1.2rem; border-top: 1px solid var(--line); } +.about-links a { color: var(--mint); font-size: 0.7rem; text-decoration: none; } + +.loading-screen { position: fixed; z-index: 50; inset: 0; display: grid; place-content: center; justify-items: center; background: #070604; transition: opacity 400ms ease, visibility 400ms ease; } +.loading-screen.done { opacity: 0; visibility: hidden; } +.loading-screen p { margin: 1.5rem 0 0.35rem; font-size: 0.82rem; font-weight: 600; } +.loading-screen span { color: var(--faint); font: 0.58rem var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; } +.loader-orbit { position: relative; width: 76px; height: 76px; border: 1px solid var(--line-strong); border-radius: 50%; animation: orbit 3s linear infinite; } +.loader-orbit::before, .loader-orbit::after { content: ""; position: absolute; inset: 12px; border: 1px solid var(--line); border-radius: 50%; } +.loader-orbit::after { inset: 27px; background: var(--mint); box-shadow: 0 0 25px var(--mint); } +.loader-orbit i { position: absolute; top: 50%; left: 50%; width: 5px; height: 5px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 12px var(--amber); } +.loader-orbit i:first-child { transform: translate(28px, -2px); } +.loader-orbit i:nth-child(2) { transform: translate(-33px, 8px); } +.loader-orbit i:nth-child(3) { transform: translate(10px, 31px); } +@keyframes orbit { to { transform: rotate(360deg); } } + +.maplibregl-ctrl-group { overflow: hidden; border: 1px solid var(--line) !important; border-radius: 12px !important; background: var(--panel) !important; box-shadow: none !important; } +.maplibregl-ctrl-group button { width: 38px; height: 38px; } +.maplibregl-ctrl-group button + button { border-top-color: var(--line) !important; } +.maplibregl-ctrl-icon { filter: invert(92%) sepia(12%) saturate(315%); opacity: 0.75; } +.maplibregl-ctrl-bottom-right { right: 0.65rem; bottom: 4.35rem; } +.maplibregl-ctrl-attrib { background: rgba(7, 6, 4, 0.82) !important; color: var(--faint) !important; font-size: 9px !important; } +.maplibregl-ctrl-attrib a { color: var(--muted) !important; } +.maplibregl-canvas { cursor: grab; } +.maplibregl-canvas:active { cursor: grabbing; } + +@media (max-width: 760px) { + .topbar { height: 64px; padding: 0 0.85rem; } + .brand__mark { width: 34px; height: 34px; } + .brand small, .topbar__status { display: none; } + .place-search { top: 76px; width: calc(100vw - 1.4rem); height: 44px; } + .topbar__tools { position: fixed; z-index: 9; top: 128px; right: 0.7rem; margin: 0; } + .hotspot-nav { height: 40px; } + .hotspot-nav__rank { min-width: 112px !important; } + .mobile-toggle { position: fixed; z-index: 18; left: 0.7rem; bottom: max(0.7rem, env(safe-area-inset-bottom)); min-height: 48px; display: flex; border-radius: 2px; background: #120d06; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.48); } + .control-panel { z-index: 23; top: auto; left: 0.7rem; right: 0.7rem; bottom: max(0.7rem, env(safe-area-inset-bottom)); width: auto; max-height: min(72vh, 640px); padding: 1.15rem; transform: translateY(calc(100% + 1rem)); transition: transform 250ms ease; } + .control-panel.open { transform: translateY(0); } + .controls-close { align-self: flex-end; min-height: 38px; display: inline-flex; align-items: center; gap: 0.4rem; margin: -0.35rem -0.3rem 0.55rem 0; padding: 0 0.7rem; border: 1px solid var(--line); border-radius: 2px; background: transparent; color: var(--muted); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; cursor: pointer; } + .controls-close:hover { border-color: var(--line-strong); color: var(--mint-bright); } + .controls-close svg { width: 16px; stroke: currentColor; stroke-width: 1.5; } + .panel-heading h1 { font-size: 1.9rem; } + .stats { margin: 1rem 0; } + .source-note { left: 8.8rem; right: 5.5rem; justify-content: flex-start; bottom: 0.4rem; } + .source-note span { display: none; } + .map-key { right: 0.8rem; bottom: 1rem; } + .map-key span { display: none; } + .map-key i { width: 62px; } + .detail-card, .location-card { z-index: 23; top: auto; left: 0.7rem; right: 0.7rem; bottom: max(0.7rem, env(safe-area-inset-bottom)); width: auto; max-height: calc(100dvh - 190px); overflow: auto; padding: 1.25rem 1.2rem calc(1.2rem + env(safe-area-inset-bottom)); animation: sheet-in 250ms ease both; } + .detail-card::before, .location-card::before { content: ""; display: block; width: 42px; height: 3px; margin: -0.55rem auto 1rem; border-radius: 999px; background: var(--line-strong); } + .detail-card__close, .location-card__close { top: 0.65rem; right: 0.7rem; width: 44px; height: 44px; } + .detail-card h2, .location-card h2 { max-width: calc(100% - 3rem); font-size: 1.25rem; } + .detail-card dl div { min-height: 44px; align-items: center; } + .detail-card a { min-height: 44px; display: inline-flex; align-items: center; } + .pin-prompt { bottom: 0.7rem; width: calc(100vw - 1.4rem); grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.65rem; } + .pin-prompt button { grid-column: 2 / 4; width: 100%; } + .pin-prompt__countdown { grid-row: 1; grid-column: 3; } + .about-panel { bottom: 0.7rem; width: calc(100vw - 1.4rem); max-height: calc(100vh - 1.4rem); padding: 1.5rem 1.2rem; } + .about-grid { grid-template-columns: 1fr; } + .about-links { flex-direction: column; gap: 0.8rem; } + .maplibregl-ctrl-bottom-right { bottom: 4rem; } +} + +@keyframes sheet-in { + from { opacity: 0; transform: translateY(18px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (min-width: 761px) and (max-width: 1120px) { + .topbar__status { display: none; } + .place-search { width: min(360px, 38vw); } + .hotspot-nav__rank { min-width: 110px !important; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } + .scan-lines { display: none; } +}