diff --git a/web/src/app/components/ControlsPanel.tsx b/web/src/app/components/ControlsPanel.tsx new file mode 100644 index 0000000..1ee99f1 --- /dev/null +++ b/web/src/app/components/ControlsPanel.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React from 'react'; + +interface ControlsPanelProps { + panelOpen: boolean; + onTogglePanel: (next: boolean) => void; + mapStyleChoice: 'dark' | 'streets'; + onChangeStyle: (v: 'dark' | 'streets') => void; + heatVisible: boolean; + onToggleHeat: (v: boolean) => void; + pointsVisible: boolean; + onTogglePoints: (v: boolean) => void; + heatRadius: number; + onChangeRadius: (v: number) => void; + heatIntensity: number; + onChangeIntensity: (v: number) => void; +} + +export default function ControlsPanel({ panelOpen, onTogglePanel, mapStyleChoice, onChangeStyle, heatVisible, onToggleHeat, pointsVisible, onTogglePoints, heatRadius, onChangeRadius, heatIntensity, onChangeIntensity }: ControlsPanelProps) { + return ( +
+
+
Map Controls
+ +
+ + {panelOpen && ( + <> +
+ + +
+
+ + onToggleHeat(e.target.checked)} /> +
+ +
+ + onTogglePoints(e.target.checked)} /> +
+ +
+ + onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} /> +
+ +
+ + onChangeIntensity(Number(e.target.value))} style={{ width: '100%' }} /> +
+ +
Tip: switching style will reapply layers.
+ + )} +
+ ); +} diff --git a/web/src/app/components/Legend.tsx b/web/src/app/components/Legend.tsx new file mode 100644 index 0000000..47b0b4a --- /dev/null +++ b/web/src/app/components/Legend.tsx @@ -0,0 +1,27 @@ +"use client"; + +import React from 'react'; + +export default function Legend() { + return ( +
+
+
Density legend
+
+
+
+
+
+
+
+
+
+
+ Low + High +
+
+
+
+ ); +} diff --git a/web/src/app/components/MapView.tsx b/web/src/app/components/MapView.tsx new file mode 100644 index 0000000..f65e455 --- /dev/null +++ b/web/src/app/components/MapView.tsx @@ -0,0 +1,185 @@ +"use client"; + +import React, { useEffect, useRef, useState } from 'react'; +import mapboxgl from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { generateDCPoints, haversine, PointFeature } from '../lib/mapUtils'; + +export type PopupData = { lngLat: [number, number]; mag?: number; text?: string; stats?: { count: number; avg?: number; min?: number; max?: number; radiusMeters?: number } } | null; + +interface MapViewProps { + mapStyleChoice: 'dark' | 'streets'; + heatRadius: number; + heatIntensity: number; + heatVisible: boolean; + pointsVisible: boolean; + onMapReady?: (map: mapboxgl.Map) => void; + onPopupCreate?: (p: PopupData) => void; // fires when user clicks features and we want to show popup +} + +export default function MapView({ mapStyleChoice, heatRadius, heatIntensity, heatVisible, pointsVisible, onMapReady, onPopupCreate }: MapViewProps) { + const containerRef = useRef(null); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + const dcDataRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + setSize({ width: el.clientWidth, height: el.clientHeight }); + + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const cr = entry.contentRect; + setSize({ width: Math.round(cr.width), height: Math.round(cr.height) }); + } + }); + + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const mapEl = mapContainerRef.current; + if (!mapEl) return; + + mapboxgl.accessToken = 'pk.eyJ1IjoicGllbG9yZDc1NyIsImEiOiJjbWcxdTd6c3AwMXU1MmtxMDh6b2l5amVrIn0.5Es0azrah23GX1e9tmbjGw'; + + const styleUrl = mapStyleChoice === 'dark' + ? 'mapbox://styles/mapbox/dark-v10' + : 'mapbox://styles/mapbox/streets-v11'; + + mapRef.current = new mapboxgl.Map({ container: mapEl, style: styleUrl, center: [-77.0369, 38.9072], zoom: 11 }); + const map = mapRef.current; + + if (!dcDataRef.current) dcDataRef.current = generateDCPoints(900); + + const computeNearbyStats = (center: [number, number], radiusMeters = 500) => { + const data = dcDataRef.current; + if (!data) return { count: 0 }; + const mags: number[] = []; + for (const f of data.features as PointFeature[]) { + const coord = f.geometry.coordinates as [number, number]; + const d = haversine(center, coord); + if (d <= radiusMeters) mags.push(f.properties.mag); + } + if (mags.length === 0) return { count: 0 }; + const sum = mags.reduce((s, x) => s + x, 0); + return { count: mags.length, avg: +(sum / mags.length).toFixed(2), min: Math.min(...mags), max: Math.max(...mags), radiusMeters }; + }; + + const addDataAndLayers = () => { + if (!map || !dcDataRef.current) return; + + if (!map.getSource('dc-quakes')) { + map.addSource('dc-quakes', { type: 'geojson', data: dcDataRef.current }); + } else { + (map.getSource('dc-quakes') as mapboxgl.GeoJSONSource).setData(dcDataRef.current); + } + + if (!map.getLayer('dc-heat')) { + map.addLayer({ + id: 'dc-heat', type: 'heatmap', source: 'dc-quakes', maxzoom: 15, + paint: { + 'heatmap-weight': ['interpolate', ['linear'], ['get', 'mag'], 0, 0, 6, 1], + 'heatmap-intensity': heatIntensity, + 'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0,120,48,0)', 0.2, 'rgba(34,139,34,0.8)', 0.4, 'rgba(154,205,50,0.9)', 0.6, 'rgba(255,215,0,0.95)', 0.8, 'rgba(255,140,0,0.95)', 1, 'rgba(215,25,28,1)'], + 'heatmap-radius': heatRadius, + 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 12, 0.8] + } + }); + } + + if (!map.getLayer('dc-point')) { + map.addLayer({ + id: 'dc-point', type: 'circle', source: 'dc-quakes', minzoom: 12, + paint: { + 'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 2, 6, 8], + 'circle-color': '#ffffff', + 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0, 14, 1] + } + }); + } + + if (map.getLayer('dc-heat')) { + map.setLayoutProperty('dc-heat', 'visibility', heatVisible ? 'visible' : 'none'); + map.setPaintProperty('dc-heat', 'heatmap-radius', heatRadius); + map.setPaintProperty('dc-heat', 'heatmap-intensity', heatIntensity); + } + if (map.getLayer('dc-point')) { + map.setLayoutProperty('dc-point', 'visibility', pointsVisible ? 'visible' : 'none'); + } + }; + + map.on('load', () => { + addDataAndLayers(); + + map.on('click', 'dc-point', (e) => { + const feature = e.features && e.features[0]; + if (!feature) return; + const coords = (feature.geometry as any).coordinates.slice() as [number, number]; + const mag = feature.properties ? feature.properties.mag : undefined; + const stats = computeNearbyStats(coords, 500); + if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, text: `Magnitude: ${mag ?? 'N/A'}`, stats }); + }); + + map.on('click', 'dc-heat', (e) => { + const p = e.point; + const bbox = [[p.x - 6, p.y - 6], [p.x + 6, p.y + 6]] as [mapboxgl.PointLike, mapboxgl.PointLike]; + const nearby = map.queryRenderedFeatures(bbox, { layers: ['dc-point'] }); + if (nearby && nearby.length > 0) { + const f = nearby[0]; + const coords = (f.geometry as any).coordinates.slice() as [number, number]; + const mag = f.properties ? f.properties.mag : undefined; + const stats = computeNearbyStats(coords, 500); + if (onPopupCreate) onPopupCreate({ lngLat: coords, mag, text: `Magnitude: ${mag ?? 'N/A'}`, stats }); + } else { + const stats = computeNearbyStats([e.lngLat.lng, e.lngLat.lat], 500); + if (onPopupCreate) onPopupCreate({ lngLat: [e.lngLat.lng, e.lngLat.lat], text: 'Zoom in to see individual points and details', stats }); + } + }); + + map.on('mouseenter', 'dc-point', () => map.getCanvas().style.cursor = 'pointer'); + map.on('mouseleave', 'dc-point', () => map.getCanvas().style.cursor = ''); + map.on('mouseenter', 'dc-heat', () => map.getCanvas().style.cursor = 'pointer'); + map.on('mouseleave', 'dc-heat', () => map.getCanvas().style.cursor = ''); + }); + + map.on('styledata', () => { + if (!map.getSource('dc-quakes')) { + addDataAndLayers(); + } + }); + + if (onMapReady) onMapReady(map); + + return () => { + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, []); + + // update visibility & paint when props change + useEffect(() => { + const map = mapRef.current; + if (!map) return; + if (map.getLayer && map.getLayer('dc-heat')) { + map.setLayoutProperty('dc-heat', 'visibility', heatVisible ? 'visible' : 'none'); + map.setPaintProperty('dc-heat', 'heatmap-radius', heatRadius); + map.setPaintProperty('dc-heat', 'heatmap-intensity', heatIntensity); + } + if (map.getLayer && map.getLayer('dc-point')) { + map.setLayoutProperty('dc-point', 'visibility', pointsVisible ? 'visible' : 'none'); + } + }, [heatRadius, heatIntensity, heatVisible, pointsVisible]); + + return ( +
+
+
+ ); +} diff --git a/web/src/app/components/PopupOverlay.tsx b/web/src/app/components/PopupOverlay.tsx new file mode 100644 index 0000000..32142f4 --- /dev/null +++ b/web/src/app/components/PopupOverlay.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from 'react'; +import mapboxgl from 'mapbox-gl'; +import type { PopupData } from './MapView'; + +interface Props { + popup: PopupData; + popupVisible: boolean; + mapRef: React.MutableRefObject; + onClose: () => void; +} + +export default function PopupOverlay({ popup, popupVisible, mapRef, onClose }: Props) { + if (!popup) return null; + const map = mapRef.current; + if (!map) return null; + + const p = map.project(popup.lngLat as any); + + return ( +
+
+
+
{popup.text ?? 'Details'}
+ +
+ {typeof popup.mag !== 'undefined' &&
Magnitude: {popup.mag}
} + {popup.stats && popup.stats.count > 0 && ( +
+
Nearby points: {popup.stats.count} (within {popup.stats.radiusMeters}m)
+
Avg: {popup.stats.avg}   Min: {popup.stats.min}   Max: {popup.stats.max}
+
+ )} +
+
+ ); +} diff --git a/web/src/app/components/ZoomControls.tsx b/web/src/app/components/ZoomControls.tsx new file mode 100644 index 0000000..de9e5df --- /dev/null +++ b/web/src/app/components/ZoomControls.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React from 'react'; +import mapboxgl from 'mapbox-gl'; + +interface Props { mapRef: React.MutableRefObject } + +export default function ZoomControls({ mapRef }: Props) { + return ( +
+ + +
+ ); +} diff --git a/web/src/app/components/mapUtils.ts b/web/src/app/components/mapUtils.ts new file mode 100644 index 0000000..b3c1a7b --- /dev/null +++ b/web/src/app/components/mapUtils.ts @@ -0,0 +1,42 @@ +export type PointFeature = GeoJSON.Feature; + +export const haversine = (a: [number, number], b: [number, number]) => { + const toRad = (v: number) => v * Math.PI / 180; + const R = 6371000; // meters + const dLat = toRad(b[1] - a[1]); + const dLon = toRad(b[0] - a[0]); + const lat1 = toRad(a[1]); + const lat2 = toRad(b[1]); + const sinDLat = Math.sin(dLat/2); + const sinDLon = Math.sin(dLon/2); + const aH = sinDLat*sinDLat + sinDLon*sinDLon * Math.cos(lat1)*Math.cos(lat2); + const c = 2 * Math.atan2(Math.sqrt(aH), Math.sqrt(1-aH)); + return R * c; +}; + +export const generateDCPoints = (count = 500) => { + const center = { lon: -77.0369, lat: 38.9072 }; + const features: PointFeature[] = []; + + const randNormal = () => { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + }; + + for (let i = 0; i < count; i++) { + const radius = Math.abs(randNormal()) * 0.02; + const angle = Math.random() * Math.PI * 2; + const lon = center.lon + Math.cos(angle) * radius; + const lat = center.lat + Math.sin(angle) * radius; + const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { mag } + }); + } + + return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; +}; diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 7d6d073..39534fe 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,7 +2,7 @@ @import '@skeletonlabs/skeleton'; @import '@skeletonlabs/skeleton/optional/presets'; -@import '@skeletonlabs/skeleton/themes/rose'; +@import '@skeletonlabs/skeleton/themes/cerberus'; @source '../../node_modules/@skeletonlabs/skeleton-react/dist'; @@ -30,3 +30,164 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* Map control theming that follows Skeleton/Tailwind color variables */ +.map-control { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + background: rgba(255,255,255,0.04); + color: var(--foreground); + padding: 12px; + border-radius: 10px; + font-size: 13px; + width: 240px; + backdrop-filter: blur(8px); + border: 1px solid rgba(0,0,0,0.06); + box-shadow: 0 6px 18px rgba(0,0,0,0.35); +} + +.map-select { + background: transparent; + color: var(--foreground); + border: 1px solid rgba(0,0,0,0.08); + padding: 4px 6px; + border-radius: 4px; +} + +/* control panel helper classes */ +.mc-title { + margin-bottom: 8px; + font-weight: 700; + font-size: 14px; +} + +.mc-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.mc-label { + flex: 1; + font-size: 13px; + color: var(--foreground); +} + +.mc-range { + width: 100%; + -webkit-appearance: none; + appearance: none; + height: 6px; + background: rgba(0,0,0,0.12); + border-radius: 6px; +} +.mc-range::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--foreground); + box-shadow: 0 1px 3px rgba(0,0,0,0.3); +} + +/* Option styling: native dropdowns sometimes ignore inherited styles; try to explicitly set colors */ +.map-select option { + color: var(--foreground) !important; + background: var(--background) !important; +} + +/* WebKit / Blink specific appearance tweaks for better contrast */ +.map-select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.zoom-btn { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--background); + color: var(--foreground); + border: 1px solid rgba(0,0,0,0.06); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; +} + +@media (prefers-color-scheme: dark) { + .map-control { background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.06); box-shadow: 0 8px 24px rgba(0,0,0,0.6); } + .map-select { border: 1px solid rgba(255,255,255,0.08); } + .zoom-btn { background: rgba(255,255,255,0.06); color: var(--foreground); border: 1px solid rgba(255,255,255,0.06); } +} + +/* enhance label and control spacing */ +.map-control .mc-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; } +.map-control .mc-label { flex: 1; font-size: 13px; color: var(--foreground); } +.map-control .mc-title { margin-bottom: 8px; font-weight: 700; color: var(--foreground); } + +/* style sliders */ +.map-control input[type="range"] { appearance: none; height: 6px; background: rgba(0,0,0,0.12); border-radius: 6px; } +.map-control input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.4); cursor: pointer; } +.map-control input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; cursor: pointer; } + +/* compact checkbox */ +.map-control input[type="checkbox"] { width: 16px; height: 16px; } + +.zoom-btn { + width: 40px; + height: 40px; + border-radius: 8px; + background: var(--background); + color: var(--foreground); + border: 1px solid rgba(0,0,0,0.06); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 18px; + line-height: 1; +} +.zoom-btn:hover { transform: translateY(-1px); opacity: 0.95; } + +/* Mapbox popup theming to match site theme */ +.mapboxgl-popup { + --popup-bg: var(--background); + --popup-fg: var(--foreground); +} +.mapboxgl-popup-content { + background: var(--popup-bg) !important; + color: var(--popup-fg) !important; + border-radius: 8px !important; + padding: 8px !important; + box-shadow: 0 8px 24px rgba(0,0,0,0.35) !important; + border: 1px solid rgba(0,0,0,0.08) !important; + font-size: 13px; +} +.mapboxgl-popup-tip { + filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2)); +} +.mapboxgl-popup-close-button { + color: var(--popup-fg) !important; + opacity: 0.9; +} +.mapboxgl-popup a { color: inherit; } + +/* Custom React popup styles */ +.custom-popup { + transition: opacity 180ms ease, transform 180ms cubic-bezier(.2,.8,.2,1); + opacity: 0; + transform: translateY(-6px) scale(0.98); + z-index: 50; +} +.custom-popup.visible { opacity: 1; transform: translateY(-10px) scale(1); } +.mapbox-popup-inner button[aria-label="Close popup"] { border-radius: 6px; padding: 6px; background: rgba(0,0,0,0.04); } +.mapbox-popup-inner button[aria-label="Close popup"]:focus { outline: 2px solid rgba(0,0,0,0.12); } +.mapbox-popup-inner button[aria-label="Close popup"] { cursor: pointer; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 52f0bc7..f476bd9 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -23,7 +23,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/web/src/app/lib/mapUtils.ts b/web/src/app/lib/mapUtils.ts new file mode 100644 index 0000000..ef073e2 --- /dev/null +++ b/web/src/app/lib/mapUtils.ts @@ -0,0 +1,38 @@ +export type PointFeature = GeoJSON.Feature; + +export const haversine = (a: [number, number], b: [number, number]) => { + const toRad = (v: number) => v * Math.PI / 180; + const R = 6371000; // meters + const dLat = toRad(b[1] - a[1]); + const dLon = toRad(b[0] - a[0]); + const lat1 = toRad(a[1]); + const lat2 = toRad(b[1]); + const sinDLat = Math.sin(dLat/2); + const sinDLon = Math.sin(dLon/2); + const aH = sinDLat*sinDLat + sinDLon*sinDLon * Math.cos(lat1)*Math.cos(lat2); + const c = 2 * Math.atan2(Math.sqrt(aH), Math.sqrt(1-aH)); + return R * c; +}; + +export const generateDCPoints = (count = 500) => { + const center = { lon: -77.0369, lat: 38.9072 }; + const features: PointFeature[] = []; + + const randNormal = () => { + let u = 0, v = 0; + while (u === 0) u = Math.random(); + while (v === 0) v = Math.random(); + return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); + }; + + for (let i = 0; i < count; i++) { + const radius = Math.abs(randNormal()) * 0.02; + const angle = Math.random() * Math.PI * 2; + const lon = center.lon + Math.cos(angle) * radius; + const lat = center.lat + Math.sin(angle) * radius; + const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); + features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lon, lat] }, properties: { mag } }); + } + + return { type: 'FeatureCollection', features } as GeoJSON.FeatureCollection; +}; diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 27638f7..e8f752e 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,209 +1,55 @@ "use client"; -import React, { useEffect, useRef, useState } from 'react'; -import mapboxgl from 'mapbox-gl'; - -import 'mapbox-gl/dist/mapbox-gl.css'; +import React, { useRef, useState } from 'react'; +import MapView, { PopupData } from './components/MapView'; +import ControlsPanel from './components/ControlsPanel'; +import PopupOverlay from './components/PopupOverlay'; +import Legend from './components/Legend'; +import ZoomControls from './components/ZoomControls'; export default function Home() { - const containerRef = useRef(null); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const [size, setSize] = useState({ width: 0, height: 0 }); - - // Generate sample clustered points around Washington, DC - const generateDCPoints = (count = 500) => { - const center = { lon: -77.0369, lat: 38.9072 }; - const features: GeoJSON.Feature[] = []; - - // simple clustered distribution using gaussian-like offsets - const randNormal = () => { - // Box-Muller transform - let u = 0, v = 0; - while (u === 0) u = Math.random(); - while (v === 0) v = Math.random(); - return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); - }; - - for (let i = 0; i < count; i++) { - // cluster radius in degrees (small) - const radius = Math.abs(randNormal()) * 0.02; // ~ up to ~2km-ish - const angle = Math.random() * Math.PI * 2; - const lon = center.lon + Math.cos(angle) * radius; - const lat = center.lat + Math.sin(angle) * radius; - // give each point a magnitude/weight to simulate intensity - const mag = Math.round(Math.max(1, Math.abs(randNormal()) * 6)); - features.push({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [lon, lat] }, - properties: { mag } - }); - } - - return { - type: 'FeatureCollection', - features - } as GeoJSON.FeatureCollection; - }; - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - - setSize({ width: el.clientWidth, height: el.clientHeight }); - - const ro = new ResizeObserver((entries) => { - for (const entry of entries) { - const cr = entry.contentRect; - setSize({ width: Math.round(cr.width), height: Math.round(cr.height) }); - } - }); - - ro.observe(el); - - return () => ro.disconnect(); - }, []); - - useEffect(() => { - const mapEl = mapContainerRef.current; - if (!mapEl) return; - - // set your token (keeps the existing token already in the file) - mapboxgl.accessToken = 'pk.eyJ1IjoicGllbG9yZDc1NyIsImEiOiJjbWcxdTd6c3AwMXU1MmtxMDh6b2l5amVrIn0.5Es0azrah23GX1e9tmbjGw'; - - // create the map - mapRef.current = new mapboxgl.Map({ - container: mapEl, - style: 'mapbox://styles/mapbox/dark-v10', - center: [-77.0369, 38.9072], // Washington, DC - zoom: 11 - }); - - const map = mapRef.current; - - map.on('load', () => { - // add sample DC data - const dcData = generateDCPoints(900); - - map.addSource('dc-quakes', { - type: 'geojson', - data: dcData - }); - - // heatmap layer: white at low density, orange/red at high density - map.addLayer({ - id: 'dc-heat', - type: 'heatmap', - source: 'dc-quakes', - maxzoom: 15, - paint: { - 'heatmap-weight': [ - 'interpolate', - ['linear'], - ['get', 'mag'], - 0, - 0, - 6, - 1 - ], - 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 15, 3], - 'heatmap-color': [ - 'interpolate', - ['linear'], - ['heatmap-density'], - 0, - 'rgba(255,255,255,0)', - 0.1, - 'rgba(255,255,255,0.6)', - 0.3, - 'rgba(255,200,200,0.6)', - 0.6, - 'rgba(255,120,120,0.8)', - 1, - 'rgba(255,0,0,1)' - ], - 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 10, 12, 50], - 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 7, 1, 12, 0.8] - } - }, 'waterway-label'); - - // circle layer for points when zoomed in - map.addLayer({ - id: 'dc-point', - type: 'circle', - source: 'dc-quakes', - minzoom: 12, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 2, 6, 8], - 'circle-color': 'white', - 'circle-stroke-color': 'rgba(255,0,0,0.9)', - 'circle-stroke-width': 1, - 'circle-opacity': ['interpolate', ['linear'], ['zoom'], 12, 0, 14, 1] - } - }); - - // Show popup when clicking a circle point - map.on('click', 'dc-point', (e) => { - const feature = e.features && e.features[0]; - if (!feature) return; - const coords = (feature.geometry as any).coordinates.slice(); - const mag = feature.properties ? feature.properties.mag : undefined; - const html = `
Magnitude: ${mag ?? 'N/A'}
Coordinates: ${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}
`; - new mapboxgl.Popup({ offset: 15 }) - .setLngLat(coords) - .setHTML(html) - .addTo(map); - }); - - // When clicking the heatmap, try to find nearby point features; otherwise prompt to zoom in - map.on('click', 'dc-heat', (e) => { - // search a small bbox around the click point for any rendered circle features - const p = e.point; - const bbox = [[p.x - 6, p.y - 6], [p.x + 6, p.y + 6]]; - const nearby = map.queryRenderedFeatures(bbox as [mapboxgl.PointLike, mapboxgl.PointLike], { layers: ['dc-point'] }); - if (nearby && nearby.length > 0) { - const f = nearby[0]; - const coords = (f.geometry as any).coordinates.slice(); - const mag = f.properties ? f.properties.mag : undefined; - const html = `
Magnitude: ${mag ?? 'N/A'}
Coordinates: ${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}
`; - new mapboxgl.Popup({ offset: 15 }).setLngLat(coords).setHTML(html).addTo(map); - } else { - new mapboxgl.Popup({ offset: 15 }) - .setLngLat(e.lngLat) - .setHTML('
Zoom in to see individual points and details
') - .addTo(map); - } - }); - - // Change cursor to pointer when hovering heatmap or points - map.on('mouseenter', 'dc-point', () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', 'dc-point', () => { - map.getCanvas().style.cursor = ''; - }); - map.on('mouseenter', 'dc-heat', () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', 'dc-heat', () => { - map.getCanvas().style.cursor = ''; - }); - }); - - return () => { - if (mapRef.current) { - mapRef.current.remove(); - mapRef.current = null; - } - }; - }, []); + const mapRef = useRef(null); + const [heatVisible, setHeatVisible] = useState(true); + const [pointsVisible, setPointsVisible] = useState(true); + const [mapStyleChoice, setMapStyleChoice] = useState<'dark' | 'streets'>('dark'); + const [heatRadius, setHeatRadius] = useState(30); + const [heatIntensity, setHeatIntensity] = useState(1); + const [panelOpen, setPanelOpen] = useState(() => { + try { const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_panel_open') : null; return v === null ? true : v === '1'; } catch (e) { return true; } + }); + const [popup, setPopup] = useState(null); + const [popupVisible, setPopupVisible] = useState(false); return ( -
-
+ { setPanelOpen(next); try { window.localStorage.setItem('map_panel_open', next ? '1' : '0'); } catch (e) {} }} + mapStyleChoice={mapStyleChoice} + onChangeStyle={(v) => setMapStyleChoice(v)} + heatVisible={heatVisible} + onToggleHeat={(v) => setHeatVisible(v)} + pointsVisible={pointsVisible} + onTogglePoints={(v) => setPointsVisible(v)} + heatRadius={heatRadius} + onChangeRadius={(v) => setHeatRadius(v)} + heatIntensity={heatIntensity} + onChangeIntensity={(v) => setHeatIntensity(v)} /> + + { mapRef.current = m; }} + onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }} + /> + + + + { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
); }