128 lines
4.1 KiB
TypeScript
128 lines
4.1 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { MapData } from './types';
|
|
|
|
// Leaflet CDN を動的にロードする
|
|
let leafletLoaded = false;
|
|
let leafletLoadPromise: Promise<void> | null = null;
|
|
|
|
function loadLeaflet(): Promise<void> {
|
|
if (leafletLoaded) return Promise.resolve();
|
|
if (leafletLoadPromise) return leafletLoadPromise;
|
|
|
|
leafletLoadPromise = new Promise<void>((resolve, reject) => {
|
|
// CSS
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
|
document.head.appendChild(link);
|
|
|
|
// JS
|
|
const script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
|
script.onload = () => {
|
|
leafletLoaded = true;
|
|
resolve();
|
|
};
|
|
script.onerror = () => reject(new Error('Failed to load Leaflet'));
|
|
document.head.appendChild(script);
|
|
});
|
|
|
|
return leafletLoadPromise;
|
|
}
|
|
|
|
declare const L: typeof import('leaflet');
|
|
|
|
export function MapPlacesDetail({ data }: { data: MapData }) {
|
|
const { t } = useTranslation('embed');
|
|
const { query, places } = data;
|
|
const mapRef = useRef<HTMLDivElement>(null);
|
|
const mapInstanceRef = useRef<import('leaflet').Map | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!mapRef.current || places.length === 0) return;
|
|
let cancelled = false;
|
|
|
|
loadLeaflet().then(() => {
|
|
if (cancelled || !mapRef.current) return;
|
|
|
|
// 既存のマップがあれば破棄
|
|
if (mapInstanceRef.current) {
|
|
mapInstanceRef.current.remove();
|
|
}
|
|
|
|
const center = { lat: places[0]!.lat, lon: places[0]!.lon };
|
|
const map = L.map(mapRef.current).setView([center.lat, center.lon], 13);
|
|
mapInstanceRef.current = map;
|
|
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
const markers = places.map((p) =>
|
|
L.marker([p.lat, p.lon])
|
|
.addTo(map)
|
|
.bindPopup(`<b>${p.name}</b><br>${p.address}`),
|
|
);
|
|
|
|
if (markers.length > 1) {
|
|
const group = L.featureGroup(markers);
|
|
map.fitBounds(group.getBounds().pad(0.15));
|
|
}
|
|
|
|
// モーダルが開いた直後はサイズが確定していない場合がある
|
|
setTimeout(() => map.invalidateSize(), 100);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (mapInstanceRef.current) {
|
|
mapInstanceRef.current.remove();
|
|
mapInstanceRef.current = null;
|
|
}
|
|
};
|
|
}, [places]);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<h2 className="text-lg font-bold text-slate-800 mb-4">
|
|
📍 {t('searchResults.map', { query })}
|
|
</h2>
|
|
|
|
{/* Leaflet map */}
|
|
<div
|
|
ref={mapRef}
|
|
style={{ height: 400 }}
|
|
className="w-full rounded-xl overflow-hidden border border-slate-200 mb-6"
|
|
/>
|
|
|
|
{/* Place list */}
|
|
<div className="space-y-4">
|
|
{places.map((p, i) => (
|
|
<div key={`${p.lat}-${p.lon}`} className="bg-canvas border border-slate-200 rounded-xl p-4">
|
|
<div className="text-slate-400 mb-1" style={{ fontSize: 13 }}>#{i + 1}</div>
|
|
<h3 className="text-sm font-semibold text-slate-800 leading-snug mb-2">{p.name}</h3>
|
|
|
|
<div className="text-xs text-slate-600 space-y-1 mb-3">
|
|
<div>📍 {p.address}</div>
|
|
<div>📌 {p.lat.toFixed(6)}, {p.lon.toFixed(6)}</div>
|
|
{p.type && <div>🏷 {p.type}</div>}
|
|
{p.details && <div>💬 {p.details}</div>}
|
|
</div>
|
|
|
|
<a
|
|
href={p.mapUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold rounded-lg no-underline transition-colors"
|
|
>
|
|
{t('viewOn.osm')}
|
|
</a>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|