diff --git a/site/package.json b/site/package.json index 23bf3e1..e3a5920 100644 --- a/site/package.json +++ b/site/package.json @@ -22,7 +22,6 @@ "leaflet": "^1.9.4", "react": "19.2.8", "react-dom": "19.2.8", - "react-leaflet": "^5.0.0", "viem": "2.55.15" }, "devDependencies": { diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 7bc5e4f..a8588e1 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -26,9 +26,6 @@ importers: react-dom: specifier: 19.2.8 version: 19.2.8(react@19.2.8) - react-leaflet: - specifier: ^5.0.0 - version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) viem: specifier: 2.55.15 version: 2.55.15(typescript@7.0.2) @@ -90,13 +87,6 @@ packages: '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} - '@react-leaflet/core@3.0.0': - resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - '@rolldown/binding-android-arm64@1.2.4': resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -557,13 +547,6 @@ packages: peerDependencies: react: ^19.2.8 - react-leaflet@5.0.0: - resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} - peerDependencies: - leaflet: ^1.9.0 - react: ^19.0.0 - react-dom: ^19.0.0 - react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -743,12 +726,6 @@ snapshots: '@oxc-project/types@0.144.0': {} - '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - leaflet: 1.9.4 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -1067,13 +1044,6 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 - react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): - dependencies: - '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - leaflet: 1.9.4 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react@19.2.8: {} rolldown@1.2.4: diff --git a/site/public/licenses/leaflet-BSD-2-Clause.txt b/site/public/licenses/leaflet-BSD-2-Clause.txt new file mode 100644 index 0000000..7b41f20 --- /dev/null +++ b/site/public/licenses/leaflet-BSD-2-Clause.txt @@ -0,0 +1,26 @@ +BSD 2-Clause License + +Copyright (c) 2010-2023, Volodymyr Agafonkin +Copyright (c) 2010-2011, CloudMade +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/site/src/styles.css b/site/src/styles.css index af52d10..3b1c023 100644 --- a/site/src/styles.css +++ b/site/src/styles.css @@ -2902,6 +2902,11 @@ footer { margin: 13px 15px; } +.leaflet-evidence-popup { + display: grid; + gap: 5px; +} + .leaflet-evidence-map .leaflet-popup-content strong { font-size: 12px; line-height: 1.35; diff --git a/site/src/views/DisasterEvidenceMapDetail.tsx b/site/src/views/DisasterEvidenceMapDetail.tsx index 72cce78..cec0625 100644 --- a/site/src/views/DisasterEvidenceMapDetail.tsx +++ b/site/src/views/DisasterEvidenceMapDetail.tsx @@ -1,12 +1,5 @@ -import { useEffect, useMemo, useState } from 'react'; -import { - Circle, - CircleMarker, - MapContainer, - Popup, - TileLayer, - useMap, -} from 'react-leaflet'; +import Leaflet from 'leaflet'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { LiveCallLoading, ProjectDetailFrame, VerificationStamp } from '../components/ProjectDetailFrame'; import type { LiveSourceStatus } from '../components/ProjectDetailFrame'; import { callVerifiedListing, downloadJson } from '../lib'; @@ -103,19 +96,124 @@ function markerColor(marker: EvidenceMarker): string { return '#607568'; } -function FitMapToEvidence({ markers }: { markers: EvidenceMarker[] }) { - const map = useMap(); +function markerPopup(marker: EvidenceMarker): HTMLElement { + const content = document.createElement('div'); + const title = document.createElement('strong'); + const detail = document.createElement('span'); + content.className = 'leaflet-evidence-popup'; + title.textContent = marker.title; + detail.textContent = `${marker.source} · ${Math.round(marker.distanceKm).toLocaleString()} km from Tokyo`; + content.append(title, detail); + return content; +} + +function EvidenceLeafletMap({ + markers, + selectedMarkerId, + onSelectMarker, +}: { + markers: EvidenceMarker[]; + selectedMarkerId: string | null; + onSelectMarker: (markerId: string) => void; +}) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const evidenceLayersRef = useRef(null); + const markerLayersRef = useRef(new Map()); useEffect(() => { - if (!markers.length) return; - map.invalidateSize(); - map.fitBounds( - markers.map((marker) => [marker.latitude, marker.longitude] as [number, number]), - { padding: [34, 34], maxZoom: 5 }, - ); - }, [map, markers]); - - return null; + if (!containerRef.current) return; + + const map = Leaflet.map(containerRef.current, { + minZoom: 2, + scrollWheelZoom: true, + worldCopyJump: true, + }).setView([35.689444444, 139.691666666], 3); + Leaflet.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(map); + + mapRef.current = map; + evidenceLayersRef.current = Leaflet.layerGroup().addTo(map); + + return () => { + map.remove(); + mapRef.current = null; + evidenceLayersRef.current = null; + markerLayersRef.current.clear(); + }; + }, []); + + useEffect(() => { + const map = mapRef.current; + const evidenceLayers = evidenceLayersRef.current; + if (!map || !evidenceLayers || !markers.length) return; + + evidenceLayers.clearLayers(); + markerLayersRef.current.clear(); + + const watchArea = markers.find((marker) => marker.kind === 'watch-area'); + if (watchArea) { + Leaflet.circle([watchArea.latitude, watchArea.longitude], { + color: '#355b42', + dashArray: '6 7', + fillColor: '#355b42', + fillOpacity: 0.06, + radius: WATCH_RADIUS_KM * 1000, + weight: 1.5, + }).addTo(evidenceLayers); + } + + for (const marker of markers) { + const selected = selectedMarkerId === marker.id; + const markerLayer = Leaflet.circleMarker( + [marker.latitude, marker.longitude], + { + color: 'white', + fillColor: markerColor(marker), + fillOpacity: 0.94, + opacity: 1, + radius: selected ? 10 : marker.alert ? 8 : 6, + weight: selected ? 3 : 2, + }, + ) + .bindPopup(markerPopup(marker)) + .on('click', () => onSelectMarker(marker.id)) + .addTo(evidenceLayers); + markerLayersRef.current.set(marker.id, markerLayer); + } + + const resizeFrame = window.requestAnimationFrame(() => { + map.invalidateSize(); + map.fitBounds( + Leaflet.latLngBounds( + markers.map((marker) => [marker.latitude, marker.longitude]), + ), + { padding: [34, 34], maxZoom: 5 }, + ); + }); + + return () => window.cancelAnimationFrame(resizeFrame); + }, [markers, onSelectMarker]); + + useEffect(() => { + for (const marker of markers) { + const markerLayer = markerLayersRef.current.get(marker.id); + if (!markerLayer) continue; + const selected = selectedMarkerId === marker.id; + markerLayer.setRadius(selected ? 10 : marker.alert ? 8 : 6); + markerLayer.setStyle({ weight: selected ? 3 : 2 }); + } + }, [markers, selectedMarkerId]); + + return ( +
+ ); } export function DisasterEvidenceMapDetail() { @@ -129,7 +227,6 @@ export function DisasterEvidenceMapDetail() { const selectedMarker = markers.find((marker) => marker.id === selectedMarkerId) ?? null; const selectedCall = calls.find((call) => call.spec.id === selectedMarker?.sourceId) ?? null; const alerts = markers.filter((marker) => marker.alert); - const watchArea = markers.find((marker) => marker.kind === 'watch-area') ?? null; const evidencePack = useMemo(() => calls.length !== 3 ? null : { schemaVersion: '1.0', @@ -300,57 +397,11 @@ export function DisasterEvidenceMapDetail() { {calls.length === 3 && (
- - - - {watchArea && ( - - )} - {markers.map((marker) => { - const color = markerColor(marker); - return ( - setSelectedMarkerId(marker.id) }} - key={marker.id} - pathOptions={{ - color: 'white', - fillColor: color, - fillOpacity: 0.94, - opacity: 1, - weight: selectedMarker?.id === marker.id ? 3 : 2, - }} - radius={selectedMarker?.id === marker.id ? 10 : marker.alert ? 8 : 6} - > - - {marker.title} - {marker.source} · {Math.round(marker.distanceKm).toLocaleString()} km from Tokyo - - - ); - })} - +
{alerts.length ? `${alerts.length} trusted alerts` : 'No events crossed the rule'}Derived only from {calls.length} verified receipts diff --git a/use-cases/disaster-evidence-map/README.md b/use-cases/disaster-evidence-map/README.md index 6ce9e17..c2433d3 100644 --- a/use-cases/disaster-evidence-map/README.md +++ b/use-cases/disaster-evidence-map/README.md @@ -35,7 +35,7 @@ Event calls → geofence → source receipts → alert evidence pack. The first implementation deliberately uses one location rule: a GeoDB-signed Tokyo point and a 1,500 km radius. It combines the latest active NASA EONET events with the largest recent USGS earthquakes and creates an alert only after all three inputs verify. -The interactive map uses React Leaflet 5 with Leaflet 1.9.4 and OpenStreetMap tiles. Tile attribution remains visible inside the map. The library, tiles, and Airnode calls all run directly in the browser; there is no map proxy or application backend. +The interactive map uses Leaflet 1.9.4 directly with OpenStreetMap tiles. Tile attribution remains visible inside the map. The library, tiles, and Airnode calls all run directly in the browser; there is no map proxy or application backend. ## Run it