maestro/ui/src/components/embed/MapPlacesDetail.tsx
oss-sync d061ad08d8
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

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: '&copy; <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">
&#128205; {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>&#128205; {p.address}</div>
<div>&#128204; {p.lat.toFixed(6)}, {p.lon.toFixed(6)}</div>
{p.type && <div>&#127991; {p.type}</div>}
{p.details && <div>&#128172; {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>
);
}