Files
TREK/client/src/pages/atlas/useAtlas.ts
T
jubnl 3c040fab11 fix: miscellaneous bug fixes (#1139)
* fix(share): serve place thumbnails in shared trip links (#1100)

Google-sourced place photos are stored as image_url pointing at the
JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401
for an unauthenticated shared-trip viewer and render as broken images.

Rewrite place image_url values in the shared payload to a public,
token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and
add an unguarded SharedController route that validates the token and that
the place belongs to its trip before streaming the cached bytes. Mirrors
the existing JourneyPublicController precedent. No client changes needed.

* fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119)

Atlas sourced country and sub-national boundaries from Natural Earth's GitHub
`master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020
counties such as Oppland/Hordaland) and depicts some contested territory in
unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped
entirely.

- Country borders (admin0) now come from the geoBoundaries CGAZ composite;
  sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2
  codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes
  them into committed gzipped bundles under server/assets/atlas, read server-side at
  runtime (no network at boot, no GitHub CSP allowlist entry).
- New GET /addons/atlas/countries/geo serves the country layer; the client fetches
  it from the API instead of GitHub.
- A migration reconciles manually-marked visited_regions against the new bundle
  (valid code -> keep; region name still matches -> re-code; curated merge crosswalk
  for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and
  visited_countries hold only invariant alpha-2 country codes, so they are untouched.
- Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0.

Closes #1119

* fix(packing): make templates admin-only to create, usable by members

Creating a packing-list template was gated only by trip access, so any
trip member could create one from the Lists feature, while applying a
template silently failed for non-admins because the apply dropdown was
populated from the AdminGuard-protected /api/admin/packing-templates
endpoint.

- save-as-template now returns 403 for non-admins; the Save-as-Template
  button is hidden unless the user is an admin (both the TripPlanner
  toolbar and the inline packing header).
- add member-accessible GET /api/trips/:tripId/packing/templates so the
  apply dropdown lists templates for any trip member; client fetches
  from it instead of the admin endpoint.

Closes #1120
Closes #1121

* fix(packing): show bag tracking to non-admin members

The global Bag Tracking toggle was only readable via the admin-gated
GET /api/admin/bag-tracking, so non-admin trip members got 403 and the
weight fields, bag circles, and BAGS sidebar never rendered (#1124).

Surface the flag through the already-authenticated GET /api/addons
(loaded into the client addon store on app start for every user); the
packing hook reads it from the store instead of the admin endpoint. The
admin write path stays admin-gated and unchanged.
2026-06-09 16:02:37 +02:00

696 lines
32 KiB
TypeScript

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'
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<HTMLDivElement>(null)
const mapInstance = useRef<L.Map | null>(null)
const geoLayerRef = useRef<L.GeoJSON | null>(null)
const glareRef = useRef<HTMLDivElement>(null)
const borderGlareRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const country_layer_by_a2_ref = useRef<Record<string, any>>({})
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): 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<AtlasData | null>(null)
const [loading, setLoading] = useState<boolean>(true)
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
const regionLayerRef = useRef<L.GeoJSON | null>(null)
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
const [showRegions, setShowRegions] = useState(false)
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
const regionTooltipRef = useRef<HTMLDivElement>(null)
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
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<BucketItem[]>([])
const [showBucketAdd, setShowBucketAdd] = useState(false)
const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' })
const [bucketSearch, setBucketSearch] = useState('')
const [bucketSearchResults, setBucketSearchResults] = useState<any[]>([])
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<any>(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<string, string>()
for (const [a2Key, a3Val] of Object.entries(A2_TO_A3)) a3ToA2.set(a3Val, a2Key)
const opts: { code: string; label: string }[] = []
const seen = new Set<string>()
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 = `
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.firstVisit')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.firstVisit)}</span>
</div>
<div style="flex:1;display:flex;flex-direction:column;gap:2px">
<span style="font-size:9px;text-transform:uppercase;letter-spacing:0.08em;opacity:0.4">${t('atlas.lastVisitLabel')}</span>
<span style="font-size:12px;font-weight:700">${formatDate(c.lastVisit)}</span>
</div>
</div>
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, 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(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, 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<string>()
const visitedRegionNamesByCountry = new Map<string, Set<string>>()
const regionPlaceCounts: Record<string, number> = {}
for (const [countryCode, regions] of Object.entries(visitedRegions)) {
const names = new Set<string>()
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<string, string> = {}
countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
// Map country A2 code to country color
const a2ColorMap: Record<string, string> = {}
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
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
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)
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
const executeConfirmAction = async (): Promise<void> => {
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
return {
...prev,
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 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
return {
...prev,
countries: prev.countries.filter(c => c.code !== code),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
setVisitedRegions(prev => {
if (!prev[code]) return prev
const next = { ...prev }
delete next[code]
return next
})
}
}
const handleAddBucketItem = async (): Promise<void> => {
if (!bucketForm.name.trim()) return
try {
const data: Record<string, unknown> = { 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<void> => {
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: `<div style="width:28px;height:28px;border-radius:50%;background:rgba(251,191,36,0.9);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);border:2px solid white"><svg width="14" height="14" viewBox="0 0 24 24" fill="white" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
})
return L.marker([b.lat!, b.lng!], { icon }).bindTooltip(
`<div style="font-size:12px;font-weight:600">${b.name}</div>${b.notes ? `<div style="font-size:10px;opacity:0.7;margin-top:2px">${b.notes}</div>` : ''}`,
{ className: 'atlas-tooltip', direction: 'top', offset: [0, -14] }
)
})
bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current)
}, [bucketList])
const loadCountryDetail = async (code: string): Promise<void> => {
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,
}
}