import React, { useEffect, useMemo, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import apiClient, { mapsApi } from '../../api/client' import L from 'leaflet' import type { GeoJsonFeatureCollection } from '../../types' import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel' import { continentForCountry } from '@trek/shared' function useCountryNames(language: string): (code: string) => string { const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code) useEffect(() => { try { const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } }) } catch { /* */ } }, [language]) return resolver } /** * Atlas page logic — the whole interactive globe lives here: atlas/bucket-list * loading, the Leaflet map lifecycle (country + sub-national region layers, * bucket markers, viewport-driven region fetching), country/region mark/unmark * flows and the country search. AtlasPage stays a wiring container that renders * the returned state via its presentational SidebarContent/MobileStats helpers. * Behaviour is identical to the previous in-component logic. */ export function useAtlas() { const { t, language } = useTranslation() const { settings } = useSettingsStore() const navigate = useNavigate() const resolveName = useCountryNames(language) const dm = settings.dark_mode const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const mapRef = useRef(null) const mapInstance = useRef(null) const geoLayerRef = useRef(null) const glareRef = useRef(null) const borderGlareRef = useRef(null) const panelRef = useRef(null) const country_layer_by_a2_ref = useRef>({}) const handlePanelMouseMove = (e: React.MouseEvent): void => { if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return const rect = panelRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top // Subtle inner glow glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)` glareRef.current.style.opacity = '1' // Border glow that follows cursor borderGlareRef.current.style.opacity = '1' borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` } const handlePanelMouseLeave = () => { if (glareRef.current) glareRef.current.style.opacity = '0' if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0' } const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [selectedCountry, setSelectedCountry] = useState(null) const [countryDetail, setCountryDetail] = useState(null) const [geoData, setGeoData] = useState(null) const [visitedRegions, setVisitedRegions] = useState>({}) const regionLayerRef = useRef(null) const regionGeoCache = useRef>({}) const [showRegions, setShowRegions] = useState(false) const [regionGeoLoaded, setRegionGeoLoaded] = useState(0) const regionTooltipRef = useRef(null) const loadCountryDetailRef = useRef<(code: string) => void>(() => {}) const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {}) const setConfirmActionRef = useRef(() => {}) const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null) const [bucketMonth, setBucketMonth] = useState(0) const [bucketYear, setBucketYear] = useState(0) // Bucket list const [bucketList, setBucketList] = useState([]) const [showBucketAdd, setShowBucketAdd] = useState(false) const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' }) const [bucketSearch, setBucketSearch] = useState('') const [bucketSearchResults, setBucketSearchResults] = useState([]) const [bucketSearching, setBucketSearching] = useState(false) const [bucketPoiMonth, setBucketPoiMonth] = useState(0) const [bucketPoiYear, setBucketPoiYear] = useState(0) const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') const bucketMarkersRef = useRef(null) const [atlas_country_search, set_atlas_country_search] = useState('') const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([]) const [atlas_country_open, set_atlas_country_open] = useState(false) const atlas_country_options = useMemo(() => { if (!geoData) return [] // Precompute A3 → A2 reverse lookup once per geoData change instead of // scanning A2_TO_A3 for every feature that needs the fallback. const a3ToA2 = new Map() for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key) const opts: { code: string; label: string }[] = [] const seen = new Set() for (const f of (geoData as any).features || []) { const rawA2 = f?.properties?.ISO_A2 let resolvedA2: string | null = (typeof rawA2 === 'string' && rawA2.length === 2 && rawA2 !== '-99') ? rawA2 : null if (!resolvedA2) { const a3 = f?.properties?.ADM0_A3 || f?.properties?.ISO_A3 || f?.properties?.['ISO3166-1-Alpha-3'] || null if (a3 && a3 !== '-99') resolvedA2 = a3ToA2.get(a3) ?? null } if (!resolvedA2 || seen.has(resolvedA2)) continue seen.add(resolvedA2) const label = String(resolveName(resolvedA2) || f?.properties?.NAME || f?.properties?.ADMIN || resolvedA2) opts.push({ code: resolvedA2, label }) } opts.sort((a, b) => a.label.localeCompare(b.label)) return opts }, [geoData, resolveName]) // Load atlas data + bucket list useEffect(() => { Promise.all([ apiClient.get('/addons/atlas/stats'), apiClient.get('/addons/atlas/bucket-list'), ]).then(([statsRes, bucketRes]) => { setData(statsRes.data) setBucketList(bucketRes.data.items || []) setLoading(false) }).catch(() => setLoading(false)) }, []) // Load country-border GeoJSON from our API (geoBoundaries, served server-side — // no third-party fetch from the browser). useEffect(() => { apiClient.get('/addons/atlas/countries/geo') .then(res => { const geo = res.data // Dynamically build A2→A3 mapping from GeoJSON for (const f of geo.features) { const a2 = f.properties?.ISO_A2 const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3 // Only accept clean 2-letter ISO codes and never overwrite an existing // mapping: some datasets carry subdivision-style values like "CN-TW" for // Taiwan, which would clobber the legitimate TWN->TW entry (#1049). if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { A2_TO_A3[a2] = a3 } } setGeoData(geo) }) .catch(() => {}) }, []) // Load visited regions (geocoded from places/trips) — once on mount useEffect(() => { apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`) .then(r => setVisitedRegions(r.data?.regions || {})) .catch(() => {}) }, []) // Load admin-1 GeoJSON for countries visible in the current viewport const loadRegionsForViewportRef = useRef<() => void>(() => {}) const loadRegionsForViewport = (): void => { if (!mapInstance.current) return const bounds = mapInstance.current.getBounds() const toLoad: string[] = [] for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) { if (regionGeoCache.current[code]) continue try { if (bounds.intersects((layer as any).getBounds())) toLoad.push(code) } catch {} } if (!toLoad.length) return apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`) .then(geoRes => { const geo = geoRes.data if (!geo?.features) return let added = false for (const c of toLoad) { const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c) if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true } } if (added) setRegionGeoLoaded(v => v + 1) }) .catch(() => {}) } loadRegionsForViewportRef.current = loadRegionsForViewport // Initialize map — runs after loading is done and mapRef is available useEffect(() => { if (loading || !mapRef.current) return if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null } const map = L.map(mapRef.current, { center: [25, 0], zoom: 3, minZoom: 3, maxZoom: 10, zoomControl: false, attributionControl: false, maxBounds: [[-90, -220], [90, 220]], maxBoundsViscosity: 1.0, fadeAnimation: false, preferCanvas: true, }) L.control.zoom({ position: 'bottomright' }).addTo(map) const tileUrl = dark ? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png' L.tileLayer(tileUrl, { maxZoom: 10, keepBuffer: 25, updateWhenZooming: true, updateWhenIdle: false, tileSize: 256, zoomOffset: 0, crossOrigin: true, referrerPolicy: 'strict-origin-when-cross-origin', } as any).addTo(map) // Preload adjacent zoom level tiles L.tileLayer(tileUrl, { maxZoom: 10, keepBuffer: 10, opacity: 0, tileSize: 256, crossOrigin: true, referrerPolicy: 'strict-origin-when-cross-origin', }).addTo(map) // Custom pane for region layer — above overlay (z-index 400) map.createPane('regionPane') map.getPane('regionPane')!.style.zIndex = '401' mapInstance.current = map // Zoom-based region switching map.on('zoomend', () => { const z = map.getZoom() const shouldShow = z >= 5 setShowRegions(shouldShow) const overlayPane = map.getPane('overlayPane') if (overlayPane) { overlayPane.style.opacity = shouldShow ? '0.35' : '1' overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto' } if (shouldShow) { // Re-add region layer if it was removed while zoomed out if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) { regionLayerRef.current.addTo(map) } loadRegionsForViewportRef.current() } else { // Physically remove region layer so its SVG paths can't intercept events if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none' if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) { regionLayerRef.current.resetStyle() regionLayerRef.current.removeFrom(map) } } }) map.on('moveend', () => { if (map.getZoom() >= 6) loadRegionsForViewportRef.current() }) return () => { map.remove(); mapInstance.current = null } }, [dark, loading]) // Render GeoJSON countries useEffect(() => { if (!mapInstance.current || !geoData || !data) return const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean)) const countryMap = {} data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c }) // Preserve current map view const currentCenter = mapInstance.current.getCenter() const currentZoom = mapInstance.current.getZoom() if (geoLayerRef.current) { mapInstance.current.removeLayer(geoLayerRef.current) } // Generate deterministic color per country code const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef'] // Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily const visitedA3List = [...visitedA3] const colorMap = {} visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0] const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 }) geoLayerRef.current = L.geoJSON(geoData, { renderer: canvasRenderer, interactive: true, bubblingMouseEvents: false, style: (feature) => { const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id const visited = visitedA3.has(a3) return { fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'), fillOpacity: visited ? 0.7 : 0.3, color: dark ? '#333' : '#cbd5e1', weight: 0.5, } }, onEachFeature: (feature, layer) => { const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id const c = countryMap[a3] if (c) { country_layer_by_a2_ref.current[c.code] = layer const name = resolveName(c.code) const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) } const tooltipHtml = `
${name}
${c.tripCount} ${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}
${c.placeCount} ${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}
${t('atlas.firstVisit')} ${formatDate(c.firstVisit)}
${t('atlas.lastVisitLabel')} ${formatDate(c.lastVisit)}
` layer.bindTooltip(tooltipHtml, { // sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's // bounds centre, which for countries with overseas territories (e.g. France) lands // far out in the ocean instead of over the area being hovered. sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 }) layer.on('click', () => { if (c.placeCount === 0 && c.tripCount === 0) { handleUnmarkCountry(c.code) } }) layer.on('mouseover', (e) => { e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }) }) layer.on('mouseout', (e) => { geoLayerRef.current.resetStyle(e.target) }) } else { // Unvisited country — allow clicking to mark as visited // Reverse lookup: find A2 code from A3, or use A3 directly const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3) const isoA2 = feature.properties?.ISO_A2 const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null) if (countryCode && countryCode !== '-99') { country_layer_by_a2_ref.current[countryCode] = layer const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode) layer.bindTooltip(`
${name}
`, { sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 }) layer.on('click', () => handleMarkCountry(countryCode, name)) layer.on('mouseover', (e) => { e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' }) }) layer.on('mouseout', (e) => { geoLayerRef.current.resetStyle(e.target) }) } } } } as L.GeoJSONOptions & { renderer?: L.Renderer }).addTo(mapInstance.current) // Restore map view after re-render mapInstance.current.setView(currentCenter, currentZoom, { animate: false }) }, [geoData, data, dark]) // Render sub-national region layer (zoom >= 5) useEffect(() => { if (!mapInstance.current) return // Remove existing region layer if (regionLayerRef.current) { mapInstance.current.removeLayer(regionLayerRef.current) regionLayerRef.current = null } if (Object.keys(regionGeoCache.current).length === 0) return // Build set of visited region codes and per-country name sets const visitedRegionCodes = new Set() const visitedRegionNamesByCountry = new Map>() const regionPlaceCounts: Record = {} for (const [countryCode, regions] of Object.entries(visitedRegions)) { const names = new Set() for (const r of regions) { visitedRegionCodes.add(r.code) names.add(r.name.toLowerCase()) regionPlaceCounts[r.code] = r.placeCount regionPlaceCounts[`${countryCode}:${r.name.toLowerCase()}`] = r.placeCount } visitedRegionNamesByCountry.set(countryCode, names) } // Match feature by ISO code OR region name scoped to the feature's country const isVisitedFeature = (f: any) => { if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true const countryA2 = (f.properties?.iso_a2 || '').toUpperCase() const countryNames = visitedRegionNamesByCountry.get(countryA2) if (!countryNames) return false const name = (f.properties?.name || '').toLowerCase() if (countryNames.has(name)) return true const nameEn = (f.properties?.name_en || '').toLowerCase() if (nameEn && countryNames.has(nameEn)) return true return false } // Include ALL region features — visited ones get colored fill, unvisited get outline only const allFeatures: any[] = [] for (const geo of Object.values(regionGeoCache.current)) { for (const f of geo.features) { allFeatures.push(f) } } if (allFeatures.length === 0) return // Use same colors as country layer const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef'] const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : [] const countryColorMap: Record = {} countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) // Map country A2 code to country color const a2ColorMap: Record = {} if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] }) const mergedGeo = { type: 'FeatureCollection', features: allFeatures } const svgRenderer = L.svg({ pane: 'regionPane' }) regionLayerRef.current = L.geoJSON(mergedGeo as any, { renderer: svgRenderer, interactive: true, pane: 'regionPane', style: (feature) => { const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const visited = isVisitedFeature(feature) return visited ? { fillColor: a2ColorMap[countryA2] || '#6366f1', fillOpacity: 0.85, color: dark ? '#888' : '#64748b', weight: 1.2, } : { fillColor: dark ? '#ffffff' : '#000000', fillOpacity: 0.03, color: dark ? '#555' : '#94a3b8', weight: 1, } }, onEachFeature: (feature, layer) => { const regionName = feature?.properties?.name || '' const regionNameEn = feature?.properties?.name_en || '' const countryName = feature?.properties?.admin || '' const regionCode = feature?.properties?.iso_3166_2 || '' const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const visited = isVisitedFeature(feature) const count = regionPlaceCounts[regionCode] || regionPlaceCounts[`${countryA2}:${regionName.toLowerCase()}`] || regionPlaceCounts[`${countryA2}:${regionNameEn.toLowerCase()}`] || 0 layer.on('click', () => { if (!countryA2) return if (visited) { const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode || r.name.toLowerCase() === regionNameEn.toLowerCase()) if (regionEntry?.manuallyMarked) { setConfirmActionRef.current({ type: 'unmark-region', code: countryA2, name: regionName, regionCode, countryName, }) } else { loadCountryDetailRef.current(countryA2) } } else { setConfirmActionRef.current({ type: 'choose-region', code: countryA2, // country A2 code — used for flag display name: regionName, // region name — shown as heading regionCode, countryName, }) } }) layer.on('mouseover', (e: any) => { e.target.setStyle(visited ? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' } : { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' } ) const tt = regionTooltipRef.current if (tt) { tt.style.display = 'block' tt.style.left = e.originalEvent.clientX + 12 + 'px' tt.style.top = e.originalEvent.clientY - 10 + 'px' tt.innerHTML = visited ? `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` : `
${regionName}
${countryName}
` } }) layer.on('mousemove', (e: any) => { const tt = regionTooltipRef.current if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' } }) layer.on('mouseout', (e: any) => { regionLayerRef.current?.resetStyle(e.target) const tt = regionTooltipRef.current if (tt) tt.style.display = 'none' }) }, } as L.GeoJSONOptions & { renderer?: L.Renderer }) // Only add to map if currently in region mode — otherwise hold it ready for when user zooms in if (mapInstance.current.getZoom() >= 6) { regionLayerRef.current.addTo(mapInstance.current) } }, [regionGeoLoaded, visitedRegions, dark, t]) const handleMarkCountry = (code: string, name: string): void => { setConfirmAction({ type: 'choose', code, name }) } handleMarkCountryRef.current = handleMarkCountry setConfirmActionRef.current = setConfirmAction const handleUnmarkCountry = (code: string): void => { const country = data?.countries.find(c => c.code === code) setConfirmAction({ type: 'unmark', code, name: resolveName(code) }) } const select_country_from_search = (country_code: string): void => { const country_label = resolveName(country_code) set_atlas_country_search(country_label) set_atlas_country_open(false) set_atlas_country_results([]) const layer = country_layer_by_a2_ref.current[country_code] try { if (layer?.getBounds && mapInstance.current) { mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 }) } } catch (e ) { console.error('Error fitting bounds', e) } // Mirror the map-click behaviour so an already-visited country can be removed // straight from search. Tiny countries (Vatican City, Singapore) are hard to // hit on the map, so search was the only way in — but it always opened the // "Mark / Bucket" dialog with no Remove option. const visited = data?.countries.find(c => c.code === country_code) if (visited) { if (visited.placeCount === 0 && visited.tripCount === 0) { handleUnmarkCountry(country_code) } else { loadCountryDetailRef.current(country_code) } return } setConfirmAction({ type: 'choose', code: country_code, name: country_label }) } const executeConfirmAction = async (): Promise => { if (!confirmAction) return const { type, code } = confirmAction setConfirmAction(null) // Update local state immediately (no API reload = no map re-render flash) if (type === 'mark') { apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {}) setData(prev => { if (!prev || prev.countries.find(c => c.code === code)) return prev const cont = continentForCountry(code) return { ...prev, countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 }, } }) } else { apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {}) setSelectedCountry(null) setCountryDetail(null) setData(prev => { if (!prev) return prev const c = prev.countries.find(c => c.code === code) if (!c || c.placeCount > 0 || c.tripCount > 0) return prev const cont = continentForCountry(code) return { ...prev, countries: prev.countries.filter(c => c.code !== code), stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) }, } }) setVisitedRegions(prev => { if (!prev[code]) return prev const next = { ...prev } delete next[code] return next }) } } const handleAddBucketItem = async (): Promise => { if (!bucketForm.name.trim()) return try { const data: Record = { name: bucketForm.name.trim() } if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim() if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) } const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null) if (targetDate) data.target_date = targetDate const r = await apiClient.post('/addons/atlas/bucket-list', data) setBucketList(prev => [r.data.item, ...prev]) setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }) setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) setShowBucketAdd(false) } catch { /* */ } } const handleDeleteBucketItem = async (id: number): Promise => { try { await apiClient.delete(`/addons/atlas/bucket-list/${id}`) setBucketList(prev => prev.filter(i => i.id !== id)) } catch { /* */ } } const handleBucketPoiSearch = async () => { if (!bucketSearch.trim()) return setBucketSearching(true) try { const result = await mapsApi.search(bucketSearch, language) setBucketSearchResults(result.places || []) } catch (err) { console.error('Bucket-list place search failed:', err) } finally { setBucketSearching(false) } } const handleSelectBucketPoi = (result: any) => { const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null setBucketForm({ name: result.name || bucketSearch, notes: '', lat: String(result.lat || ''), lng: String(result.lng || ''), target_date: targetDate || '', }) setBucketSearchResults([]) setBucketSearch('') } // Render bucket list markers on map useEffect(() => { if (!mapInstance.current) return if (bucketMarkersRef.current) { mapInstance.current.removeLayer(bucketMarkersRef.current) } if (bucketList.length === 0) return const markers = bucketList.filter(b => b.lat && b.lng).map(b => { const icon = L.divIcon({ className: '', html: `
`, iconSize: [28, 28], iconAnchor: [14, 14], }) return L.marker([b.lat!, b.lng!], { icon }).bindTooltip( `
${b.name}
${b.notes ? `
${b.notes}
` : ''}`, { className: 'atlas-tooltip', direction: 'top', offset: [0, -14] } ) }) bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current) }, [bucketList]) const loadCountryDetail = async (code: string): Promise => { setSelectedCountry(code) try { const r = await apiClient.get(`/addons/atlas/country/${code}`) setCountryDetail(r.data) } catch { /* */ } } loadCountryDetailRef.current = loadCountryDetail const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } const countries = data?.countries || [] return { t, language, navigate, resolveName, dark, loading, mapRef, regionTooltipRef, panelRef, glareRef, borderGlareRef, handlePanelMouseMove, handlePanelMouseLeave, data, setData, stats, countries, selectedCountry, countryDetail, loadCountryDetail, handleUnmarkCountry, select_country_from_search, visitedRegions, setVisitedRegions, atlas_country_search, set_atlas_country_search, atlas_country_results, set_atlas_country_results, atlas_country_open, set_atlas_country_open, atlas_country_options, confirmAction, setConfirmAction, executeConfirmAction, bucketMonth, setBucketMonth, bucketYear, setBucketYear, bucketList, setBucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, } }