From ab070ce57211954928dead053aac316b07883777 Mon Sep 17 00:00:00 2001 From: koH Date: Mon, 20 Jul 2026 01:02:38 -0500 Subject: [PATCH] feat(acci): redesign map interface --- accispace/HANDOFF.md | 128 ++++++ accispace/README.md | 17 + accispace/app.js | 898 +++++++++++++++++++++++++++++++++++++++++ accispace/favicon.svg | 6 + accispace/index.html | 287 +++++++++++++ accispace/netlify.toml | 20 + accispace/style.css | 743 ++++++++++++++++++++++++++++++++++ 7 files changed, 2099 insertions(+) create mode 100644 accispace/HANDOFF.md create mode 100644 accispace/README.md create mode 100644 accispace/app.js create mode 100644 accispace/favicon.svg create mode 100644 accispace/index.html create mode 100644 accispace/netlify.toml create mode 100644 accispace/style.css diff --git a/accispace/HANDOFF.md b/accispace/HANDOFF.md new file mode 100644 index 0000000..f4999c0 --- /dev/null +++ b/accispace/HANDOFF.md @@ -0,0 +1,128 @@ +# 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. +- A quiet top navigation keeps identity, Austin location search, and live-data status visible without covering the map. +- A bottom-left L-shaped dock groups filters, timeline, visual mode, terrain, reset, hotspot navigation, random targeting, and source notes. +- The timeline opens by default on the main map and collapses into its dock control on desktop and mobile. +- Year, travel mode, severity, terrain, and visual-mode controls update the map and totals. +- Visual mode cycles among combined hexagonal columns and points, points only, and columns only. +- 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, TxDOT CRIS reference, and a link to the City source dataset. +- Hovering a crash on pointer devices previews travel context, severity level, harm, and date before selection. + +## 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. +- Location search opens from the compact top-navigation search control. +- Native map controls stay above the open timeline and the dock remains reachable with one thumb. + +## 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 +- Collapsible timeline behavior on desktop and mobile +- Combined, point, and column visual modes +- Pointer hover previews with travel context and severity level +- Mobile navigation search and exclusive filter-drawer behavior + +## Additional data sources + +The current rendered layer remains the City of Austin crash-level dataset. The source panel now links the next authoritative sources worth integrating: + +- City crash-victim demographic records for person-level mode and demographic context +- City crash-to-mobility-project lookup records for before-and-after safety-project context +- TxDOT CRIS for statewide queries and public extracts + +The City person and project datasets can join to crash records by their published crash identifiers. TxDOT CRIS is the upstream statewide system, but its public extract and query workflows should be treated as a scheduled ingestion source rather than a browser-time dependency. + +## 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. +- A future data pass could join person-level travel context and nearby mobility projects after privacy, completeness, and identifier coverage are profiled. + +## 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..5ef9e61 --- /dev/null +++ b/accispace/app.js @@ -0,0 +1,898 @@ +const DATASET_ID = "y2wy-tgr5"; +const DATASET_URL = `https://data.austintexas.gov/resource/${DATASET_ID}.geojson`; +const SOURCE_PAGE = "https://data.austintexas.gov/Transportation-and-Mobility/Austin-Crash-Report-Data-Crash-Level-Records/y2wy-tgr5/about_data"; +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, + visualization: "combined", + 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"), + timelinePanel: document.querySelector("#timeline-panel"), + timelineToggle: document.querySelector("#timeline-toggle"), + crashTooltip: document.querySelector("#crash-tooltip"), + navSearchToggle: document.querySelector("#nav-search-toggle"), +}; + +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; +document.querySelector("#timeline-first").textContent = 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("mousemove", "crash-hit-targets", showCrashTooltip); + map.on("mouseleave", "crash-hit-targets", () => { + map.getCanvas().style.cursor = "grab"; + elements.crashTooltip.hidden = true; + }); + map.on("click", "crash-hit-targets", (event) => { + elements.crashTooltip.hidden = true; + 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", "cris_crash_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"; + const travelType = crashTravelType(p); + const units = number(p.units_involved); + document.querySelector("#detail-mode").textContent = units + ? `${travelType}, ${units} ${units === 1 ? "unit" : "units"}` + : travelType; + document.querySelector("#detail-reference").textContent = p.cris_crash_id ? `TxDOT CRIS ${p.cris_crash_id}` : `City record ${p.id}`; + document.querySelector("#detail-source").href = SOURCE_PAGE; + elements.locationCard.hidden = true; + elements.detailCard.hidden = false; +} + +function crashTravelType(properties = {}) { + if (number(properties.pedestrian_death_count) + number(properties.pedestrian_serious_injury_count) > 0) return "Pedestrian involved"; + if (number(properties.bicycle_death_count) + number(properties.bicycle_serious_injury_count) > 0) return "Bicycle involved"; + if (number(properties.motorcycle_death_count) + number(properties.motorcycle_serious_injury_count) > 0) return "Motorcycle involved"; + if (number(properties.micromobility_death_count) + number(properties.micromobility_serious_injury_count) > 0) return "Micromobility involved"; + return "Motor vehicle crash"; +} + +function showCrashTooltip(event) { + const feature = event.features?.[0]; + if (!feature || window.matchMedia("(hover: none)").matches) return; + const p = feature.properties; + const deaths = number(p.death_cnt); + const injuries = number(p.sus_serious_injry_cnt); + const date = new Date(p.crash_timestamp_ct); + document.querySelector("#tooltip-mode").textContent = crashTravelType(p); + document.querySelector("#tooltip-severity").textContent = deaths ? "Level 4 · fatal" : "Level 1 · serious"; + document.querySelector("#tooltip-severity").style.color = deaths ? "var(--coral)" : "var(--amber)"; + document.querySelector("#tooltip-harm").textContent = [ + deaths ? `${deaths} ${deaths === 1 ? "life" : "lives"} lost` : "", + injuries ? `${injuries} serious ${injuries === 1 ? "injury" : "injuries"}` : "", + ].filter(Boolean).join(", ") || "Harm count not specified"; + document.querySelector("#tooltip-date").textContent = date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + const tooltipWidth = 220; + const left = Math.min(window.innerWidth - tooltipWidth - 12, event.point.x + 16); + const top = Math.max(76, Math.min(window.innerHeight - 118, event.point.y - 42)); + elements.crashTooltip.style.left = `${left}px`; + elements.crashTooltip.style.top = `${top}px`; + elements.crashTooltip.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.querySelector("span").textContent = state.terrain ? "Terrain" : "Terrain off"; + event.currentTarget.dataset.label = state.terrain ? "Turn terrain off" : "Turn terrain on"; +}); + +document.querySelector("#toggle-layer").addEventListener("click", (event) => { + const modes = ["combined", "points", "columns"]; + state.visualization = modes[(modes.indexOf(state.visualization) + 1) % modes.length]; + const showPoints = state.visualization !== "columns"; + const showColumns = state.visualization !== "points"; + ["crash-halo", "crash-points"].forEach((layer) => map.setLayoutProperty(layer, "visibility", showPoints ? "visible" : "none")); + ["harm-cell-glow", "harm-cells"].forEach((layer) => map.setLayoutProperty(layer, "visibility", showColumns ? "visible" : "none")); + const labels = { combined: "Combined", points: "Points", columns: "Columns" }; + document.querySelector("#layer-label").textContent = labels[state.visualization]; + event.currentTarget.dataset.label = `Map visual: ${labels[state.visualization]}`; +}); + +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.classList.toggle("active", open); +} + +elements.mobileToggle.addEventListener("click", () => setExploreOpen(!elements.controls.classList.contains("open"))); +document.querySelector("#controls-close").addEventListener("click", () => setExploreOpen(false)); + +function setTimelineOpen(open) { + elements.timelinePanel.classList.toggle("collapsed", !open); + elements.timelineToggle.setAttribute("aria-expanded", String(open)); + elements.timelineToggle.classList.toggle("active", open); + elements.timelineToggle.dataset.label = open ? "Hide timeline" : "Show timeline"; +} + +elements.timelineToggle.addEventListener("click", () => setTimelineOpen(elements.timelinePanel.classList.contains("collapsed"))); +document.querySelector("#timeline-collapse").addEventListener("click", () => setTimelineOpen(false)); +setTimelineOpen(true); + +function setSearchOpen(open) { + elements.placeSearch.classList.toggle("search-open", open); + elements.navSearchToggle.setAttribute("aria-expanded", String(open)); + elements.navSearchToggle.setAttribute("aria-label", open ? "Close location search" : "Open location search"); + if (open) { + setExploreOpen(false); + elements.placeQuery.focus(); + } else { + closeSearchResults(); + } +} + +elements.navSearchToggle.addEventListener("click", () => setSearchOpen(!elements.placeSearch.classList.contains("search-open"))); + +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(); + elements.placeSearch.classList.remove("search-open"); + elements.navSearchToggle.setAttribute("aria-expanded", "false"); + 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); + setSearchOpen(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..2839d31 --- /dev/null +++ b/accispace/index.html @@ -0,0 +1,287 @@ + + + + + + + + + + + ACCI | Austin Crash Context Interface + + + + + + + + + + +
+
+ + + +
+ + + + ACCI + Austin Crash Context Interface + + + + + +
+ + Connecting to Austin open data +
+ + +
+ + + +
+
+ Crash history + 2022 to present +
+
+ + +
+ +
+ + + + + + + + + + + + + +
City of Austin Vision Zero 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..1c74c14 --- /dev/null +++ b/accispace/style.css @@ -0,0 +1,743 @@ +: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; } +} + +/* Map workspace: quiet top navigation, L-shaped tool dock, and persistent timeline. */ +.topbar { + height: 68px; + padding: 0 1.15rem; + border-color: rgba(255, 194, 82, 0.14); + background: rgba(9, 7, 4, 0.88); +} + +.brand__mark { width: 36px; height: 36px; } +.topbar__status { margin-left: auto; max-width: 210px; } +.nav-search-toggle { display: none; } + +.place-search { + top: 11px; + width: min(440px, 38vw); + height: 46px; + border-radius: 14px; + background: rgba(18, 13, 6, 0.9); +} + +.place-search > button, +.search-results, +.location-card__actions button, +.pin-prompt button { border-radius: 9px; } + +.map-dock { + position: fixed; + z-index: 18; + left: 1rem; + bottom: 1rem; + display: flex; + align-items: flex-end; + pointer-events: none; +} + +.map-dock__vertical, +.map-dock__horizontal { + display: flex; + pointer-events: auto; + border: 1px solid var(--line-strong); + background: rgba(13, 10, 6, 0.96); + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.5); +} + +.map-dock__vertical { + flex-direction: column; + order: 1; + border-radius: 14px 14px 0 14px; +} + +.map-dock__horizontal { + order: 2; + margin-left: -1px; + border-radius: 0 14px 14px 0; +} + +.dock-button { + position: relative; + min-width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0 0.75rem; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; +} + +.map-dock__vertical .dock-button + .dock-button { border-top: 1px solid var(--line); } +.map-dock__horizontal .dock-button + .dock-button { border-left: 1px solid var(--line); } +.dock-button:first-child { border-radius: inherit; } +.dock-button:hover:not(:disabled), +.dock-button.active, +.dock-button[aria-pressed="true"] { background: rgba(255, 176, 0, 0.1); color: var(--mint-bright); } +.dock-button:disabled { cursor: wait; opacity: 0.34; } +.dock-button svg { width: 19px; height: 19px; flex: 0 0 auto; stroke: currentColor; stroke-width: 1.5; } +.map-dock__vertical .dock-button span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } +.map-dock__horizontal .dock-button span { font: 0.56rem/1 var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; } +.dock-button--primary { color: var(--mint-bright); } +.dock-button--wide { min-width: 122px; } + +@media (hover: hover) { + .dock-button::after { + content: attr(data-label); + position: absolute; + left: 50%; + bottom: calc(100% + 9px); + width: max-content; + max-width: 180px; + padding: 0.45rem 0.6rem; + border: 1px solid var(--line); + border-radius: 7px; + background: #120d06; + color: var(--text); + font: 0.58rem/1.3 var(--font-mono); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 5px); + transition: opacity 150ms ease, transform 150ms ease; + } + + .map-dock__vertical .dock-button::after { + left: calc(100% + 9px); + bottom: 50%; + transform: translate(5px, 50%); + } + + .dock-button:hover::after, + .dock-button:focus-visible::after { opacity: 1; transform: translate(-50%, 0); } + .map-dock__vertical .dock-button:hover::after, + .map-dock__vertical .dock-button:focus-visible::after { transform: translate(0, 50%); } +} + +.timeline-panel { + position: fixed; + z-index: 16; + left: 540px; + bottom: 1rem; + width: min(520px, calc(100vw - 900px)); + min-width: 330px; + min-height: 74px; + display: grid; + grid-template-columns: 118px minmax(180px, 1fr) 42px; + align-items: center; + gap: 1rem; + padding: 0.8rem 0.7rem 0.7rem 1rem; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: rgba(13, 10, 6, 0.96); + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.5); + transition: opacity 180ms ease, transform 180ms ease, visibility 180ms ease; +} + +.timeline-panel.collapsed { opacity: 0; visibility: hidden; transform: translateY(12px); } +.timeline-panel__heading .eyebrow { margin-bottom: 0.35rem; font-size: 0.54rem; } +.timeline-panel__heading output { color: var(--text); font: 600 0.66rem/1.3 var(--font-mono); } +.timeline-panel__track input { display: block; width: 100%; } +.timeline-panel__ticks { display: flex; justify-content: space-between; margin-top: 0.1rem; color: var(--faint); font: 0.5rem/1 var(--font-mono); } +.timeline-panel__collapse { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--muted); cursor: pointer; } +.timeline-panel__collapse:hover { border-color: var(--line-strong); color: var(--mint-bright); } +.timeline-panel__collapse svg { width: 18px; stroke: currentColor; stroke-width: 1.5; } + +.control-panel { + top: auto; + left: 5.4rem; + bottom: 5.5rem; + width: min(360px, calc(100vw - 7rem)); + max-height: calc(100dvh - 10.5rem); + padding: 1.25rem; + border-radius: 14px; + opacity: 0; + visibility: hidden; + transform: translateY(12px); + transition: opacity 180ms ease, transform 180ms ease, visibility 180ms ease; +} + +.control-panel.open { opacity: 1; visibility: visible; transform: translateY(0); } +.controls-close { min-height: 38px; display: inline-flex; align-items: center; align-self: flex-end; gap: 0.35rem; margin: -0.35rem -0.25rem 0.35rem 0; padding: 0 0.6rem; border: 1px solid var(--line); border-radius: 9px; background: transparent; color: var(--muted); font-size: 0.58rem; text-transform: uppercase; cursor: pointer; } +.controls-close svg { width: 16px; stroke: currentColor; stroke-width: 1.5; } +.panel-heading h1 { font-size: clamp(1.65rem, 2.4vw, 2.2rem); } +.stats { margin: 1rem 0 1.15rem; } +.interaction-hint { margin-top: 0.25rem; } + +.crash-tooltip { + position: fixed; + z-index: 25; + width: 220px; + padding: 0.75rem 0.8rem; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: rgba(13, 10, 6, 0.97); + box-shadow: 0 14px 42px rgba(0, 0, 0, 0.55); + pointer-events: none; +} +.crash-tooltip[hidden] { display: none; } +.crash-tooltip__topline { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; } +.crash-tooltip__topline span, +.crash-tooltip > span { color: var(--faint); font: 0.52rem/1.3 var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; } +.crash-tooltip__topline strong { color: var(--amber); font: 600 0.54rem/1.3 var(--font-mono); text-transform: uppercase; } +.crash-tooltip p { margin: 0.45rem 0 0.3rem; color: var(--text); font-size: 0.72rem; font-weight: 600; } + +.source-note { + left: auto; + right: 1rem; + bottom: 0.5rem; + min-height: 20px; + pointer-events: none; +} +.about-links--sources { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } + +.detail-card, +.location-card { top: 88px; border-radius: 14px; } +.about-panel { border-radius: 16px; } +.map-key { bottom: 3.1rem; } +.maplibregl-ctrl-bottom-right { bottom: 4.25rem; } + +@media (max-width: 1120px) and (min-width: 761px) { + .timeline-panel { left: 313px; width: min(430px, calc(100vw - 660px)); min-width: 300px; grid-template-columns: 105px minmax(150px, 1fr) 38px; gap: 0.7rem; } + .map-dock__horizontal .dock-button { padding: 0 0.55rem; } + .map-dock__horizontal .dock-button > span:not(#hotspot-position) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } + .dock-button--wide { min-width: 106px; } +} + +@media (max-width: 760px) { + .topbar { height: 60px; padding: 0 0.7rem; gap: 0.65rem; } + .brand { gap: 0.55rem; } + .brand__mark { width: 32px; height: 32px; } + .brand strong { font-size: 0.9rem; } + .brand small { display: none; } + .topbar__status { min-width: 0; margin-left: auto; } + .topbar__status { display: flex; } + .topbar__status #data-status { max-width: 115px; overflow: hidden; font-size: 0.54rem; text-overflow: ellipsis; white-space: nowrap; } + .signal { width: 6px; height: 6px; } + .nav-search-toggle { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid var(--line); border-radius: 11px; background: rgba(18, 13, 6, 0.88); color: var(--muted); } + .nav-search-toggle svg { width: 18px; stroke: currentColor; stroke-width: 1.5; } + .place-search { top: 68px; left: 0.7rem; width: calc(100vw - 1.4rem); opacity: 0; visibility: hidden; transform: translateY(-8px); transition: opacity 150ms ease, transform 150ms ease, visibility 150ms ease; } + .place-search.search-open { opacity: 1; visibility: visible; transform: translateY(0); } + + .map-dock { left: 0.65rem; bottom: max(0.65rem, env(safe-area-inset-bottom)); } + .dock-button { min-width: 44px; width: 44px; height: 44px; padding: 0; } + .dock-button--wide { width: 92px; min-width: 92px; } + .map-dock__horizontal .dock-button > span:not(#hotspot-position) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); } + .map-dock__horizontal .dock-button:nth-child(2) { display: none; } + .map-dock__vertical { border-radius: 12px 12px 0 12px; } + .map-dock__horizontal { border-radius: 0 12px 12px 0; } + .map-dock__vertical .dock-button:nth-child(3), + .map-dock__vertical .dock-button:nth-child(4) { display: none; } + + .timeline-panel { + left: 4.35rem; + right: 0.65rem; + bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 45px); + width: auto; + min-width: 0; + min-height: 70px; + grid-template-columns: 86px minmax(100px, 1fr) 38px; + gap: 0.6rem; + padding: 0.65rem; + border-radius: 12px; + } + .timeline-panel__heading output { font-size: 0.57rem; } + + .control-panel { + top: auto; + left: 0.65rem; + right: 0.65rem; + bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 45px); + width: auto; + max-height: calc(100dvh - 8rem); + padding: 1.15rem; + border-radius: 14px; + opacity: 0; + visibility: hidden; + transform: translateY(18px); + } + .control-panel.open { opacity: 1; visibility: visible; transform: translateY(0); } + + .detail-card, + .location-card { bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 45px); border-radius: 14px; } + .pin-prompt { bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 45px); } + .about-panel { bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 45px); max-height: calc(100dvh - 4.5rem); } + .about-links--sources { grid-template-columns: 1fr; } + .source-note { display: none; } + .map-key { right: 0.75rem; bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 55px); } + .maplibregl-ctrl-bottom-right { bottom: calc(max(0.65rem, env(safe-area-inset-bottom)) + 118px); } + .crash-tooltip { display: none !important; } +} + +@media (max-width: 390px) { + .topbar__status #data-status { max-width: 82px; } + .dock-button--wide { width: 78px; min-width: 78px; } + .map-dock__horizontal .dock-button:last-child { display: none; } + .timeline-panel { left: 4.1rem; grid-template-columns: 72px minmax(82px, 1fr) 34px; gap: 0.4rem; } + .timeline-panel__heading .eyebrow { letter-spacing: 0.07em; } +}