// metro-maps.jsx — 日本地鐵圖(Leaflet 互動地圖) // ── MetroMapViewer ──────────────────────────────────────────── // Uses refs + direct DOM manipulation for 60fps smooth zoom/pan. // Image is rendered at natural pixel dimensions; initial scale fits screen. // Zooming in approaches 1:1 = full resolution (no blurriness). function MetroMapViewer({ map, onClose }) { const imgRef = React.useRef(null); const containerRef = React.useRef(null); const st = React.useRef({ scale: 1, tx: 0, ty: 0, lastDist: null, startScale: 1, startTouchX: 0, startTouchY: 0, startTx: 0, startTy: 0, pinchCx: 0, pinchCy: 0, naturalW: 0, naturalH: 0, }); // transformOrigin: '0 0' — image top-left is the anchor. // State (tx, ty) = image top-left position in container space. // Point at image pixel (px, py) → container pos: (tx + px*scale, ty + py*scale) // Pinch-to-point math: // After scale change s1→s2, container point (cx,cy) stays fixed: // tx_new = cx - (cx - tx) * (s2/s1) // ty_new = cy - (cy - ty) * (s2/s1) function applyTransform() { if (!imgRef.current) return; const { scale, tx, ty } = st.current; imgRef.current.style.transform = `translate(${tx}px,${ty}px) scale(${scale})`; } function fitScale() { const c = containerRef.current; const s = st.current; if (!c || !s.naturalW) return 1; return Math.min(c.clientWidth / s.naturalW, c.clientHeight / s.naturalH); } function resetToFit() { const c = containerRef.current; const s = st.current; if (!c || !s.naturalW) return; const fs = fitScale(); s.scale = fs; // Center the image in the container s.tx = (c.clientWidth - s.naturalW * fs) / 2; s.ty = (c.clientHeight - s.naturalH * fs) / 2; applyTransform(); } function onImgLoad(e) { const s = st.current; s.naturalW = e.target.naturalWidth; s.naturalH = e.target.naturalHeight; e.target.style.width = s.naturalW + 'px'; e.target.style.height = s.naturalH + 'px'; resetToFit(); } function getTouchDist(e) { if (e.touches.length < 2) return null; return Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY); } function onTS(e) { const s = st.current; if (e.touches.length >= 2) { s.lastDist = getTouchDist(e); } else { s.lastDist = null; s.startTouchX = e.touches[0].clientX; s.startTouchY = e.touches[0].clientY; s.startTx = s.tx; s.startTy = s.ty; } } function onTM(e) { e.preventDefault(); const s = st.current; if (e.touches.length === 2 && s.lastDist) { const newDist = getTouchDist(e); const scaleRatio = newDist / s.lastDist; const fs = fitScale(); const newScale = Math.min(4.0, Math.max(fs * 0.9, s.scale * scaleRatio)); const actualRatio = newScale / s.scale; // Current pinch midpoint in container space (recalculated each frame) const rect = containerRef.current.getBoundingClientRect(); const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; // Zoom toward current midpoint (incremental) s.tx = midX - (midX - s.tx) * actualRatio; s.ty = midY - (midY - s.ty) * actualRatio; s.scale = newScale; s.lastDist = newDist; // ← update each frame (incremental, not absolute) applyTransform(); } else if (e.touches.length === 1 && !s.lastDist) { s.tx = s.startTx + (e.touches[0].clientX - s.startTouchX); s.ty = s.startTy + (e.touches[0].clientY - s.startTouchY); applyTransform(); } } function onTE(e) { if (e.touches.length < 2) { st.current.lastDist = null; // Update pan start pos if still one finger on screen if (e.touches.length === 1) { const s = st.current; s.startTouchX = e.touches[0].clientX; s.startTouchY = e.touches[0].clientY; s.startTx = s.tx; s.startTy = s.ty; } } } // Attach passive:false listener so preventDefault works on mobile React.useEffect(() => { const el = containerRef.current; if (!el) return; const opts = { passive: false }; el.addEventListener('touchmove', onTM, opts); return () => el.removeEventListener('touchmove', onTM, opts); }, []); return ReactDOM.createPortal(
{/* Header */}
{map.emoji} {map.city_name}
{/* Map viewer */}
{map.city_name}
雙指縮放・單指拖動・點「重設」回復
, document.body ); } // ── MetroMapsScreen ─────────────────────────────────────────── function MetroMapsScreen({ go }) { const [maps, setMaps] = React.useState([]); const [loading, setLoading] = React.useState(true); const [viewing, setViewing] = React.useState(null); const [search, setSearch] = React.useState(''); const [leafletReady, setLeafletReady] = React.useState(false); const mapDivRef = React.useRef(null); const leafletMap = React.useRef(null); const markers = React.useRef([]); // Load maps data React.useEffect(() => { fetch('/api/metro-maps', { headers: { Authorization: `Bearer ${window.tabiToken()}` } }) .then(r => r.ok ? r.json() : []) .then(d => { setMaps(Array.isArray(d) ? d : []); setLoading(false); }) .catch(() => setLoading(false)); }, []); // Load Leaflet (hosted locally) React.useEffect(() => { if (window.L) { setLeafletReady(true); return; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '/vendor/leaflet.css'; document.head.appendChild(link); const script = document.createElement('script'); script.src = '/vendor/leaflet.min.js'; script.onload = () => setLeafletReady(true); document.body.appendChild(script); }, []); // Init / update map when ready + maps loaded React.useEffect(() => { if (!leafletReady || !mapDivRef.current || maps.length === 0) return; const L = window.L; if (!leafletMap.current) { const m = L.map(mapDivRef.current, { center: [37.5, 137.5], zoom: 5, zoomControl: false, // ← remove +/- from top-left attributionControl: false, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, }).addTo(m); // No zoom control on mobile — pinch to zoom is natural leafletMap.current = m; } // Clear old markers markers.current.forEach(mk => mk.remove()); markers.current = []; // Add city markers — small clean pin style const filtered = search ? maps.filter(m => m.city_name.includes(search) || m.city_key.includes(search.toLowerCase())) : maps; filtered.forEach(city => { if (!city.lat || !city.lng) return; const icon = L.divIcon({ className: '', html: `
${city.emoji}
`, iconSize: [36, 36], iconAnchor: [18, 18], tooltipAnchor: [18, -20], }); const mk = L.marker([city.lat, city.lng], { icon }) .addTo(leafletMap.current) .bindTooltip(city.city_name, { permanent: false, direction: 'top', offset: [0, -4], className: 'metro-tooltip', }) .on('click', () => setViewing(city)); markers.current.push(mk); }); // If search match, fly to first result if (search && filtered.length > 0 && filtered[0].lat) { leafletMap.current.flyTo([filtered[0].lat, filtered[0].lng], 11, { duration: 0.8 }); } }, [leafletReady, maps, search]); // Cleanup on unmount React.useEffect(() => () => { if (leafletMap.current) { leafletMap.current.remove(); leafletMap.current = null; } }, []); return (
{/* ① 頁頭:返回 + 標題 */}
METRO
日本地鐵圖
{/* ② 搜尋欄 */}
🔍 setSearch(e.target.value)} placeholder="搜尋城市,例:東京、大阪" style={{ width:'100%', padding:'9px 12px 9px 34px', borderRadius:10, border:`1px solid ${TABI_COLORS.line}`, background:TABI_COLORS.bg, fontSize:16, color:TABI_COLORS.ink, outline:'none', fontFamily:'inherit', boxSizing:'border-box', }} /> {search && ( )}
{/* ③ 城市快捷列 */} {!loading && maps.length > 0 && (
{maps.map(m => ( ))}
)} {/* Leaflet tooltip CSS */} {/* ④ 地圖(fills remaining height) */}
{loading ? (
載入中…
) : maps.length === 0 ? (
🗺️
尚無地鐵圖
) : (
)}
{viewing && setViewing(null)} />}
); }