mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.month': 'الشهر',
|
||||
'atlas.year': 'السنة',
|
||||
'atlas.addToBucketHint': 'حفظ كمكان تريد زيارته',
|
||||
'atlas.removeFromBucket': 'إزالة من قائمة الأمنيات',
|
||||
'atlas.removeFromBucketHint': 'إزالة هذا البلد من قائمة أمنياتك',
|
||||
'atlas.bucketWhen': 'متى تخطط للزيارة؟',
|
||||
'atlas.statsTab': 'الإحصائيات',
|
||||
'atlas.bucketTab': 'قائمة الأمنيات',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.month': 'Μήνας',
|
||||
'atlas.year': 'Έτος',
|
||||
'atlas.addToBucketHint': 'Αποθηκεύστε ως μέρος που θέλετε να επισκεφθείτε',
|
||||
'atlas.removeFromBucket': 'Αφαίρεση από τη λίστα επιθυμιών',
|
||||
'atlas.removeFromBucketHint': 'Αφαιρέστε αυτή τη χώρα από τη λίστα επιθυμιών σας',
|
||||
'atlas.bucketWhen': 'Πότε σκοπεύετε να το επισκεφθείτε;',
|
||||
'atlas.statsTab': 'Στατιστικά',
|
||||
'atlas.bucketTab': 'Λίστα Επιθυμιών',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.month': '月',
|
||||
'atlas.year': '年',
|
||||
'atlas.addToBucketHint': '行きたい場所として保存',
|
||||
'atlas.removeFromBucket': '行きたいリストから削除',
|
||||
'atlas.removeFromBucketHint': 'この国を行きたいリストから外します',
|
||||
'atlas.bucketWhen': '訪問予定はいつですか?',
|
||||
'atlas.statsTab': '統計',
|
||||
'atlas.bucketTab': '行きたいリスト',
|
||||
|
||||
@@ -19,6 +19,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.month': '월',
|
||||
'atlas.year': '연도',
|
||||
'atlas.addToBucketHint': '방문하고 싶은 장소로 저장',
|
||||
'atlas.removeFromBucket': '버킷 리스트에서 제거',
|
||||
'atlas.removeFromBucketHint': '이 나라를 버킷 리스트에서 뺍니다',
|
||||
'atlas.bucketWhen': '방문 예정 시기는 언제인가요?',
|
||||
'atlas.statsTab': '통계',
|
||||
'atlas.bucketTab': '버킷 리스트',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ń',
|
||||
|
||||
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.searchCountry': 'Поиск страны...',
|
||||
'atlas.month': 'Месяц',
|
||||
'atlas.addToBucketHint': 'Сохранить как место для посещения',
|
||||
'atlas.removeFromBucket': 'Убрать из списка желаний',
|
||||
'atlas.removeFromBucketHint': 'Удалить эту страну из списка желаний',
|
||||
'atlas.bucketWhen': 'Когда вы планируете поехать?',
|
||||
'atlas.planned': 'Запланировано',
|
||||
'atlas.showPlanned': 'Показать запланированные страны',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.searchCountry': 'Пошук країни...',
|
||||
'atlas.month': 'Місяць',
|
||||
'atlas.addToBucketHint': 'Зберегти як місце для відвідування',
|
||||
'atlas.removeFromBucket': 'Видалити зі списку бажань',
|
||||
'atlas.removeFromBucketHint': 'Прибрати цю країну зі списку бажань',
|
||||
'atlas.bucketWhen': 'Коли ви плануєте поїхати?',
|
||||
'atlas.planned': 'Заплановано',
|
||||
'atlas.showPlanned': 'Показати заплановані країни',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.searchCountry': '搜尋國家...',
|
||||
'atlas.month': '月份',
|
||||
'atlas.addToBucketHint': '儲存為想去的地方',
|
||||
'atlas.removeFromBucket': '從心願單移除',
|
||||
'atlas.removeFromBucketHint': '將此國家從心願單中移除',
|
||||
'atlas.bucketWhen': '你計劃什麼時候去?',
|
||||
'atlas.planned': '計劃中',
|
||||
'atlas.showPlanned': '顯示計劃中的國家',
|
||||
|
||||
@@ -53,6 +53,8 @@ const atlas: TranslationStrings = {
|
||||
'atlas.searchCountry': '搜索国家...',
|
||||
'atlas.month': '月份',
|
||||
'atlas.addToBucketHint': '保存为想去的地方',
|
||||
'atlas.removeFromBucket': '从心愿单移除',
|
||||
'atlas.removeFromBucketHint': '将此国家从心愿单中移除',
|
||||
'atlas.bucketWhen': '你计划什么时候去?',
|
||||
'atlas.planned': '计划中',
|
||||
'atlas.showPlanned': '显示计划中的国家',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user