MapBox Styling and component update
This commit is contained in:
62
web/src/app/components/ControlsPanel.tsx
Normal file
62
web/src/app/components/ControlsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
web/src/app/components/Legend.tsx
Normal file
27
web/src/app/components/Legend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
web/src/app/components/MapView.tsx
Normal file
185
web/src/app/components/MapView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
web/src/app/components/PopupOverlay.tsx
Normal file
45
web/src/app/components/PopupOverlay.tsx
Normal 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} <strong>Min:</strong> {popup.stats.min} <strong>Max:</strong> {popup.stats.max}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
web/src/app/components/ZoomControls.tsx
Normal file
15
web/src/app/components/ZoomControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
web/src/app/components/mapUtils.ts
Normal file
42
web/src/app/components/mapUtils.ts
Normal 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>;
|
||||||
|
};
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
@import '@skeletonlabs/skeleton';
|
@import '@skeletonlabs/skeleton';
|
||||||
@import '@skeletonlabs/skeleton/optional/presets';
|
@import '@skeletonlabs/skeleton/optional/presets';
|
||||||
@import '@skeletonlabs/skeleton/themes/rose';
|
@import '@skeletonlabs/skeleton/themes/cerberus';
|
||||||
|
|
||||||
@source '../../node_modules/@skeletonlabs/skeleton-react/dist';
|
@source '../../node_modules/@skeletonlabs/skeleton-react/dist';
|
||||||
|
|
||||||
@@ -30,3 +30,164 @@ body {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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; }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="rose">
|
<html lang="en" data-theme="cerberus">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
38
web/src/app/lib/mapUtils.ts
Normal file
38
web/src/app/lib/mapUtils.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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>;
|
||||||
|
};
|
||||||
@@ -1,209 +1,55 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import MapView, { PopupData } from './components/MapView';
|
||||||
|
import ControlsPanel from './components/ControlsPanel';
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import PopupOverlay from './components/PopupOverlay';
|
||||||
|
import Legend from './components/Legend';
|
||||||
|
import ZoomControls from './components/ZoomControls';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<any>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const [heatVisible, setHeatVisible] = useState(true);
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
const [pointsVisible, setPointsVisible] = useState(true);
|
||||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
const [mapStyleChoice, setMapStyleChoice] = useState<'dark' | 'streets'>('dark');
|
||||||
|
const [heatRadius, setHeatRadius] = useState(30);
|
||||||
// Generate sample clustered points around Washington, DC
|
const [heatIntensity, setHeatIntensity] = useState(1);
|
||||||
const generateDCPoints = (count = 500) => {
|
const [panelOpen, setPanelOpen] = useState<boolean>(() => {
|
||||||
const center = { lon: -77.0369, lat: 38.9072 };
|
try { const v = typeof window !== 'undefined' ? window.localStorage.getItem('map_panel_open') : null; return v === null ? true : v === '1'; } catch (e) { return true; }
|
||||||
const features: GeoJSON.Feature<GeoJSON.Point, { mag: number }>[] = [];
|
});
|
||||||
|
const [popup, setPopup] = useState<PopupData>(null);
|
||||||
// simple clustered distribution using gaussian-like offsets
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
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<GeoJSON.Geometry>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = `<div><strong>Magnitude:</strong> ${mag ?? 'N/A'}<br/><strong>Coordinates:</strong> ${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}</div>`;
|
|
||||||
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 = `<div><strong>Magnitude:</strong> ${mag ?? 'N/A'}<br/><strong>Coordinates:</strong> ${coords[1].toFixed(4)}, ${coords[0].toFixed(4)}</div>`;
|
|
||||||
new mapboxgl.Popup({ offset: 15 }).setLngLat(coords).setHTML(html).addTo(map);
|
|
||||||
} else {
|
|
||||||
new mapboxgl.Popup({ offset: 15 })
|
|
||||||
.setLngLat(e.lngLat)
|
|
||||||
.setHTML('<div><em>Zoom in to see individual points and details</em></div>')
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ position: 'absolute', inset: 0 }}>
|
<div style={{ position: 'absolute', inset: 0 }}>
|
||||||
<div
|
<ControlsPanel
|
||||||
ref={mapContainerRef}
|
panelOpen={panelOpen}
|
||||||
style={{ width: size.width || '100%', height: size.height || '100%' }}
|
onTogglePanel={(next) => { 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)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MapView
|
||||||
|
mapStyleChoice={mapStyleChoice}
|
||||||
|
heatRadius={heatRadius}
|
||||||
|
heatIntensity={heatIntensity}
|
||||||
|
heatVisible={heatVisible}
|
||||||
|
pointsVisible={pointsVisible}
|
||||||
|
onMapReady={(m) => { mapRef.current = m; }}
|
||||||
|
onPopupCreate={(p) => { setPopupVisible(false); setPopup(p); requestAnimationFrame(() => setPopupVisible(true)); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Legend />
|
||||||
|
<ZoomControls mapRef={mapRef} />
|
||||||
|
<PopupOverlay popup={popup} popupVisible={popupVisible} mapRef={mapRef} onClose={() => { setPopupVisible(false); setTimeout(() => setPopup(null), 220); }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user