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 (
+
+ );
+}
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); }} />
);
}