MapBox Styling and component update

This commit is contained in:
2025-09-27 03:37:48 -04:00
parent ed4922dfa2
commit 7273bf13e4
10 changed files with 622 additions and 201 deletions

View File

@@ -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 (
<div className="map-control">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontWeight: 700 }}>Map Controls</div>
<button aria-expanded={panelOpen} aria-label={panelOpen ? 'Collapse panel' : 'Expand panel'} onClick={() => onTogglePanel(!panelOpen)} style={{ borderRadius: 6, padding: '4px 8px' }}>{panelOpen ? '' : '+'}</button>
</div>
{panelOpen && (
<>
<div className="mc-row">
<label className="mc-label">Style</label>
<select className="map-select" value={mapStyleChoice} onChange={(e) => onChangeStyle(e.target.value as 'dark' | 'streets')}>
<option value="dark">Dark</option>
<option value="streets">Streets</option>
</select>
</div>
<div className="mc-row">
<label className="mc-label">Heatmap</label>
<input type="checkbox" checked={heatVisible} onChange={(e) => onToggleHeat(e.target.checked)} />
</div>
<div className="mc-row">
<label className="mc-label">Points</label>
<input type="checkbox" checked={pointsVisible} onChange={(e) => onTogglePoints(e.target.checked)} />
</div>
<div style={{ marginBottom: 6 }}>
<label style={{ display: 'block', fontSize: 12 }}>Radius: {heatRadius}</label>
<input className="mc-range" type="range" min={5} max={100} value={heatRadius} onChange={(e) => onChangeRadius(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div style={{ marginBottom: 6 }}>
<label style={{ display: 'block', fontSize: 12 }}>Intensity: {heatIntensity}</label>
<input className="mc-range" type="range" min={0.1} max={5} step={0.1} value={heatIntensity} onChange={(e) => onChangeIntensity(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div style={{ fontSize: 11, opacity: 0.9 }}>Tip: switching style will reapply layers.</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import React from 'react';
export default function Legend() {
return (
<div style={{ position: 'absolute', left: 12, bottom: 12, zIndex: 12 }}>
<div style={{ background: 'var(--background)', color: 'var(--foreground)', padding: 8, borderRadius: 8, boxShadow: '0 6px 18px rgba(0,0,0,0.12)', border: '1px solid rgba(0,0,0,0.06)', fontSize: 12 }}>
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Density legend</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<div style={{ width: 18, height: 12, background: 'rgba(0,120,48,0.0)', border: '1px solid rgba(0,0,0,0.06)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(34,139,34,0.8)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(154,205,50,0.9)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,215,0,0.95)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(255,140,0,0.95)' }} />
<div style={{ width: 18, height: 12, background: 'rgba(215,25,28,1)' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontSize: 11 }}>Low</span>
<span style={{ fontSize: 11, fontWeight: 700 }}>High</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -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<HTMLDivElement | null>(null);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
const dcDataRef = useRef<GeoJSON.FeatureCollection | null>(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 (
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }}>
<div ref={mapContainerRef} style={{ width: size.width || '100%', height: size.height || '100%' }} />
</div>
);
}

View File

@@ -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<mapboxgl.Map | null>;
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 (
<div
role="dialog"
aria-label="Feature details"
className={`custom-popup ${popupVisible ? 'visible' : ''}`}
style={{ position: 'absolute', left: Math.round(p.x), top: Math.round(p.y), transform: 'translate(-50%, -100%)', pointerEvents: popupVisible ? 'auto' : 'none' }}
>
<div className="mapbox-popup-inner" style={{ background: 'var(--background)', color: 'var(--foreground)', padding: 8, borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.25)', border: '1px solid rgba(0,0,0,0.08)', minWidth: 180 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<div style={{ fontWeight: 700 }}>{popup.text ?? 'Details'}</div>
<button aria-label="Close popup" onClick={() => { onClose(); }} style={{ background: 'transparent', border: 'none', padding: 8, marginLeft: 8, cursor: 'pointer' }}>
</button>
</div>
{typeof popup.mag !== 'undefined' && <div style={{ marginTop: 6 }}><strong>Magnitude:</strong> {popup.mag}</div>}
{popup.stats && popup.stats.count > 0 && (
<div style={{ marginTop: 6, fontSize: 13 }}>
<div><strong>Nearby points:</strong> {popup.stats.count} (within {popup.stats.radiusMeters}m)</div>
<div><strong>Avg:</strong> {popup.stats.avg} &nbsp; <strong>Min:</strong> {popup.stats.min} &nbsp; <strong>Max:</strong> {popup.stats.max}</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import React from 'react';
import mapboxgl from 'mapbox-gl';
interface Props { mapRef: React.MutableRefObject<mapboxgl.Map | null> }
export default function ZoomControls({ mapRef }: Props) {
return (
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 3, display: 'flex', flexDirection: 'column', gap: 8 }}>
<button className="zoom-btn" aria-label="Zoom in" title="Zoom in" onClick={() => { const map = mapRef.current; if (!map) return; map.easeTo({ zoom: map.getZoom() + 1 }); }}>+</button>
<button className="zoom-btn" aria-label="Zoom out" title="Zoom out" onClick={() => { const map = mapRef.current; if (!map) return; map.easeTo({ zoom: map.getZoom() - 1 }); }}>-</button>
</div>
);
}

View File

@@ -0,0 +1,42 @@
export type PointFeature = GeoJSON.Feature<GeoJSON.Point, { mag: number }>;
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<GeoJSON.Geometry>;
};