feat(atlas): show wishlist countries as hatched, stable colors, remove action (#1893)

* feat(atlas): show wishlist countries as hatched, stable colors, remove action

* i18n(atlas): translate the wishlist removal keys into all locales

* feat(atlas): offer the wishlist removal in the mobile country sheet

* docs(atlas): document the wishlist hatch and the removal action

* test(atlas): assert the stable country colour instead of a palette literal

---------

Co-authored-by: Mauro Remondi <mauro.remondi@sourcesense.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
This commit is contained in:
Mauro Remondi
2026-08-12 09:49:38 +02:00
committed by GitHub
parent 27ffd3ec68
commit b0fd73681b
32 changed files with 434 additions and 32 deletions
@@ -0,0 +1,76 @@
// FE-COMP-MATLASPOPUP-001 to FE-COMP-MATLASPOPUP-004
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
import MAtlasCountryPopup from './MAtlasCountryPopup'
import type { AtlasController } from './atlasController'
const bucketItem = (id: number, name: string, countryCode: string | null) => ({
id, name, country_code: countryCode, lat: null, lng: null, notes: null, target_date: null,
})
// Only the slice of the atlas hook this sheet reads. `t` echoes the key back so
// the assertions name the contract, not a locale string.
function makeAtlas(overrides: Record<string, unknown> = {}): AtlasController {
return {
t: (key: string) => key,
confirmAction: { code: 'FR', name: 'France', type: 'choose' },
setConfirmAction: vi.fn(),
executeConfirmAction: vi.fn(),
setData: vi.fn(),
setVisitedRegions: vi.fn(),
setBucketList: vi.fn(),
visitedRegions: {},
bucketList: [],
handleDeleteBucketItem: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as AtlasController
}
describe('MAtlasCountryPopup', () => {
it('FE-COMP-MATLASPOPUP-001: offers the wishlist removal for a country on the bucket list', () => {
const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Paris', 'FR')] })
render(<MAtlasCountryPopup atlas={atlas} />)
expect(screen.getByText('atlas.removeFromBucket')).toBeInTheDocument()
expect(screen.getByText('atlas.removeFromBucketHint')).toBeInTheDocument()
})
it('FE-COMP-MATLASPOPUP-002: hides the removal for a country that is not on the bucket list', () => {
const atlas = makeAtlas({ bucketList: [bucketItem(42, 'Tokyo', 'JP')] })
render(<MAtlasCountryPopup atlas={atlas} />)
expect(screen.getByText('atlas.addToBucket')).toBeInTheDocument()
expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument()
})
it('FE-COMP-MATLASPOPUP-003: removing drops every entry for that country and closes the sheet', async () => {
const handleDeleteBucketItem = vi.fn().mockResolvedValue(undefined)
const setConfirmAction = vi.fn()
const atlas = makeAtlas({
// Two places in France plus one elsewhere, only the French ones go.
bucketList: [bucketItem(1, 'Paris', 'FR'), bucketItem(2, 'Nice', 'FR'), bucketItem(3, 'Tokyo', 'JP')],
handleDeleteBucketItem,
setConfirmAction,
})
render(<MAtlasCountryPopup atlas={atlas} />)
fireEvent.click(screen.getByText('atlas.removeFromBucket'))
await waitFor(() => expect(setConfirmAction).toHaveBeenCalledWith(null))
expect(handleDeleteBucketItem).toHaveBeenCalledTimes(2)
expect(handleDeleteBucketItem).toHaveBeenCalledWith(1)
expect(handleDeleteBucketItem).toHaveBeenCalledWith(2)
})
it('FE-COMP-MATLASPOPUP-004: keeps the removal out of the region variant', () => {
const atlas = makeAtlas({
confirmAction: { code: 'FR', name: 'Corsica', type: 'choose-region', regionCode: 'FR-20R', countryName: 'France' },
bucketList: [bucketItem(42, 'Paris', 'FR')],
})
render(<MAtlasCountryPopup atlas={atlas} />)
expect(screen.getByText('atlas.markVisited')).toBeInTheDocument()
expect(screen.queryByText('atlas.removeFromBucket')).not.toBeInTheDocument()
})
})
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { ChevronRight, MapPin, Star } from 'lucide-react'
import { ChevronRight, MapPin, Star, Trash2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import apiClient from '../../../api/client'
import { continentForCountry } from '@trek/shared'
@@ -11,6 +11,7 @@ import type { AtlasController } from './atlasController'
const markTint = 'bg-[rgba(47,163,122,.16)] text-[color:var(--m-st-confirmed)]' // theme-lint-disable — fixed tint from the design's option rows
const bucketTint = 'bg-[rgba(232,161,58,.16)] text-[color:var(--m-st-pending)]' // theme-lint-disable — fixed tint from the design's option rows
const removeTint = 'bg-[color:color-mix(in_srgb,var(--m-st-danger)_16%,transparent)] text-[color:var(--m-st-danger)]'
const btnBase = 'flex-1 rounded-full py-[11px] text-[0.8125rem] font-bold'
const cancelBtn = `${btnBase} bg-[color:var(--m-ic)] text-m-ink`
@@ -66,6 +67,8 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
setVisitedRegions,
setBucketList,
visitedRegions,
bucketList,
handleDeleteBucketItem,
} = atlas
const toast = useToast()
const [bucketDate, setBucketDate] = useState('')
@@ -155,7 +158,17 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
setConfirmAction(null)
}
// A country can sit on the bucket list more than once (one entry per place),
// so taking it off the wishlist means dropping every entry for that code.
const removeBucket = async (): Promise<void> => {
if (!confirmAction) return
const wishlistItems = bucketList.filter((b) => b.country_code === confirmAction.code)
await Promise.all(wishlistItems.map((item) => handleDeleteBucketItem(item.id)))
setConfirmAction(null)
}
const a = confirmAction
const onWishlist = !!a && bucketList.some((b) => b.country_code === a.code)
return (
<MSheet open={!!a} onClose={() => setConfirmAction(null)} variant="card" ariaLabel={a?.name}>
@@ -190,6 +203,15 @@ export default function MAtlasCountryPopup({ atlas }: MAtlasCountryPopupProps) {
hint={t('atlas.addToBucketHint')}
onClick={() => setConfirmAction({ ...a, type: 'bucket' })}
/>
{onWishlist && (
<OptionRow
icon={Trash2}
tint={removeTint}
title={t('atlas.removeFromBucket')}
hint={t('atlas.removeFromBucketHint')}
onClick={removeBucket}
/>
)}
</>
)}
+105 -1
View File
@@ -10,6 +10,11 @@ import { useAuthStore } from '../store/authStore';
import { useSettingsStore } from '../store/settingsStore';
import AtlasPage from './AtlasPage';
// ── Captured style() results, for tests asserting fill/border on a specific
// feature (e.g. the wishlist hatch pattern) without duplicating the mock's
// internal geoJSON wiring ────────────────────────────────────────────────────
const { capturedStyles } = vi.hoisted(() => ({ capturedStyles: [] as { feature: any; result: any }[] }));
// ── Leaflet mock ──────────────────────────────────────────────────────────────
vi.mock('leaflet', () => {
// Mock layer returned by onEachFeature — supports event registration
@@ -73,7 +78,7 @@ vi.mock('leaflet', () => {
for (const feature of data.features) {
const layer = makeMockLayer();
try {
if (options.style) options.style(feature);
if (options.style) capturedStyles.push({ feature, result: options.style(feature) });
options.onEachFeature(feature, layer);
} catch {
// ignore errors from callbacks in mock
@@ -187,6 +192,7 @@ function useDefaultAtlasHandlers() {
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
capturedStyles.length = 0;
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
@@ -961,6 +967,45 @@ describe('AtlasPage', () => {
});
});
describe('FE-PAGE-ATLAS-049: bucket-list country renders hatched wishlist fill', () => {
it('styles an unvisited bucket-list country with the wishlist hatch, not the flat unvisited gray', async () => {
const geoJsonFRandJP = {
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { ISO_A2: 'FR', ADM0_A3: 'FRA', ISO_A3: 'FRA', NAME: 'France', ADMIN: 'France' }, geometry: null },
{ type: 'Feature', properties: { ISO_A2: 'JP', ADM0_A3: 'JPN', ISO_A3: 'JPN', NAME: 'Japan', ADMIN: 'Japan' }, geometry: null },
],
};
server.use(
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandJP)),
http.get('/api/addons/atlas/bucket-list', () =>
HttpResponse.json({
items: [{ id: 1, name: 'Kyoto', country_code: 'JP', lat: null, lng: null, notes: null, target_date: null }],
}),
),
);
render(<AtlasPage />);
await waitFor(() => {
expect(capturedStyles.some((s) => s.feature.properties.ADM0_A3 === 'JPN')).toBe(true);
});
// FR is visited → keeps the normal solid fill, in its own hash-derived color
// from the palette (stable regardless of visit order or list contents).
const frStyle = capturedStyles.find((s) => s.feature.properties.ADM0_A3 === 'FRA')!.result;
expect(frStyle.fillColor).toBe('#dc2626');
expect(frStyle.dashArray).toBeUndefined();
// JP is on the bucket list and not visited → hatch, in JP's own hash-derived
// color — distinct from FR's, and unaffected by FR being visited.
const jpStyle = capturedStyles.find((s) => s.feature.properties.ADM0_A3 === 'JPN')!.result;
expect(jpStyle.fillColor).toBe('#0ea5e9');
expect(jpStyle.dashArray).toBe('3 2');
expect(jpStyle.fillColor).not.toBe(frStyle.fillColor);
});
});
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
server.use(
@@ -1088,6 +1133,65 @@ describe('AtlasPage', () => {
});
});
describe('FE-PAGE-ATLAS-050: choose popup offers Remove from wishlist for a bucket-list country', () => {
it('shows Remove from wishlist when the searched country is already on the bucket list, and removing it clears the item', async () => {
server.use(
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
http.get('/api/addons/atlas/bucket-list', () =>
HttpResponse.json({
items: [{ id: 42, name: 'Paris', country_code: 'FR', lat: null, lng: null, notes: null, target_date: null }],
}),
),
http.delete('/api/addons/atlas/bucket-list/:id', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
render(<AtlasPage />);
await waitFor(() => screen.getByPlaceholderText(/search a country/i));
const searchInput = screen.getByPlaceholderText(/search a country/i);
await user.type(searchInput, 'fr');
fireEvent.keyDown(searchInput, { key: 'Enter' });
await waitFor(() => {
expect(screen.getByText(/remove from wishlist/i)).toBeInTheDocument();
});
await user.click(screen.getByText(/remove from wishlist/i));
await waitFor(() => {
expect(screen.queryByText(/remove from wishlist/i)).not.toBeInTheDocument();
});
// Bucket List tab no longer shows the removed item
await user.click(screen.getAllByText('Bucket List')[0]);
await waitFor(() => {
expect(screen.queryByText('Paris')).not.toBeInTheDocument();
});
});
it('does not show Remove from wishlist for a country with no bucket-list entry', async () => {
server.use(
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
);
const user = userEvent.setup();
render(<AtlasPage />);
await waitFor(() => screen.getByPlaceholderText(/search a country/i));
const searchInput = screen.getByPlaceholderText(/search a country/i);
await user.type(searchInput, 'fr');
fireEvent.keyDown(searchInput, { key: 'Enter' });
await waitFor(() => {
expect(screen.getByText(/mark as visited/i)).toBeInTheDocument();
});
expect(screen.queryByText(/remove from wishlist/i)).not.toBeInTheDocument();
});
});
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
it('submits a bucket list item from the confirm popup', async () => {
server.use(
+20
View File
@@ -207,6 +207,26 @@ function AtlasPageDesktop(): React.ReactElement {
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
{(() => {
const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code)
if (wishlistItems.length === 0) return null
return (
<button onClick={async () => {
await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id)))
setConfirmAction(null)
}}
className="border border-edge"
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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'}>
<Trash2 size={18} className="text-[#ef4444]" style={{ flexShrink: 0 }} />
<div>
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.removeFromBucket')}</div>
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.removeFromBucketHint')}</div>
</div>
</button>
)
})()}
</div>
)}
+55
View File
@@ -5,7 +5,11 @@ import {
isCountryVisible,
normalizeRegionName,
withCountryMarkedVisited,
wishlistA3Codes,
countryColor,
COUNTRY_COLORS,
type AtlasData,
type BucketItem,
} from './atlasModel';
describe('normalizeRegionName', () => {
@@ -155,3 +159,54 @@ describe('withCountryMarkedVisited', () => {
expect(next.continentsPlanned).toEqual({ Asia: 0 });
});
});
function bucketItem(overrides: Partial<BucketItem>): BucketItem {
return { id: 1, name: 'Somewhere', lat: null, lng: null, country_code: null, notes: null, target_date: null, ...overrides };
}
describe('countryColor', () => {
it('always returns a color from the shared palette', () => {
expect(COUNTRY_COLORS).toContain(countryColor('FRA'));
});
it('is stable for the same code across repeated calls', () => {
expect(countryColor('JPN')).toBe(countryColor('JPN'));
});
it('is independent of how many other codes have been resolved before it (#reshuffle-bug)', () => {
// Regression guard: an earlier order-index-based scheme reassigned every
// country's color whenever one more country was visited/wishlisted.
const before = countryColor('JPN');
countryColor('FRA'); countryColor('DEU'); countryColor('ESP'); countryColor('ITA');
expect(countryColor('JPN')).toBe(before);
});
it('gives different codes different colors in the common case', () => {
expect(countryColor('FRA')).not.toBe(countryColor('JPN'));
});
});
describe('wishlistA3Codes', () => {
it('resolves a bucket-list country to its A3 code', () => {
const result = wishlistA3Codes([bucketItem({ country_code: 'JP' })], new Set());
expect(result).toEqual(new Set(['JPN']));
});
it('excludes a bucket-list country that is already visited', () => {
const result = wishlistA3Codes([bucketItem({ country_code: 'JP' })], new Set(['JPN']));
expect(result.size).toBe(0);
});
it('ignores bucket-list items with no country_code (POI-only entries)', () => {
const result = wishlistA3Codes([bucketItem({ country_code: null, lat: 35.6, lng: 139.7 })], new Set());
expect(result.size).toBe(0);
});
it('dedupes multiple bucket-list items in the same country', () => {
const result = wishlistA3Codes(
[bucketItem({ id: 1, country_code: 'JP' }), bucketItem({ id: 2, country_code: 'JP' })],
new Set(),
);
expect(result).toEqual(new Set(['JPN']));
});
});
+25
View File
@@ -113,6 +113,31 @@ export function normalizeRegionName(name: string): string {
.trim()
}
// Alpha-3 codes for bucket-list countries that aren't visited yet — the Atlas
// map renders these with a hatched fill so they read as "on the wishlist"
// instead of blending into the flat unvisited gray.
export function wishlistA3Codes(bucketList: BucketItem[], visitedA3: Set<string>): Set<string> {
const result = new Set<string>()
for (const item of bucketList) {
const a3 = item.country_code ? A2_TO_A3[item.country_code] : undefined
if (a3 && !visitedA3.has(a3)) result.add(a3)
}
return result
}
// Palette for the Atlas map's country fills (visited: solid, wishlist: hatched).
export const COUNTRY_COLORS = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7', '#10b981', '#0ea5e9', '#e11d48', '#0d9488', '#7c3aed', '#2563eb', '#dc2626', '#059669', '#d946ef']
// Deterministic color for a country code, hashed from the code itself so a country
// keeps the same color forever — regardless of visit order or how many other
// countries are visited/wishlisted. An order/index-based scheme would reshuffle
// every other country's color each time one more was marked or added.
export function countryColor(a3: string): string {
let hash = 0
for (let i = 0; i < a3.length; i++) hash = (hash * 31 + a3.charCodeAt(i)) >>> 0
return COUNTRY_COLORS[hash % COUNTRY_COLORS.length]
}
// Convert country code to flag emoji
export function countryCodeToFlag(code: string): string {
if (!code || code.length !== 2) return ''
+15 -10
View File
@@ -843,24 +843,29 @@ describe('useAtlas', () => {
await waitFor(() => expect(countryLayers().length).toBeGreaterThan(0));
const before = countryLayers().length;
act(() => atlas.togglePlanned());
await waitFor(() => expect(countryLayers().length).toBeGreaterThan(before));
const layers = countryLayers();
const layer = layers[layers.length - 1];
const styleFor = (a2: string) => {
const styleOn = (layer: MockGeoJson, a2: string) => {
const i = (layer.data.features ?? []).findIndex(
(f) => (f.properties as Record<string, string>).ISO_A2 === a2,
);
return layer.styles[i] as { fillOpacity: number; dashArray?: string; fillColor: string };
};
const newestLayer = () => {
const layers = countryLayers();
return layers[layers.length - 1];
};
const frBeforeToggle = styleOn(newestLayer(), 'FR').fillColor;
act(() => atlas.togglePlanned());
await waitFor(() => expect(countryLayers().length).toBeGreaterThan(before));
const styleFor = (a2: string) => styleOn(newestLayer(), a2);
expect(styleFor('FR').dashArray).toBeUndefined();
expect(styleFor('DE').dashArray).toBe('6 4');
// The palette is built from the visited list only, so France keeps the first colour
// whether or not the planned layer is on.
expect(styleFor('FR').fillColor).toBe('#6366f1');
expect(styleFor('DE').fillColor).not.toBe('#6366f1');
// Colours are hashed from the country code, so France keeps the same colour
// whether or not the planned layer is on, and a planned country never takes it.
expect(styleFor('FR').fillColor).toBe(frBeforeToggle);
expect(styleFor('DE').fillColor).not.toBe(styleFor('FR').fillColor);
});
});
});
+65 -20
View File
@@ -5,11 +5,51 @@ import { useSettingsStore } from '../../store/settingsStore'
import apiClient, { mapsApi, pluginsApi, type PluginAtlasLayer } from '../../api/client'
import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
import { A2_TO_A3, countryStatus, isCountryVisible, normalizeRegionName, withCountryMarkedVisited, wishlistA3Codes, countryColor, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, type VisitStatus } from '@trek/shared'
const PLANNED_KEY = 'trek_atlas_show_planned'
function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace('#', '')
const r = parseInt(clean.substring(0, 2), 16)
const g = parseInt(clean.substring(2, 4), 16)
const b = parseInt(clean.substring(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
// Diagonal-stripe CanvasPattern for the wishlist country fill, in that
// country's own "if visited" color — built once per render pass so it
// survives Leaflet's Canvas renderer (ctx.fillStyle accepts a CanvasPattern
// object same as a color string). Returns null in environments without a
// real 2D canvas context (e.g. jsdom in tests), so callers must fall back to
// a plain color.
function createWishlistPattern(color: string, dark: boolean): CanvasPattern | null {
try {
const size = 8
const patternCanvas = document.createElement('canvas')
patternCanvas.width = size
patternCanvas.height = size
const ctx = patternCanvas.getContext('2d')
if (!ctx) return null
ctx.fillStyle = hexToRgba(color, dark ? 0.14 : 0.2)
ctx.fillRect(0, 0, size, size)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(-1, size + 1)
ctx.lineTo(size + 1, -1)
ctx.moveTo(-1, 1)
ctx.lineTo(1, -1)
ctx.moveTo(size - 1, size + 1)
ctx.lineTo(size + 1, size - 1)
ctx.stroke()
return ctx.createPattern(patternCanvas, 'repeat')
} catch {
return null
}
}
function useCountryNames(language: string): (code: string) => string {
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
useEffect(() => {
@@ -312,6 +352,7 @@ export function useAtlas() {
const plannedA3 = new Set(visibleCountries.filter(c => countryStatus(c) !== 'visited').map(c => A2_TO_A3[c.code]).filter(Boolean))
const countryMap = {}
visibleCountries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c })
const wishlistA3 = wishlistA3Codes(bucketList, visitedA3)
// Preserve current map view
const currentCenter = mapInstance.current.getCenter()
@@ -321,15 +362,11 @@ export function useAtlas() {
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.
// Built from the visited countries only — if planned ones took part, flipping the toggle
// would shift every index and reshuffle the colours of the whole map.
const visitedA3List = [...new Set(visitedCountries.map(c => A2_TO_A3[c.code]).filter(Boolean))]
const colorMap = {}
visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] })
const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0]
// Color per country code, hashed from the code itself (countryColor in atlasModel) —
// stable forever, regardless of visit order or how many countries are visited/planned/
// wishlisted. Also used by the region layer below, so both stay in sync.
const colorForCode = countryColor
const wishlistPatternCache = new Map<string, CanvasPattern | null>()
const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 })
@@ -351,6 +388,20 @@ export function useAtlas() {
}
}
const visited = visitedA3.has(a3)
if (!visited && wishlistA3.has(a3)) {
const wishlistColor = colorForCode(a3)
if (!wishlistPatternCache.has(wishlistColor)) {
wishlistPatternCache.set(wishlistColor, createWishlistPattern(wishlistColor, dark))
}
const pattern = wishlistPatternCache.get(wishlistColor)
return {
fillColor: (pattern || wishlistColor) as unknown as string,
fillOpacity: pattern ? 1 : 0.4,
color: wishlistColor,
weight: 1,
dashArray: '3 2',
}
}
return {
fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'),
fillOpacity: visited ? 0.7 : 0.3,
@@ -435,7 +486,7 @@ export function useAtlas() {
// Restore map view after re-render
mapInstance.current.setView(currentCenter, currentZoom, { animate: false })
}, [geoData, data, dark, visibleCountries, visitedCountries])
}, [geoData, data, dark, visibleCountries, visitedCountries, bucketList])
// Render plugin tint layers (atlasLayerProvider hook) — a dashed wash over the
// countries a plugin flagged, in its own non-interactive pane above the country
@@ -546,16 +597,10 @@ export function useAtlas() {
}
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']
// Same source as the country layer's palette (visited only), or the two layers would
// hand the same country different colours.
const countryA3Set = [...new Set(visitedCountries.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
// Same palette as the country layer — countryColor is a pure hash of the code, so
// both layers always agree on a country's color without needing to share state.
const a2ColorMap: Record<string, string> = {}
visitedCountries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] })
visitedCountries.forEach(c => { if (A2_TO_A3[c.code]) a2ColorMap[c.code] = countryColor(A2_TO_A3[c.code]) })
const mergedGeo = { type: 'FeatureCollection', features: allFeatures }
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'الشهر',
'atlas.year': 'السنة',
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
'atlas.removeFromBucket': 'إزالة من قائمة الأمنيات',
'atlas.removeFromBucketHint': 'إزالة هذا البلد من قائمة أمنياتك',
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
'atlas.statsTab': 'الإحصائيات',
'atlas.bucketTab': 'قائمة الأمنيات',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Mês',
'atlas.year': 'Ano',
'atlas.addToBucketHint': 'Salvar como lugar que você quer visitar',
'atlas.removeFromBucket': 'Remover da lista de desejos',
'atlas.removeFromBucketHint': 'Tirar este país da sua lista de desejos',
'atlas.bucketWhen': 'Quando pretende visitar?',
'atlas.statsTab': 'Estatísticas',
'atlas.bucketTab': 'Lista de desejos',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Cerca un país...',
'atlas.month': 'Mes',
'atlas.addToBucketHint': 'Desa-ho com a lloc que vols visitar',
'atlas.removeFromBucket': 'Elimina de la llista de desitjos',
'atlas.removeFromBucketHint': 'Treu aquest país de la teva llista de desitjos',
'atlas.bucketWhen': 'Quan planeges visitar-lo?',
'atlas.planned': 'Planificat',
'atlas.showPlanned': 'Mostra els països planificats',
+2
View File
@@ -17,6 +17,8 @@ const atlas: TranslationStrings = {
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
'atlas.month': 'Měsíc',
'atlas.addToBucketHint': 'Uložit jako místo, které chcete navštívit',
'atlas.removeFromBucket': 'Odebrat ze seznamu přání',
'atlas.removeFromBucketHint': 'Odebrat tuto zemi z vašeho bucket listu',
'atlas.bucketWhen': 'Kdy plánujete návštěvu?',
'atlas.statsTab': 'Statistiky',
'atlas.bucketTab': 'Bucket List',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Monat',
'atlas.year': 'Jahr',
'atlas.addToBucketHint': 'Als Wunschziel speichern',
'atlas.removeFromBucket': 'Von der Wunschliste entfernen',
'atlas.removeFromBucketHint': 'Dieses Land von deiner Bucket List nehmen',
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Wunschliste',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Month',
'atlas.year': 'Year',
'atlas.addToBucketHint': 'Save as a place you want to visit',
'atlas.removeFromBucket': 'Remove from wishlist',
'atlas.removeFromBucketHint': 'Take this country off your bucket list',
'atlas.bucketWhen': 'When do you plan to visit?',
'atlas.statsTab': 'Stats',
'atlas.bucketTab': 'Bucket List',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Buscar un país...',
'atlas.month': 'Mes',
'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar',
'atlas.removeFromBucket': 'Quitar de la lista de deseos',
'atlas.removeFromBucketHint': 'Saca este país de tu lista de deseos',
'atlas.bucketWhen': '¿Cuándo planeas visitarlo?',
'atlas.planned': 'Planeado',
'atlas.showPlanned': 'Mostrar países planeados',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Rechercher un pays…',
'atlas.month': 'Mois',
'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter',
'atlas.removeFromBucket': 'Retirer de la bucket list',
'atlas.removeFromBucketHint': 'Enlever ce pays de votre bucket list',
'atlas.bucketWhen': "Quand prévoyez-vous d'y aller ?",
'atlas.planned': 'Prévu',
'atlas.showPlanned': 'Afficher les pays prévus',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Μήνας',
'atlas.year': 'Έτος',
'atlas.addToBucketHint': 'Αποθηκεύστε ως μέρος που θέλετε να επισκεφθείτε',
'atlas.removeFromBucket': 'Αφαίρεση από τη λίστα επιθυμιών',
'atlas.removeFromBucketHint': 'Αφαιρέστε αυτή τη χώρα από τη λίστα επιθυμιών σας',
'atlas.bucketWhen': 'Πότε σκοπεύετε να το επισκεφθείτε;',
'atlas.statsTab': 'Στατιστικά',
'atlas.bucketTab': 'Λίστα Επιθυμιών',
+2
View File
@@ -18,6 +18,8 @@ const atlas: TranslationStrings = {
'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)',
'atlas.month': 'Hónap',
'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként',
'atlas.removeFromBucket': 'Eltávolítás a bakancslistáról',
'atlas.removeFromBucketHint': 'Vedd le ezt az országot a bakancslistádról',
'atlas.bucketWhen': 'Mikor tervezed meglátogatni?',
'atlas.statsTab': 'Statisztikák',
'atlas.bucketTab': 'Bakancslista',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Bulan',
'atlas.year': 'Tahun',
'atlas.addToBucketHint': 'Simpan sebagai tempat yang ingin dikunjungi',
'atlas.removeFromBucket': 'Hapus dari bucket list',
'atlas.removeFromBucketHint': 'Keluarkan negara ini dari daftar impianmu',
'atlas.bucketWhen': 'Kapan kamu berencana mengunjunginya?',
'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Daftar Impian',
+2
View File
@@ -17,6 +17,8 @@ const atlas: TranslationStrings = {
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
'atlas.month': 'Mese',
'atlas.addToBucketHint': 'Salvalo come luogo che vuoi visitare',
'atlas.removeFromBucket': 'Rimuovi dalla lista desideri',
'atlas.removeFromBucketHint': 'Togli questo paese dalla tua lista desideri',
'atlas.bucketWhen': 'Quando pensi di visitarlo?',
'atlas.statsTab': 'Statistiche',
'atlas.bucketTab': 'Lista desideri',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': '月',
'atlas.year': '年',
'atlas.addToBucketHint': '行きたい場所として保存',
'atlas.removeFromBucket': '行きたいリストから削除',
'atlas.removeFromBucketHint': 'この国を行きたいリストから外します',
'atlas.bucketWhen': '訪問予定はいつですか?',
'atlas.statsTab': '統計',
'atlas.bucketTab': '行きたいリスト',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': '월',
'atlas.year': '연도',
'atlas.addToBucketHint': '방문하고 싶은 장소로 저장',
'atlas.removeFromBucket': '버킷 리스트에서 제거',
'atlas.removeFromBucketHint': '이 나라를 버킷 리스트에서 뺍니다',
'atlas.bucketWhen': '방문 예정 시기는 언제인가요?',
'atlas.statsTab': '통계',
'atlas.bucketTab': '버킷 리스트',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Zoek een land...',
'atlas.month': 'Maand',
'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken',
'atlas.removeFromBucket': 'Uit bucket list verwijderen',
'atlas.removeFromBucketHint': 'Haal dit land van je bucketlist af',
'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?',
'atlas.planned': 'Gepland',
'atlas.showPlanned': 'Geplande landen tonen',
+2
View File
@@ -18,6 +18,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Miesiąc',
'atlas.year': 'Rok',
'atlas.addToBucketHint': 'Zapisz jako miejsce, które chcesz odwiedzić',
'atlas.removeFromBucket': 'Usuń z listy marzeń',
'atlas.removeFromBucketHint': 'Usuń ten kraj ze swojej listy marzeń',
'atlas.bucketWhen': 'Kiedy planujesz je odwiedzić?',
'atlas.statsTab': 'Statystyki',
'atlas.bucketTab': 'Lista marzeń',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Поиск страны...',
'atlas.month': 'Месяц',
'atlas.addToBucketHint': 'Сохранить как место для посещения',
'atlas.removeFromBucket': 'Убрать из списка желаний',
'atlas.removeFromBucketHint': 'Удалить эту страну из списка желаний',
'atlas.bucketWhen': 'Когда вы планируете поехать?',
'atlas.planned': 'Запланировано',
'atlas.showPlanned': 'Показать запланированные страны',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Månad',
'atlas.year': 'År',
'atlas.addToBucketHint': 'Spara som en plats du vill besöka',
'atlas.removeFromBucket': 'Ta bort från bucketlistan',
'atlas.removeFromBucketHint': 'Ta bort det här landet från din bucketlista',
'atlas.bucketWhen': 'När har du tänkt att komma på besök?',
'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Bucketlista',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Ay',
'atlas.year': 'Yıl',
'atlas.addToBucketHint': 'Ziyaret etmek istediğiniz yer olarak kaydedin',
'atlas.removeFromBucket': 'Yapılacaklar listesinden kaldır',
'atlas.removeFromBucketHint': 'Bu ülkeyi yapılacaklar listenizden çıkarın',
'atlas.bucketWhen': 'Ne zaman ziyaret etmeyi planlıyorsunuz?',
'atlas.statsTab': 'İstatistikler',
'atlas.bucketTab': 'Yapılacaklar Listesi',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': 'Пошук країни...',
'atlas.month': 'Місяць',
'atlas.addToBucketHint': 'Зберегти як місце для відвідування',
'atlas.removeFromBucket': 'Видалити зі списку бажань',
'atlas.removeFromBucketHint': 'Прибрати цю країну зі списку бажань',
'atlas.bucketWhen': 'Коли ви плануєте поїхати?',
'atlas.planned': 'Заплановано',
'atlas.showPlanned': 'Показати заплановані країни',
+2
View File
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
'atlas.month': 'Tháng',
'atlas.year': 'Năm',
'atlas.addToBucketHint': 'Lưu làm địa điểm bạn muốn ghé thăm',
'atlas.removeFromBucket': 'Xóa khỏi danh sách nhóm',
'atlas.removeFromBucketHint': 'Bỏ quốc gia này khỏi danh sách nhóm của bạn',
'atlas.bucketWhen': 'Khi nào bạn có kế hoạch đến thăm?',
'atlas.statsTab': 'Thống kê',
'atlas.bucketTab': 'Danh sách nhóm',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': '搜尋國家...',
'atlas.month': '月份',
'atlas.addToBucketHint': '儲存為想去的地方',
'atlas.removeFromBucket': '從心願單移除',
'atlas.removeFromBucketHint': '將此國家從心願單中移除',
'atlas.bucketWhen': '你計劃什麼時候去?',
'atlas.planned': '計劃中',
'atlas.showPlanned': '顯示計劃中的國家',
+2
View File
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
'atlas.searchCountry': '搜索国家...',
'atlas.month': '月份',
'atlas.addToBucketHint': '保存为想去的地方',
'atlas.removeFromBucket': '从心愿单移除',
'atlas.removeFromBucketHint': '将此国家从心愿单中移除',
'atlas.bucketWhen': '你计划什么时候去?',
'atlas.planned': '计划中',
'atlas.showPlanned': '显示计划中的国家',
+4
View File
@@ -22,12 +22,16 @@ Planned countries are hidden from the map by default. Use the **Show planned cou
Countries you mark by hand always count as visited, whatever the dates of any trip going there.
Countries on your bucket list that you have not been to yet are drawn with a diagonal hatch, in the colour that country would carry once you get there. That keeps a wishlist entry easy to tell apart from a visited country and from the plain grey of everywhere else. Every country keeps its colour permanently: marking one more country as visited never reshuffles the rest of the map.
## Marking countries as visited
Click any country on the map to open an action popup where you can mark it as visited or add it to your bucket list. Use the search bar at the top of the map to find and fly to a country — pressing Enter or selecting a result from the dropdown opens the same action popup.
To remove a manually-marked country (one with no trips or places recorded in it), click it on the map and confirm removal in the popup.
If the country is already on your bucket list, the same popup offers **Remove from wishlist**, which takes every entry for that country off the list without the detour through the Bucket List tab.
Visits detected automatically from your trips are shown in addition to any countries you mark manually.
### Sub-national regions