Merge pull request #420 from mauriceboe/feat/atlas
feat(atlas): sub-national region view when zooming in
This commit is contained in:
@@ -690,8 +690,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'إزالة',
|
'atlas.unmark': 'إزالة',
|
||||||
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
||||||
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
||||||
|
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
|
||||||
'atlas.markVisited': 'تعيين كمُزار',
|
'atlas.markVisited': 'تعيين كمُزار',
|
||||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||||
|
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
|
||||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||||
'atlas.addPoi': 'إضافة مكان',
|
'atlas.addPoi': 'إضافة مكان',
|
||||||
'atlas.searchCountry': 'ابحث عن دولة...',
|
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||||
|
|||||||
@@ -672,8 +672,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Remover',
|
'atlas.unmark': 'Remover',
|
||||||
'atlas.confirmMark': 'Marcar este país como visitado?',
|
'atlas.confirmMark': 'Marcar este país como visitado?',
|
||||||
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
|
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
|
||||||
'atlas.markVisited': 'Marcar como visitado',
|
'atlas.markVisited': 'Marcar como visitado',
|
||||||
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
||||||
|
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
|
||||||
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
||||||
'atlas.addPoi': 'Adicionar lugar',
|
'atlas.addPoi': 'Adicionar lugar',
|
||||||
'atlas.searchCountry': 'Buscar um país...',
|
'atlas.searchCountry': 'Buscar um país...',
|
||||||
|
|||||||
@@ -689,8 +689,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Odebrat',
|
'atlas.unmark': 'Odebrat',
|
||||||
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
|
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
|
||||||
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
|
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?',
|
||||||
'atlas.markVisited': 'Označit jako navštívené',
|
'atlas.markVisited': 'Označit jako navštívené',
|
||||||
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
|
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
|
||||||
|
'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených',
|
||||||
'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
|
'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
|
||||||
'atlas.addPoi': 'Přidat místo',
|
'atlas.addPoi': 'Přidat místo',
|
||||||
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
|
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
|
||||||
|
|||||||
@@ -688,8 +688,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Entfernen',
|
'atlas.unmark': 'Entfernen',
|
||||||
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||||
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
|
||||||
'atlas.markVisited': 'Als besucht markieren',
|
'atlas.markVisited': 'Als besucht markieren',
|
||||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||||
|
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
|
||||||
'atlas.addToBucket': 'Zur Bucket List',
|
'atlas.addToBucket': 'Zur Bucket List',
|
||||||
'atlas.addPoi': 'Ort hinzufügen',
|
'atlas.addPoi': 'Ort hinzufügen',
|
||||||
'atlas.searchCountry': 'Land suchen...',
|
'atlas.searchCountry': 'Land suchen...',
|
||||||
|
|||||||
@@ -685,8 +685,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Remove',
|
'atlas.unmark': 'Remove',
|
||||||
'atlas.confirmMark': 'Mark this country as visited?',
|
'atlas.confirmMark': 'Mark this country as visited?',
|
||||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||||
'atlas.markVisited': 'Mark as visited',
|
'atlas.markVisited': 'Mark as visited',
|
||||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||||
|
'atlas.markRegionVisitedHint': 'Add this region to your visited list',
|
||||||
'atlas.addToBucket': 'Add to bucket list',
|
'atlas.addToBucket': 'Add to bucket list',
|
||||||
'atlas.addPoi': 'Add place',
|
'atlas.addPoi': 'Add place',
|
||||||
'atlas.searchCountry': 'Search a country...',
|
'atlas.searchCountry': 'Search a country...',
|
||||||
|
|||||||
@@ -700,8 +700,10 @@ const es: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Eliminar',
|
'atlas.unmark': 'Eliminar',
|
||||||
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
||||||
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
||||||
|
'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?',
|
||||||
'atlas.markVisited': 'Marcar como visitado',
|
'atlas.markVisited': 'Marcar como visitado',
|
||||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||||
|
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
|
||||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||||
'atlas.addPoi': 'Añadir lugar',
|
'atlas.addPoi': 'Añadir lugar',
|
||||||
'atlas.searchCountry': 'Buscar un país...',
|
'atlas.searchCountry': 'Buscar un país...',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const fr: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Retirer',
|
'atlas.unmark': 'Retirer',
|
||||||
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
||||||
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
|
||||||
'atlas.markVisited': 'Marquer comme visité',
|
'atlas.markVisited': 'Marquer comme visité',
|
||||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
||||||
|
'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités',
|
||||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||||
'atlas.addPoi': 'Ajouter un lieu',
|
'atlas.addPoi': 'Ajouter un lieu',
|
||||||
'atlas.searchCountry': 'Rechercher un pays…',
|
'atlas.searchCountry': 'Rechercher un pays…',
|
||||||
|
|||||||
@@ -688,8 +688,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Eltávolítás',
|
'atlas.unmark': 'Eltávolítás',
|
||||||
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
|
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
|
||||||
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
|
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?',
|
||||||
'atlas.markVisited': 'Megjelölés meglátogatottként',
|
'atlas.markVisited': 'Megjelölés meglátogatottként',
|
||||||
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
||||||
|
'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához',
|
||||||
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
||||||
'atlas.addPoi': 'Hely hozzáadása',
|
'atlas.addPoi': 'Hely hozzáadása',
|
||||||
'atlas.searchCountry': 'Ország keresése...',
|
'atlas.searchCountry': 'Ország keresése...',
|
||||||
|
|||||||
@@ -688,8 +688,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Rimuovi',
|
'atlas.unmark': 'Rimuovi',
|
||||||
'atlas.confirmMark': 'Segnare questo paese come visitato?',
|
'atlas.confirmMark': 'Segnare questo paese come visitato?',
|
||||||
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
|
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?',
|
||||||
'atlas.markVisited': 'Segna come visitato',
|
'atlas.markVisited': 'Segna come visitato',
|
||||||
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
|
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
|
||||||
|
'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati',
|
||||||
'atlas.addToBucket': 'Aggiungi alla lista desideri',
|
'atlas.addToBucket': 'Aggiungi alla lista desideri',
|
||||||
'atlas.addPoi': 'Aggiungi luogo',
|
'atlas.addPoi': 'Aggiungi luogo',
|
||||||
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
|
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const nl: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Verwijderen',
|
'atlas.unmark': 'Verwijderen',
|
||||||
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
||||||
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
|
||||||
'atlas.markVisited': 'Markeren als bezocht',
|
'atlas.markVisited': 'Markeren als bezocht',
|
||||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
||||||
|
'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst',
|
||||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||||
'atlas.addPoi': 'Plaats toevoegen',
|
'atlas.addPoi': 'Plaats toevoegen',
|
||||||
'atlas.searchCountry': 'Zoek een land...',
|
'atlas.searchCountry': 'Zoek een land...',
|
||||||
|
|||||||
@@ -651,8 +651,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Usuń',
|
'atlas.unmark': 'Usuń',
|
||||||
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
||||||
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?',
|
||||||
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
||||||
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
||||||
|
'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych',
|
||||||
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
||||||
'atlas.addPoi': 'Dodaj miejsce',
|
'atlas.addPoi': 'Dodaj miejsce',
|
||||||
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const ru: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Удалить',
|
'atlas.unmark': 'Удалить',
|
||||||
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
||||||
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
|
||||||
'atlas.markVisited': 'Отметить как посещённую',
|
'atlas.markVisited': 'Отметить как посещённую',
|
||||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||||
|
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
|
||||||
'atlas.addToBucket': 'В список желаний',
|
'atlas.addToBucket': 'В список желаний',
|
||||||
'atlas.addPoi': 'Добавить место',
|
'atlas.addPoi': 'Добавить место',
|
||||||
'atlas.searchCountry': 'Поиск страны...',
|
'atlas.searchCountry': 'Поиск страны...',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const zh: Record<string, string> = {
|
|||||||
'atlas.unmark': '移除',
|
'atlas.unmark': '移除',
|
||||||
'atlas.confirmMark': '将此国家标记为已访问?',
|
'atlas.confirmMark': '将此国家标记为已访问?',
|
||||||
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
||||||
|
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
|
||||||
'atlas.markVisited': '标记为已访问',
|
'atlas.markVisited': '标记为已访问',
|
||||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||||
|
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
|
||||||
'atlas.addToBucket': '添加到心愿单',
|
'atlas.addToBucket': '添加到心愿单',
|
||||||
'atlas.addPoi': '添加地点',
|
'atlas.addPoi': '添加地点',
|
||||||
'atlas.searchCountry': '搜索国家...',
|
'atlas.searchCountry': '搜索国家...',
|
||||||
|
|||||||
@@ -154,7 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | 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 [bucketMonth, setBucketMonth] = useState(0)
|
||||||
const [bucketYear, setBucketYear] = useState(0)
|
const [bucketYear, setBucketYear] = useState(0)
|
||||||
|
|
||||||
@@ -221,6 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
.catch(() => {})
|
.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
|
// Initialize map — runs after loading is done and mapRef is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !mapRef.current) return
|
if (loading || !mapRef.current) return
|
||||||
@@ -230,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
center: [25, 0],
|
center: [25, 0],
|
||||||
zoom: 3,
|
zoom: 3,
|
||||||
minZoom: 3,
|
minZoom: 3,
|
||||||
maxZoom: 7,
|
maxZoom: 10,
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
maxBounds: [[-90, -220], [90, 220]],
|
maxBounds: [[-90, -220], [90, 220]],
|
||||||
@@ -246,7 +290,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
L.tileLayer(tileUrl, {
|
L.tileLayer(tileUrl, {
|
||||||
maxZoom: 8,
|
maxZoom: 10,
|
||||||
keepBuffer: 25,
|
keepBuffer: 25,
|
||||||
updateWhenZooming: true,
|
updateWhenZooming: true,
|
||||||
updateWhenIdle: false,
|
updateWhenIdle: false,
|
||||||
@@ -257,14 +301,49 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
|
|
||||||
// Preload adjacent zoom level tiles
|
// Preload adjacent zoom level tiles
|
||||||
L.tileLayer(tileUrl, {
|
L.tileLayer(tileUrl, {
|
||||||
maxZoom: 8,
|
maxZoom: 10,
|
||||||
keepBuffer: 10,
|
keepBuffer: 10,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
crossOrigin: true,
|
crossOrigin: true,
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Custom pane for region layer — above overlay (z-index 400)
|
||||||
|
map.createPane('regionPane')
|
||||||
|
map.getPane('regionPane')!.style.zIndex = '401'
|
||||||
|
|
||||||
mapInstance.current = map
|
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 }
|
return () => { map.remove(); mapInstance.current = null }
|
||||||
}, [dark, loading])
|
}, [dark, loading])
|
||||||
|
|
||||||
@@ -339,10 +418,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
layer.on('click', () => {
|
layer.on('click', () => {
|
||||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||||
// Manually marked only — show unmark popup
|
|
||||||
handleUnmarkCountry(c.code)
|
handleUnmarkCountry(c.code)
|
||||||
} else {
|
|
||||||
loadCountryDetail(c.code)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
layer.on('mouseover', (e) => {
|
layer.on('mouseover', (e) => {
|
||||||
@@ -379,9 +455,153 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
|
||||||
}, [geoData, data, dark])
|
}, [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 first
|
||||||
|
const visitedRegionCodes = new Set<string>()
|
||||||
|
const visitedRegionNames = new Set<string>()
|
||||||
|
const regionPlaceCounts: Record<string, number> = {}
|
||||||
|
for (const [, regions] of Object.entries(visitedRegions)) {
|
||||||
|
for (const r of regions) {
|
||||||
|
visitedRegionCodes.add(r.code)
|
||||||
|
visitedRegionNames.add(r.name.toLowerCase())
|
||||||
|
regionPlaceCounts[r.code] = r.placeCount
|
||||||
|
regionPlaceCounts[r.name.toLowerCase()] = r.placeCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match feature by ISO code OR region name
|
||||||
|
const isVisitedFeature = (f: any) => {
|
||||||
|
if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true
|
||||||
|
const name = (f.properties?.name || '').toLowerCase()
|
||||||
|
if (visitedRegionNames.has(name)) return true
|
||||||
|
// Fuzzy: check if any visited name is contained in feature name or vice versa
|
||||||
|
for (const vn of visitedRegionNames) {
|
||||||
|
if (name.includes(vn) || vn.includes(name)) 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 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[regionName.toLowerCase()] || 0
|
||||||
|
layer.on('click', () => {
|
||||||
|
if (!countryA2) return
|
||||||
|
if (visited) {
|
||||||
|
const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode)
|
||||||
|
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'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// 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 => {
|
const handleMarkCountry = (code: string, name: string): void => {
|
||||||
setConfirmAction({ type: 'choose', code, name })
|
setConfirmAction({ type: 'choose', code, name })
|
||||||
}
|
}
|
||||||
|
handleMarkCountryRef.current = handleMarkCountry
|
||||||
|
setConfirmActionRef.current = setConfirmAction
|
||||||
|
|
||||||
const handleUnmarkCountry = (code: string): void => {
|
const handleUnmarkCountry = (code: string): void => {
|
||||||
const country = data?.countries.find(c => c.code === code)
|
const country = data?.countries.find(c => c.code === code)
|
||||||
@@ -435,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
setCountryDetail(r.data)
|
setCountryDetail(r.data)
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
loadCountryDetailRef.current = loadCountryDetail
|
||||||
|
|
||||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||||
const countries = data?.countries || []
|
const countries = data?.countries || []
|
||||||
@@ -533,6 +760,18 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 0 }}>
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
|
||||||
|
|
||||||
|
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
|
||||||
|
<div ref={regionTooltipRef} style={{
|
||||||
|
position: 'fixed', display: 'none',
|
||||||
|
zIndex: 9999, pointerEvents: 'none',
|
||||||
|
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
|
||||||
|
color: dark ? '#fff' : '#111',
|
||||||
|
borderRadius: 10, padding: '10px 14px',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
|
||||||
|
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
|
||||||
|
fontSize: 12, minWidth: 120,
|
||||||
|
}} />
|
||||||
<div
|
<div
|
||||||
className="absolute z-20 flex justify-center"
|
className="absolute z-20 flex justify-center"
|
||||||
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
style={{ top: 14, left: 0, right: 0, pointerEvents: 'none' }}
|
||||||
@@ -769,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmAction.type === 'choose-region' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{confirmAction.countryName && (
|
||||||
|
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||||
|
)}
|
||||||
|
<button onClick={async () => {
|
||||||
|
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
|
||||||
|
if (!rCode) return
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
|
||||||
|
setVisitedRegions(prev => {
|
||||||
|
const existing = prev[countryCode] || []
|
||||||
|
if (existing.find(r => r.code === rCode)) return prev
|
||||||
|
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
|
||||||
|
})
|
||||||
|
setData(prev => {
|
||||||
|
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||||
|
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{confirmAction.type === 'unmark' && (
|
{confirmAction.type === 'unmark' && (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||||
@@ -785,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmAction.type === 'unmark-region' && (
|
||||||
|
<>
|
||||||
|
{confirmAction.countryName && (
|
||||||
|
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setConfirmAction(null)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={async () => {
|
||||||
|
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||||
|
if (!rCode) return
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||||
|
setVisitedRegions(prev => {
|
||||||
|
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||||
|
const next = { ...prev, [countryCode]: remaining }
|
||||||
|
if (remaining.length === 0) delete next[countryCode]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// If no manually-marked regions remain, also remove country if it has no trips/places
|
||||||
|
setData(prev => {
|
||||||
|
if (!prev) return prev
|
||||||
|
const c = prev.countries.find(c => c.code === countryCode)
|
||||||
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
|
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||||
|
if (remainingRegions.length > 0) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||||
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {}
|
||||||
|
setConfirmAction(null)
|
||||||
|
}}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||||
|
{t('atlas.unmark')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{confirmAction.type === 'bucket' && (
|
{confirmAction.type === 'bucket' && (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||||
@@ -815,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
||||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -550,6 +550,33 @@ function runMigrations(db: Database.Database): void {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// Migration 69: Place region cache for sub-national Atlas regions
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS place_regions (
|
||||||
|
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
|
||||||
|
country_code TEXT NOT NULL,
|
||||||
|
region_code TEXT NOT NULL,
|
||||||
|
region_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS visited_regions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
region_code TEXT NOT NULL,
|
||||||
|
region_name TEXT NOT NULL,
|
||||||
|
country_code TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, region_code)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code);
|
||||||
|
`);
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
getCountryPlaces,
|
getCountryPlaces,
|
||||||
markCountryVisited,
|
markCountryVisited,
|
||||||
unmarkCountryVisited,
|
unmarkCountryVisited,
|
||||||
|
markRegionVisited,
|
||||||
|
unmarkRegionVisited,
|
||||||
|
getVisitedRegions,
|
||||||
|
getRegionGeo,
|
||||||
listBucketList,
|
listBucketList,
|
||||||
createBucketItem,
|
createBucketItem,
|
||||||
updateBucketItem,
|
updateBucketItem,
|
||||||
@@ -21,6 +25,21 @@ router.get('/stats', async (req: Request, res: Response) => {
|
|||||||
res.json(data);
|
res.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/regions', async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthRequest).user.id;
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store');
|
||||||
|
const data = await getVisitedRegions(userId);
|
||||||
|
res.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/regions/geo', async (req: Request, res: Response) => {
|
||||||
|
const countries = (req.query.countries as string || '').split(',').filter(Boolean);
|
||||||
|
if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] });
|
||||||
|
const geo = await getRegionGeo(countries);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.json(geo);
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/country/:code', (req: Request, res: Response) => {
|
router.get('/country/:code', (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthRequest).user.id;
|
const userId = (req as AuthRequest).user.id;
|
||||||
const code = req.params.code.toUpperCase();
|
const code = req.params.code.toUpperCase();
|
||||||
@@ -39,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/region/:code/mark', (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthRequest).user.id;
|
||||||
|
const { name, country_code } = req.body;
|
||||||
|
if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' });
|
||||||
|
markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase());
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/region/:code/mark', (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthRequest).user.id;
|
||||||
|
unmarkRegionVisited(userId, req.params.code.toUpperCase());
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -2,6 +2,38 @@ import fetch from 'node-fetch';
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { Trip, Place } from '../types';
|
import { Trip, Place } from '../types';
|
||||||
|
|
||||||
|
// ── Admin-1 GeoJSON cache (sub-national regions) ─────────────────────────
|
||||||
|
|
||||||
|
let admin1GeoCache: any = null;
|
||||||
|
let admin1GeoLoading: Promise<any> | null = null;
|
||||||
|
|
||||||
|
async function loadAdmin1Geo(): Promise<any> {
|
||||||
|
if (admin1GeoCache) return admin1GeoCache;
|
||||||
|
if (admin1GeoLoading) return admin1GeoLoading;
|
||||||
|
admin1GeoLoading = fetch(
|
||||||
|
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_admin_1_states_provinces.geojson',
|
||||||
|
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||||
|
).then(r => r.json()).then(geo => {
|
||||||
|
admin1GeoCache = geo;
|
||||||
|
admin1GeoLoading = null;
|
||||||
|
console.log(`[Atlas] Cached admin-1 GeoJSON: ${geo.features?.length || 0} features`);
|
||||||
|
return geo;
|
||||||
|
}).catch(err => {
|
||||||
|
admin1GeoLoading = null;
|
||||||
|
console.error('[Atlas] Failed to load admin-1 GeoJSON:', err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return admin1GeoLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegionGeo(countryCodes: string[]): Promise<any> {
|
||||||
|
const geo = await loadAdmin1Geo();
|
||||||
|
if (!geo) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const codes = new Set(countryCodes.map(c => c.toUpperCase()));
|
||||||
|
const features = geo.features.filter((f: any) => codes.has(f.properties?.iso_a2?.toUpperCase()));
|
||||||
|
return { type: 'FeatureCollection', features };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Geocode cache ───────────────────────────────────────────────────────────
|
// ── Geocode cache ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const geocodeCache = new Map<string, string | null>();
|
const geocodeCache = new Map<string, string | null>();
|
||||||
@@ -339,6 +371,126 @@ export function markCountryVisited(userId: number, code: string): void {
|
|||||||
|
|
||||||
export function unmarkCountryVisited(userId: number, code: string): void {
|
export function unmarkCountryVisited(userId: number, code: string): void {
|
||||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||||
|
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark / unmark region ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC'
|
||||||
|
).all(userId) as { region_code: string; region_name: string; country_code: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode);
|
||||||
|
// Auto-mark parent country if not already visited
|
||||||
|
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||||
|
const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined;
|
||||||
|
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode);
|
||||||
|
if (region) {
|
||||||
|
const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number };
|
||||||
|
if (remaining.count === 0) {
|
||||||
|
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sub-national region resolution ────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RegionInfo { country_code: string; region_code: string; region_name: string }
|
||||||
|
|
||||||
|
const regionCache = new Map<string, RegionInfo | null>();
|
||||||
|
|
||||||
|
async function reverseGeocodeRegion(lat: number, lng: number): Promise<RegionInfo | null> {
|
||||||
|
const key = roundKey(lat, lng);
|
||||||
|
if (regionCache.has(key)) return regionCache.get(key)!;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=8&accept-language=en`,
|
||||||
|
{ headers: { 'User-Agent': 'TREK Travel Planner' } }
|
||||||
|
);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as { address?: Record<string, string> };
|
||||||
|
const countryCode = data.address?.country_code?.toUpperCase() || null;
|
||||||
|
// Try finest ISO level first (lvl6 = departments/provinces), then lvl5, then lvl4 (states/regions)
|
||||||
|
let regionCode = data.address?.['ISO3166-2-lvl6'] || data.address?.['ISO3166-2-lvl5'] || data.address?.['ISO3166-2-lvl4'] || null;
|
||||||
|
// Normalize: FR-75C → FR-75 (strip trailing letter suffixes for GeoJSON compatibility)
|
||||||
|
if (regionCode && /^[A-Z]{2}-\d+[A-Z]$/i.test(regionCode)) {
|
||||||
|
regionCode = regionCode.replace(/[A-Z]$/i, '');
|
||||||
|
}
|
||||||
|
const regionName = data.address?.county || data.address?.state || data.address?.province || data.address?.region || data.address?.city || null;
|
||||||
|
if (!countryCode || !regionName) { regionCache.set(key, null); return null; }
|
||||||
|
const info: RegionInfo = {
|
||||||
|
country_code: countryCode,
|
||||||
|
region_code: regionCode || `${countryCode}-${regionName.substring(0, 3).toUpperCase()}`,
|
||||||
|
region_name: regionName,
|
||||||
|
};
|
||||||
|
regionCache.set(key, info);
|
||||||
|
return info;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVisitedRegions(userId: number): Promise<{ regions: Record<string, { code: string; name: string; placeCount: number }[]> }> {
|
||||||
|
const trips = getUserTrips(userId);
|
||||||
|
const tripIds = trips.map(t => t.id);
|
||||||
|
const places = getPlacesForTrips(tripIds);
|
||||||
|
|
||||||
|
// Check DB cache first
|
||||||
|
const placeIds = places.filter(p => p.lat && p.lng).map(p => p.id);
|
||||||
|
const cached = placeIds.length > 0
|
||||||
|
? db.prepare(`SELECT * FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`).all(...placeIds) as { place_id: number; country_code: string; region_code: string; region_name: string }[]
|
||||||
|
: [];
|
||||||
|
const cachedMap = new Map(cached.map(c => [c.place_id, c]));
|
||||||
|
|
||||||
|
// Resolve uncached places (rate-limited to avoid hammering Nominatim)
|
||||||
|
const uncached = places.filter(p => p.lat && p.lng && !cachedMap.has(p.id));
|
||||||
|
const insertStmt = db.prepare('INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)');
|
||||||
|
|
||||||
|
for (const place of uncached) {
|
||||||
|
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||||
|
if (info) {
|
||||||
|
insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||||
|
cachedMap.set(place.id, { place_id: place.id, ...info });
|
||||||
|
}
|
||||||
|
// Nominatim rate limit: 1 req/sec
|
||||||
|
if (uncached.indexOf(place) < uncached.length - 1) {
|
||||||
|
await new Promise(r => setTimeout(r, 1100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by country → regions with place counts
|
||||||
|
const regionMap: Record<string, Map<string, { code: string; name: string; placeCount: number }>> = {};
|
||||||
|
for (const [, entry] of cachedMap) {
|
||||||
|
if (!regionMap[entry.country_code]) regionMap[entry.country_code] = new Map();
|
||||||
|
const existing = regionMap[entry.country_code].get(entry.region_code);
|
||||||
|
if (existing) {
|
||||||
|
existing.placeCount++;
|
||||||
|
} else {
|
||||||
|
regionMap[entry.country_code].set(entry.region_code, { code: entry.region_code, name: entry.region_name, placeCount: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
|
||||||
|
for (const [country, regions] of Object.entries(regionMap)) {
|
||||||
|
result[country] = [...regions.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge manually marked regions
|
||||||
|
const manualRegions = listManuallyVisitedRegions(userId);
|
||||||
|
for (const r of manualRegions) {
|
||||||
|
if (!result[r.country_code]) result[r.country_code] = [];
|
||||||
|
if (!result[r.country_code].find(x => x.code === r.region_code)) {
|
||||||
|
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regions: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bucket list CRUD ────────────────────────────────────────────────────────
|
// ── Bucket list CRUD ────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user