Compare commits
5 Commits
feature/ti
...
feature/wo
| Author | SHA1 | Date | |
|---|---|---|---|
| 36f0c25721 | |||
| d09946161f | |||
| 87993ae9c6 | |||
| 65248fd082 | |||
| e9662754c4 |
BIN
public/airplane.png
Normal file
BIN
public/airplane.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -13,6 +13,7 @@
|
|||||||
let journeyProgress = $state(null);
|
let journeyProgress = $state(null);
|
||||||
let inDetail = $state(false);
|
let inDetail = $state(false);
|
||||||
let pendingCountry = $state('');
|
let pendingCountry = $state('');
|
||||||
|
let journeyMode = $state('map');
|
||||||
|
|
||||||
function onNavigate(s) {
|
function onNavigate(s) {
|
||||||
screen = s;
|
screen = s;
|
||||||
@@ -56,10 +57,10 @@
|
|||||||
<div class="worldmap-page">
|
<div class="worldmap-page">
|
||||||
<div class="map-area">
|
<div class="map-area">
|
||||||
{#if journeyActive}
|
{#if journeyActive}
|
||||||
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} />
|
<JourneyView onclose={endJourney} onprogress={onJourneyProgress} mode={journeyMode} onmodechange={(m) => journeyMode = m} />
|
||||||
{:else}
|
{:else}
|
||||||
<WorldMap onCountryClick={handleCountryClick} />
|
<WorldMap onCountryClick={handleCountryClick} />
|
||||||
<button class="journey-play-btn" onclick={startJourney}>▶</button>
|
<button class="journey-play-btn" onclick={startJourney}>▶ Replay My Trips</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<StatsPanel />
|
<StatsPanel />
|
||||||
@@ -115,14 +116,14 @@
|
|||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 44px;
|
padding: 12px 28px;
|
||||||
height: 44px;
|
border-radius: 24px;
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: #8b5cf6;
|
background: #8b5cf6;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 20px;
|
font-size: 15px;
|
||||||
line-height: 1;
|
font-weight: 600;
|
||||||
|
gap: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
class="slider"
|
class="slider"
|
||||||
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
|
style="transform: translateX({screen === 'worldmap' ? 0 : 100}%);"
|
||||||
></div>
|
></div>
|
||||||
<button onclick={() => onNavigate('worldmap')}>Worldmap</button>
|
<button class:active={screen === 'worldmap'} onclick={() => onNavigate('worldmap')}>Worldmap</button>
|
||||||
<button onclick={() => onNavigate('timeline')}>Timeline</button>
|
<button class:active={screen === 'timeline'} onclick={() => onNavigate('timeline')}>Timeline</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,18 +103,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 9999px;
|
||||||
padding: 3px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 3px;
|
top: 4px;
|
||||||
left: 3px;
|
left: 4px;
|
||||||
width: calc(50% - 3px);
|
width: calc(50% - 4px);
|
||||||
height: calc(100% - 6px);
|
height: calc(100% - 8px);
|
||||||
background: var(--bg);
|
background: var(--accent);
|
||||||
border-radius: 6px;
|
border-radius: 9999px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -124,15 +124,20 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 4px 18px;
|
padding: 6px 24px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented button.active {
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.right {
|
.right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
209
src/lib/shared/cities.js
Normal file
209
src/lib/shared/cities.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
export const ALL_CITIES = {
|
||||||
|
'Afghanistan': ['Kabul', 'Herat', 'Kandahar', 'Mazar-i-Sharif', 'Jalalabad'],
|
||||||
|
'Albania': ['Tirana', 'Durrës', 'Vlorë', 'Shkodër', 'Sarandë'],
|
||||||
|
'Algeria': ['Algiers', 'Oran', 'Constantine', 'Annaba', 'Tlemcen'],
|
||||||
|
'Angola': ['Luanda', 'Huambo', 'Benguela', 'Lubango', 'Malanje'],
|
||||||
|
'Argentina': ['Buenos Aires', 'Córdoba', 'Rosario', 'Mendoza', 'Bariloche', 'Salta', 'Ushuaia', 'Mar del Plata', 'Iguazú'],
|
||||||
|
'Armenia': ['Yerevan', 'Gyumri', 'Vanadzor', 'Vagharshapat'],
|
||||||
|
'Australia': ['Sydney', 'Melbourne', 'Brisbane', 'Perth', 'Adelaide', 'Gold Coast', 'Cairns', 'Hobart', 'Darwin', 'Canberra', 'Newcastle'],
|
||||||
|
'Austria': ['Vienna', 'Salzburg', 'Innsbruck', 'Graz', 'Linz', 'Hallstatt', 'Zell am See'],
|
||||||
|
'Azerbaijan': ['Baku', 'Ganja', 'Sumqayit', 'Mingachevir', 'Nakhchivan'],
|
||||||
|
'Bahamas': ['Nassau', 'Freeport', 'Marsh Harbour', 'George Town'],
|
||||||
|
'Bahrain': ['Manama', 'Muharraq', 'Riffa', 'Hamad Town'],
|
||||||
|
'Bangladesh': ['Dhaka', 'Chittagong', 'Sylhet', 'Cox\'s Bazar', 'Rajshahi', 'Khulna'],
|
||||||
|
'Barbados': ['Bridgetown', 'Speightstown', 'Oistins', 'Holetown'],
|
||||||
|
'Belarus': ['Minsk', 'Brest', 'Grodno', 'Vitebsk', 'Gomel'],
|
||||||
|
'Belgium': ['Brussels', 'Antwerp', 'Ghent', 'Bruges', 'Leuven', 'Liège', 'Namur'],
|
||||||
|
'Belize': ['Belize City', 'San Ignacio', 'Belmopan', 'Placencia', 'Caye Caulker'],
|
||||||
|
'Benin': ['Porto-Novo', 'Cotonou', 'Parakou', 'Abomey'],
|
||||||
|
'Bhutan': ['Thimphu', 'Paro', 'Punakha', 'Jakar'],
|
||||||
|
'Bolivia': ['La Paz', 'Sucre', 'Santa Cruz', 'Cochabamba', 'Uyuni', 'Potosí'],
|
||||||
|
'Bosnia and Herz.': ['Sarajevo', 'Mostar', 'Banja Luka', 'Tuzla', 'Zenica'],
|
||||||
|
'Botswana': ['Gaborone', 'Maun', 'Kasane', 'Francistown'],
|
||||||
|
'Brazil': ['Rio de Janeiro', 'São Paulo', 'Brasília', 'Salvador', 'Fortaleza', 'Recife', 'Porto Alegre', 'Curitiba', 'Manaus', 'Florianópolis', 'Belo Horizonte', 'Iguaçu Falls', 'Paraty', 'Bonito'],
|
||||||
|
'Brunei': ['Bandar Seri Begawan', 'Kuala Belait', 'Seria', 'Tutong'],
|
||||||
|
'Bulgaria': ['Sofia', 'Plovdiv', 'Varna', 'Burgas', 'Ruse', 'Bansko'],
|
||||||
|
'Burkina Faso': ['Ouagadougou', 'Bobo-Dioulasso', 'Koudougou', 'Banfora'],
|
||||||
|
'Burundi': ['Bujumbura', 'Gitega', 'Ngozi', 'Ruyigi'],
|
||||||
|
'Cabo Verde': ['Praia', 'Mindelo', 'Santa Maria', 'Sal Rei'],
|
||||||
|
'Cambodia': ['Phnom Penh', 'Siem Reap', 'Sihanoukville', 'Battambang', 'Kampot'],
|
||||||
|
'Cameroon': ['Yaoundé', 'Douala', 'Bamenda', 'Garoua', 'Kribi'],
|
||||||
|
'Canada': ['Toronto', 'Vancouver', 'Montreal', 'Calgary', 'Ottawa', 'Quebec City', 'Halifax', 'Whistler', 'Banff', 'Victoria', 'Edmonton'],
|
||||||
|
'Chad': ['N\'Djamena', 'Moundou', 'Sarh', 'Abéché'],
|
||||||
|
'Chile': ['Santiago', 'Valparaíso', 'Viña del Mar', 'Puerto Varas', 'San Pedro de Atacama', 'Punta Arenas', 'Easter Island (Rapa Nui)', 'Concepción', 'La Serena'],
|
||||||
|
'China': ['Beijing', 'Shanghai', 'Guangzhou', 'Shenzhen', 'Chengdu', 'Hangzhou', 'Xi\'an', 'Kunming', 'Zhangjiajie', 'Guilin', 'Hong Kong', 'Macao', 'Lhasa', 'Suzhou', 'Nanjing', 'Chongqing', 'Wuhan', 'Harbin'],
|
||||||
|
'Colombia': ['Bogotá', 'Medellín', 'Cartagena', 'Cali', 'Santa Marta', 'Bucaramanga', 'San Andrés', 'Leticia', 'Tayrona'],
|
||||||
|
'Congo': ['Brazzaville', 'Pointe-Noire', 'Dolisie', 'Ouésso'],
|
||||||
|
'Costa Rica': ['San José', 'Liberia', 'Puerto Viejo', 'La Fortuna', 'Monteverde', 'Manuel Antonio', 'Tamarindo'],
|
||||||
|
'Croatia': ['Zagreb', 'Dubrovnik', 'Split', 'Zadar', 'Rovinj', 'Pula', 'Hvar', 'Šibenik', 'Trogir'],
|
||||||
|
'Cuba': ['Havana', 'Varadero', 'Trinidad', 'Viñales', 'Santiago de Cuba', 'Cienfuegos'],
|
||||||
|
'Curaçao': ['Willemstad', 'Westpunt', 'Sint Willibrordus'],
|
||||||
|
'Cyprus': ['Nicosia', 'Limassol', 'Paphos', 'Larnaca', 'Ayia Napa'],
|
||||||
|
'Czechia': ['Prague', 'Brno', 'Český Krumlov', 'Karlovy Vary', 'Plzeň', 'Olomouc', 'Ostrava', 'Liberec'],
|
||||||
|
'Dem. Rep. Congo': ['Kinshasa', 'Lubumbashi', 'Goma', 'Bukavu', 'Kisangani'],
|
||||||
|
'Denmark': ['Copenhagen', 'Aarhus', 'Odense', 'Aalborg', 'Ribe', 'Skagen', 'Bornholm', 'Møns Klint'],
|
||||||
|
'Djibouti': ['Djibouti City', 'Tadjoura', 'Obock', 'Ali Sabieh'],
|
||||||
|
'Dominican Rep.': ['Santo Domingo', 'Punta Cana', 'Puerto Plata', 'La Romana', 'Samaná', 'Sosúa'],
|
||||||
|
'Ecuador': ['Quito', 'Guayaquil', 'Cuenca', 'Baños', 'Galápagos Islands', 'Otavalo', 'Montañita'],
|
||||||
|
'Egypt': ['Cairo', 'Alexandria', 'Luxor', 'Aswan', 'Hurghada', 'Sharm el-Sheikh', 'Giza', 'Dahab'],
|
||||||
|
'El Salvador': ['San Salvador', 'Santa Ana', 'San Miguel', 'La Libertad', 'Suchitoto'],
|
||||||
|
'Eq. Guinea': ['Malabo', 'Bata', 'Ebebiyín'],
|
||||||
|
'Eritrea': ['Asmara', 'Massawa', 'Keren', 'Assab'],
|
||||||
|
'Estonia': ['Tallinn', 'Tartu', 'Pärnu', 'Kuressaare', 'Narva'],
|
||||||
|
'Eswatini': ['Mbabane', 'Manzini', 'Big Bend', 'Mhlume'],
|
||||||
|
'Ethiopia': ['Addis Ababa', 'Lalibela', 'Gondar', 'Axum', 'Bahir Dar', 'Harar'],
|
||||||
|
'Faeroe Is.': ['Tórshavn', 'Klaksvík', 'Runavík', 'Vestmanna'],
|
||||||
|
'Fiji': ['Suva', 'Nadi', 'Lautoka', 'Denarau', 'Coral Coast'],
|
||||||
|
'Finland': ['Helsinki', 'Rovaniemi', 'Tampere', 'Turku', 'Levi', 'Savonlinna', 'Porvoo'],
|
||||||
|
'France': ['Paris', 'Nice', 'Marseille', 'Lyon', 'Bordeaux', 'Toulouse', 'Strasbourg', 'Lille', 'Montpellier', 'Avignon', 'Arles', 'Cannes', 'Saint-Tropez', 'Annecy', 'Chamonix', 'Biarritz', 'Colmar'],
|
||||||
|
'Gabon': ['Libreville', 'Port-Gentil', 'Franceville', 'Oyem'],
|
||||||
|
'Gambia': ['Banjul', 'Serrekunda', 'Brikama', 'Bakau'],
|
||||||
|
'Georgia': ['Tbilisi', 'Batumi', 'Kutaisi', 'Stepantsminda', 'Sighnaghi', 'Telavi', 'Mestia'],
|
||||||
|
'Germany': ['Berlin', 'Munich', 'Hamburg', 'Frankfurt', 'Cologne', 'Stuttgart', 'Düsseldorf', 'Dresden', 'Leipzig', 'Nuremberg', 'Heidelberg', 'Freiburg', 'Hannover', 'Bremen', 'Bonn', 'Rothenburg ob der Tauber', 'Neuschwanstein'],
|
||||||
|
'Ghana': ['Accra', 'Kumasi', 'Cape Coast', 'Tamale', 'Elmina', 'Takoradi'],
|
||||||
|
'Greece': ['Athens', 'Santorini', 'Mykonos', 'Crete', 'Thessaloniki', 'Corfu', 'Rhodes', 'Naxos', 'Paros', 'Milos', 'Delphi', 'Meteora', 'Olympia', 'Zakynthos'],
|
||||||
|
'Greenland': ['Nuuk', 'Ilulissat', 'Kangerlussuaq', 'Sisimiut'],
|
||||||
|
'Grenada': ['St. George\'s', 'Gouyave', 'Grenville', 'Sauteurs'],
|
||||||
|
'Guatemala': ['Guatemala City', 'Antigua', 'Lake Atitlán', 'Flores', 'Chichicastenango', 'Quetzaltenango', 'Semuc Champey'],
|
||||||
|
'Guinea': ['Conakry', 'Kindia', 'Kankan', 'N\'Zérékoré', 'Labé'],
|
||||||
|
'Guinea-Bissau': ['Bissau', 'Bafatá', 'Gabú', 'Cacheu'],
|
||||||
|
'Guyana': ['Georgetown', 'Linden', 'New Amsterdam', 'Bartica'],
|
||||||
|
'Haiti': ['Port-au-Prince', 'Cap-Haïtien', 'Jacmel', 'Les Cayes', 'Gonaïves'],
|
||||||
|
'Honduras': ['Tegucigalpa', 'San Pedro Sula', 'La Ceiba', 'Roatán', 'Copán Ruinas'],
|
||||||
|
'Hungary': ['Budapest', 'Debrecen', 'Szeged', 'Pécs', 'Eger', 'Siófok (Lake Balaton)', 'Visegrád', 'Hévíz'],
|
||||||
|
'Iceland': ['Reykjavík', 'Akureyri', 'Vík', 'Höfn', 'Ísafjörður', 'Blue Lagoon', 'Thingvellir'],
|
||||||
|
'India': ['Mumbai', 'Delhi', 'Jaipur', 'Agra', 'Varanasi', 'Goa', 'Kerala', 'Bangalore', 'Chennai', 'Kolkata', 'Hyderabad', 'Udaipur', 'Jaisalmer', 'Rishikesh', 'Darjeeling', 'Amritsar', 'Leh', 'Hampi', 'Mysore', 'Pondicherry'],
|
||||||
|
'Indonesia': ['Bali (Denpasar)', 'Jakarta', 'Yogyakarta', 'Lombok', 'Komodo', 'Surabaya', 'Bandung', 'Medan', 'Makassar', 'Labuan Bajo', 'Raja Ampat', 'Gili Islands'],
|
||||||
|
'Iran': ['Tehran', 'Isfahan', 'Shiraz', 'Mashhad', 'Tabriz', 'Yazd', 'Kashan'],
|
||||||
|
'Iraq': ['Baghdad', 'Erbil', 'Basra', 'Najaf', 'Karbala', 'Sulaymaniyah'],
|
||||||
|
'Ireland': ['Dublin', 'Galway', 'Cork', 'Killarney', 'Dingle', 'Cliffs of Moher', 'Kilkenny', 'Belfast (NI)', 'Ring of Kerry'],
|
||||||
|
'Israel': ['Tel Aviv', 'Jerusalem', 'Haifa', 'Eilat', 'Dead Sea', 'Nazareth', 'Tiberias', 'Caesarea', 'Akko'],
|
||||||
|
'Italy': ['Rome', 'Florence', 'Venice', 'Milan', 'Naples', 'Cinque Terre', 'Amalfi Coast', 'Positano', 'Capri', 'Verona', 'Bologna', 'Turin', 'Siena', 'Lake Como', 'Pisa', 'Palermo', 'Catania', 'Matera', 'Tuscany', 'Dolomites'],
|
||||||
|
'Jamaica': ['Kingston', 'Montego Bay', 'Negril', 'Ocho Rios', 'Port Antonio'],
|
||||||
|
'Japan': ['Tokyo', 'Osaka', 'Kyoto', 'Sapporo', 'Fukuoka', 'Hiroshima', 'Nara', 'Kanazawa', 'Nagoya', 'Yokohama', 'Kobe', 'Hakone', 'Nikko', 'Miyajima', 'Takayama', 'Okinawa', 'Kamakura', 'Fuji Five Lakes'],
|
||||||
|
'Jordan': ['Amman', 'Petra', 'Wadi Rum', 'Dead Sea', 'Aqaba', 'Madaba', 'Jerash'],
|
||||||
|
'Kazakhstan': ['Almaty', 'Nur-Sultan', 'Shymkent', 'Aktau', 'Karaganda'],
|
||||||
|
'Kenya': ['Nairobi', 'Mombasa', 'Masai Mara', 'Diani Beach', 'Amboseli', 'Lake Nakuru', 'Tsavo', 'Nanyuki'],
|
||||||
|
'Kiribati': ['Tarawa', 'Kiritimati (Christmas Island)'],
|
||||||
|
'Kosovo': ['Pristina', 'Prizren', 'Peja', 'Gjakova', 'Mitrovica'],
|
||||||
|
'Kuwait': ['Kuwait City', 'Salmiya', 'Hawally', 'Ahmadi', 'Jahra'],
|
||||||
|
'Kyrgyzstan': ['Bishkek', 'Osh', 'Karakol', 'Jalal-Abad', 'Talas'],
|
||||||
|
'Laos': ['Vientiane', 'Luang Prabang', 'Vang Vieng', 'Pakse', 'Savannakhet', 'Si Phan Don (4000 Islands)'],
|
||||||
|
'Latvia': ['Riga', 'Jūrmala', 'Liepāja', 'Cēsis', 'Sigulda', 'Daugavpils'],
|
||||||
|
'Lebanon': ['Beirut', 'Byblos', 'Baalbek', 'Tripoli', 'Sidon', 'Tyre', 'Jounieh'],
|
||||||
|
'Lesotho': ['Maseru', 'Teyateyaneng', 'Mafeteng', 'Hlotse'],
|
||||||
|
'Liberia': ['Monrovia', 'Buchanan', 'Ganta', 'Harper', 'Robertsport'],
|
||||||
|
'Libya': ['Tripoli', 'Benghazi', 'Misrata', 'Sabratha', 'Leptis Magna'],
|
||||||
|
'Liechtenstein': ['Vaduz', 'Schaan', 'Balzers', 'Triesenberg'],
|
||||||
|
'Lithuania': ['Vilnius', 'Kaunas', 'Klaipėda', 'Šiauliai', 'Trakai', 'Palanga', 'Nida'],
|
||||||
|
'Luxembourg': ['Luxembourg City', 'Echternach', 'Vianden', 'Ettelbruck'],
|
||||||
|
'Madagascar': ['Antananarivo', 'Nosy Be', 'Morondava', 'Fianarantsoa', 'Isalo', 'Tôlanaro'],
|
||||||
|
'Malawi': ['Lilongwe', 'Blantyre', 'Mzuzu', 'Lake Malawi', 'Zomba'],
|
||||||
|
'Malaysia': ['Kuala Lumpur', 'Penang (George Town)', 'Langkawi', 'Borneo (Kota Kinabalu)', 'Malacca', 'Cameron Highlands', 'Johor Bahru', 'Kuching', 'Sipadan Island'],
|
||||||
|
'Maldives': ['Malé', 'Ari Atoll', 'Baa Atoll', 'South Male Atoll', 'Addu City'],
|
||||||
|
'Mali': ['Bamako', 'Timbuktu', 'Ségou', 'Mopti', 'Djenné'],
|
||||||
|
'Malta': ['Valletta', 'Sliema', 'Gozo', 'Mellieħa', 'Mdina', 'St. Julian\'s', 'Comino'],
|
||||||
|
'Marshall Is.': ['Majuro', 'Kwajalein', 'Ebeye'],
|
||||||
|
'Mauritania': ['Nouakchott', 'Nouadhibou', 'Atar', 'Chinguetti', 'Ouadane'],
|
||||||
|
'Mauritius': ['Port Louis', 'Grand Baie', 'Flic en Flac', 'Belle Mare', 'Le Morne', 'Chamarel'],
|
||||||
|
'Mexico': ['Mexico City', 'Cancún', 'Playa del Carmen', 'Tulum', 'Guadalajara', 'Monterrey', 'Puerto Vallarta', 'Oaxaca', 'San Miguel de Allende', 'Mérida', 'Cabo San Lucas', 'Guanajuato', 'Chichen Itza', 'Palenque', 'Cuernavaca', 'Puebla'],
|
||||||
|
'Micronesia': ['Palikir', 'Chuuk', 'Pohnpei', 'Yap', 'Kosrae'],
|
||||||
|
'Moldova': ['Chișinău', 'Bălți', 'Tiraspol', 'Cahul', 'Orhei'],
|
||||||
|
'Monaco': ['Monaco City', 'Monte Carlo', 'La Condamine', 'Fontvieille'],
|
||||||
|
'Mongolia': ['Ulaanbaatar', 'Karakorum', 'Gobi Desert', 'Lake Khövsgöl', 'Altai', 'Erdenet'],
|
||||||
|
'Montenegro': ['Podgorica', 'Kotor', 'Budva', 'Bar', 'Ulcinj', 'Žabljak', 'Perast'],
|
||||||
|
'Morocco': ['Marrakech', 'Fes', 'Casablanca', 'Rabat', 'Tangier', 'Chefchaouen', 'Essaouira', 'Ouarzazate', 'Agadir', 'Meknes', 'Merzouga (Sahara)'],
|
||||||
|
'Mozambique': ['Maputo', 'Beira', 'Tofo (Inhambane)', 'Vilankulo', 'Bazaruto Archipelago', 'Nampula'],
|
||||||
|
'Myanmar': ['Yangon', 'Mandalay', 'Bagan', 'Inle Lake', 'Hpa-An', 'Ngapali Beach'],
|
||||||
|
'Namibia': ['Windhoek', 'Swakopmund', 'Sossusvlei', 'Etosha National Park', 'Fish River Canyon', 'Walvis Bay'],
|
||||||
|
'Nauru': ['Yaren', 'Boe', 'Aiwo'],
|
||||||
|
'Nepal': ['Kathmandu', 'Pokhara', 'Chitwan', 'Lumbini', 'Everest Base Camp', 'Nagarkot', 'Bandipur'],
|
||||||
|
'Netherlands': ['Amsterdam', 'Rotterdam', 'The Hague', 'Utrecht', 'Maastricht', 'Groningen', 'Leiden', 'Delft', 'Giethoorn', 'Haarlem', 'Zaanse Schans', 'Keukenhof'],
|
||||||
|
'New Zealand': ['Auckland', 'Queenstown', 'Wellington', 'Christchurch', 'Rotorua', 'Milford Sound', 'Wanaka', 'Taupō', 'Dunedin', 'Tongariro', 'Abel Tasman', 'Bay of Islands'],
|
||||||
|
'Nicaragua': ['Managua', 'Granada', 'León', 'San Juan del Sur', 'Ometepe Island', 'Corn Islands'],
|
||||||
|
'Niger': ['Niamey', 'Agadez', 'Zinder', 'Maradi', 'Tahoua'],
|
||||||
|
'Nigeria': ['Lagos', 'Abuja', 'Port Harcourt', 'Calabar', 'Ibadan', 'Kano', 'Enugu', 'Jos'],
|
||||||
|
'North Korea': ['Pyongyang', 'Kaesong', 'Chongjin', 'Nampo', 'Wonsan'],
|
||||||
|
'North Macedonia': ['Skopje', 'Ohrid', 'Bitola', 'Tetovo', 'Struga'],
|
||||||
|
'Norway': ['Oslo', 'Bergen', 'Tromsø', 'Stavanger', 'Trondheim', 'Lofoten Islands', 'Geirangerfjord', 'Flåm', 'Alesund', 'Preikestolen', 'Nordkapp'],
|
||||||
|
'Oman': ['Muscat', 'Salalah', 'Nizwa', 'Sur', 'Wahiba Sands', 'Khasab', 'Sohar'],
|
||||||
|
'Pakistan': ['Islamabad', 'Karachi', 'Lahore', 'Hunza Valley', 'Skardu', 'Faisalabad', 'Multan', 'Swat Valley'],
|
||||||
|
'Palau': ['Ngerulmud', 'Koror', 'Peleliu'],
|
||||||
|
'Palestine': ['Ramallah', 'Bethlehem', 'Hebron', 'Nablus', 'Jericho', 'Gaza'],
|
||||||
|
'Panama': ['Panama City', 'Bocas del Toro', 'Boquete', 'San Blas Islands', 'El Valle de Antón'],
|
||||||
|
'Papua New Guinea': ['Port Moresby', 'Lae', 'Mount Hagen', 'Kokopo', 'Alotau', 'Tufi'],
|
||||||
|
'Paraguay': ['Asunción', 'Ciudad del Este', 'Encarnación', 'San Bernardino', 'Filadelfia'],
|
||||||
|
'Peru': ['Lima', 'Cusco', 'Arequipa', 'Machu Picchu', 'Sacred Valley', 'Lake Titicaca', 'Iquitos (Amazon)', 'Paracas', 'Huaraz', 'Nazca', 'Máncora', 'Trujillo'],
|
||||||
|
'Philippines': ['Manila', 'Cebu', 'Palawan (El Nido)', 'Siargao', 'Boracay', 'Davao', 'Bohol (Panglao)', 'Banaue Rice Terraces', 'Coron', 'Baguio', 'Puerto Princesa'],
|
||||||
|
'Poland': ['Warsaw', 'Kraków', 'Gdańsk', 'Wrocław', 'Poznań', 'Zakopane', 'Gdynia', 'Łódź', 'Toruń', 'Szczecin', 'Lublin', 'Malbork', 'Morskie Oko'],
|
||||||
|
'Portugal': ['Lisbon', 'Porto', 'Algarve (Faro)', 'Sintra', 'Madeira', 'Coimbra', 'Azores', 'Braga', 'Évora', 'Cascais', 'Douro Valley'],
|
||||||
|
'Puerto Rico': ['San Juan', 'Ponce', 'Mayagüez', 'Culebra', 'Vieques', 'Rincón'],
|
||||||
|
'Qatar': ['Doha', 'Al Wakrah', 'Al Khor', 'Mesaieed', 'Katara'],
|
||||||
|
'Romania': ['Bucharest', 'Cluj-Napoca', 'Brașov', 'Sibiu', 'Sighișoara', 'Timișoara', 'Iași', 'Constanța', 'Transfăgărășan', 'Mamaia'],
|
||||||
|
'Russia': ['Moscow', 'Saint Petersburg', 'Moscow', 'Sochi', 'Vladivostok', 'Kazan', 'Novosibirsk', 'Yekaterinburg', 'Irkutsk', 'Lake Baikal', 'Murmansk', 'Kaliningrad', 'Kamchatka', 'Krasnodar', 'Nizhny Novgorod', 'Rostov-on-Don'],
|
||||||
|
'Rwanda': ['Kigali', 'Butare', 'Gisenyi', 'Volcanoes National Park', 'Akagera', 'Nyungwe Forest'],
|
||||||
|
'S. Sudan': ['Juba', 'Malakal', 'Wau', 'Bor', 'Yei'],
|
||||||
|
'Samoa': ['Apia', 'Salelologa', 'Lalomanu', 'Safua'],
|
||||||
|
'San Marino': ['San Marino City', 'Borgo Maggiore', 'Serravalle'],
|
||||||
|
'São Tomé and Principe': ['São Tomé', 'Santo António', 'Neves'],
|
||||||
|
'Saudi Arabia': ['Riyadh', 'Jeddah', 'Mecca', 'Medina', 'Dammam', 'AlUla', 'Abha', 'Tabuk', 'Neom'],
|
||||||
|
'Senegal': ['Dakar', 'Saint-Louis', 'Gorée Island', 'Sine-Saloum Delta', 'Pink Lake (Lac Rose)', 'Cap Skirring'],
|
||||||
|
'Serbia': ['Belgrade', 'Novi Sad', 'Niš', 'Subotica', 'Kragujevac', 'Zlatibor', 'Kopaonik'],
|
||||||
|
'Seychelles': ['Mahé (Victoria)', 'Praslin', 'La Digue', 'Silhouette Island'],
|
||||||
|
'Sierra Leone': ['Freetown', 'Bo', 'Kenema', 'Makeni', 'Bunce Island'],
|
||||||
|
'Singapore': ['Singapore'],
|
||||||
|
'Slovakia': ['Bratislava', 'Košice', 'Tatras (High Tatras)', 'Banská Štiavnica', 'Levoča', 'Žilina', 'Poprad'],
|
||||||
|
'Slovenia': ['Ljubljana', 'Lake Bled', 'Piran', 'Maribor', 'Postojna Cave', 'Triglav National Park', 'Celje'],
|
||||||
|
'Solomon Is.': ['Honiara', 'Gizo', 'Auki', 'Munda'],
|
||||||
|
'Somalia': ['Mogadishu', 'Hargeisa', 'Kismayo', 'Baidoa', 'Berbera'],
|
||||||
|
'South Africa': ['Cape Town', 'Johannesburg', 'Durban', 'Kruger National Park', 'Garden Route', 'Cape Winelands (Stellenbosch)', 'Port Elizabeth', 'Hermanus', 'Blyde River Canyon', 'Drakensberg', 'Pretoria', 'Soweto', 'Knysna'],
|
||||||
|
'South Korea': ['Seoul', 'Busan', 'Jeju Island', 'Gyeongju', 'Incheon', 'Daegu', 'Daejeon', 'Gwangju', 'Jeonju', 'Seoraksan National Park', 'Andong', 'Suwon', 'Sokcho', 'Pyeongchang'],
|
||||||
|
'Spain': ['Barcelona', 'Madrid', 'Seville', 'Granada', 'Valencia', 'Bilbao', 'San Sebastián', 'Mallorca', 'Ibiza', 'Tenerife', 'Córdoba', 'Málaga', 'Santiago de Compostela', 'Toledo', 'Ronda', 'Salamanca', 'Marbella', 'Costa Brava', 'Alhambra', 'Picos de Europa'],
|
||||||
|
'Sri Lanka': ['Colombo', 'Kandy', 'Galle', 'Sigiriya', 'Ella', 'Mirissa', 'Anuradhapura', 'Polonnaruwa', 'Nuwara Eliya', 'Yala National Park'],
|
||||||
|
'St. Kitts and Nevis': ['Basseterre', 'Charlestown', 'Frigate Bay', 'Nevis'],
|
||||||
|
'St. Lucia': ['Castries', 'Soufrière', 'Gros Islet', 'Vieux Fort', 'Marigot Bay'],
|
||||||
|
'St. Pierre and Miquelon': ['Saint-Pierre', 'Miquelon'],
|
||||||
|
'St. Vin. and Gren.': ['Kingstown', 'Bequia', 'Mustique', 'Canouan', 'Union Island'],
|
||||||
|
'Sudan': ['Khartoum', 'Omdurman', 'Port Sudan', 'Kassala', 'Nyala'],
|
||||||
|
'Suriname': ['Paramaribo', 'Lelydorp', 'Brokopondo', 'Nieuw Nickerie'],
|
||||||
|
'Sweden': ['Stockholm', 'Gothenburg', 'Malmö', 'Kiruna', 'Visby', 'Uppsala', 'Lund', 'Abisko National Park', 'Icehotel (Jukkasjärvi)', 'Smögen'],
|
||||||
|
'Switzerland': ['Zurich', 'Geneva', 'Lucerne', 'Zermatt', 'Interlaken', 'Lugano', 'Lausanne', 'Bern', 'Grindelwald', 'St. Moritz', 'Jungfraujoch', 'Montreux', 'Matterhorn Glacier Paradise'],
|
||||||
|
'Syria': ['Damascus', 'Aleppo', 'Palmyra', 'Homs', 'Latakia', 'Maaloula'],
|
||||||
|
'Taiwan': ['Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Taroko Gorge', 'Sun Moon Lake', 'Alishan', 'Jiufen', 'Kenting', 'Yilan', 'Taoyuan', 'Hualien'],
|
||||||
|
'Tajikistan': ['Dushanbe', 'Khujand', 'Pamir Mountains', 'Khorog', 'Bokhtar'],
|
||||||
|
'Tanzania': ['Dar es Salaam', 'Zanzibar City', 'Arusha', 'Serengeti National Park', 'Kilimanjaro', 'Ngorongoro Crater', 'Mwanza', 'Mbeya', 'Mafia Island', 'Lake Manyara', 'Selous'],
|
||||||
|
'Thailand': ['Bangkok', 'Chiang Mai', 'Phuket', 'Krabi', 'Pattaya', 'Koh Samui', 'Koh Phi Phi', 'Koh Tao', 'Ayutthaya', 'Chiang Rai', 'Hua Hin', 'Khao Sok', 'Pai', 'Kanchanaburi', 'Sukhothai', 'Koh Lanta', 'Railay Beach', 'Erawan National Park'],
|
||||||
|
'Timor-Leste': ['Dili', 'Baucau', 'Same', 'Atauro Island', 'Jaco Island'],
|
||||||
|
'Togo': ['Lomé', 'Kpalimé', 'Sokodé', 'Kara', 'Aneho'],
|
||||||
|
'Tonga': ['Nuku\'alofa', 'Neiafu', 'Pangai', 'Ha\'apai', 'Eua'],
|
||||||
|
'Trinidad and Tobago': ['Port of Spain', 'San Fernando', 'Tobago (Scarborough)', 'Chaguanas', 'Maracas Bay'],
|
||||||
|
'Tunisia': ['Tunis', 'Sousse', 'Hammamet', 'Djerba', 'Sfax', 'Carthage', 'Douz (Sahara)', 'Matmata', 'Bizerte', 'El Jem'],
|
||||||
|
'Turkey': ['Istanbul', 'Cappadocia (Göreme)', 'Antalya', 'Izmir', 'Bodrum', 'Fethiye', 'Pamukkale', 'Ephesus', 'Marmaris', 'Alanya', 'Ankara', 'Trabzon', 'Kas', 'Olympos', 'Gallipoli', 'Konya', 'Mardin', 'Butterfly Valley'],
|
||||||
|
'Turkmenistan': ['Ashgabat', 'Mary', 'Turkmenbashi', 'Dashoguz', 'Köneürgenç'],
|
||||||
|
'Tuvalu': ['Funafuti', 'Nanumea', 'Nukulaelae'],
|
||||||
|
'U.S. Virgin Is.': ['Charlotte Amalie', 'Christiansted', 'Frederiksted', 'Cruz Bay'],
|
||||||
|
'Uganda': ['Kampala', 'Jinja', 'Murchison Falls', 'Queen Elizabeth National Park', 'Bwindi Impenetrable Forest', 'Kibale', 'Lake Bunyonyi', 'Entebbe'],
|
||||||
|
'Ukraine': ['Kyiv', 'Lviv', 'Odesa', 'Kharkiv', 'Carpathian Mountains', 'Dnipro', 'Chernivtsi', 'Kamianets-Podilskyi', 'Zaporizhzhia', 'Lutsk'],
|
||||||
|
'United Arab Emirates': ['Dubai', 'Abu Dhabi', 'Sharjah', 'Fujairah', 'Ras Al Khaimah', 'Ajman', 'Hatta'],
|
||||||
|
'United Kingdom': ['London', 'Edinburgh', 'Bath', 'York', 'Liverpool', 'Manchester', 'Birmingham', 'Cambridge', 'Oxford', 'Brighton', 'Cornwall', 'Bristol', 'Cardiff', 'Glasgow', 'Inverness', 'Belfast', 'Lake District', 'Scottish Highlands', 'St. Ives', 'Canterbury', 'Dover', 'Stratford-upon-Avon'],
|
||||||
|
'United States of America': ['New York', 'Los Angeles', 'Chicago', 'San Francisco', 'Las Vegas', 'Miami', 'Orlando', 'Washington DC', 'Boston', 'Seattle', 'Portland', 'Denver', 'New Orleans', 'Nashville', 'Austin', 'San Diego', 'Honolulu', 'Grand Canyon', 'Yellowstone', 'Yosemite', 'Houston', 'Dallas', 'Atlanta', 'Philadelphia', 'Phoenix', 'San Antonio', 'Savannah', 'Charleston', 'Santa Fe', 'Anchorage', 'Maui', 'Kauai', 'Moab', 'Portland (ME)', 'Asheville', 'Sedona', 'Napa Valley'],
|
||||||
|
'Uruguay': ['Montevideo', 'Punta del Este', 'Colonia del Sacramento', 'Piriapolis', 'Cabo Polonio', 'Rocha'],
|
||||||
|
'Uzbekistan': ['Tashkent', 'Samarkand', 'Bukhara', 'Khiva', 'Shakhrisabz', 'Fergana', 'Nukus', 'Termez'],
|
||||||
|
'Vanuatu': ['Port Vila', 'Luganville', 'Tanna Island', 'Pentecost', 'Espiritu Santo'],
|
||||||
|
'Vatican': ['Vatican City'],
|
||||||
|
'Venezuela': ['Caracas', 'Angel Falls (Canaima)', 'Margarita Island', 'Los Roques', 'Mérida', 'Roraima', 'Maracaibo', 'Valencia'],
|
||||||
|
'Vietnam': ['Hanoi', 'Ho Chi Minh City (Saigon)', 'Ha Long Bay', 'Hoi An', 'Da Nang', 'Hue', 'Nha Trang', 'Phong Nha', 'Da Lat', 'Sapa', 'Phu Quoc', 'Mui Ne', 'Con Dao', 'Mekong Delta (Can Tho)', 'Ninh Binh', 'Son Doong Cave'],
|
||||||
|
'W. Sahara': ['Laayoune', 'Dakhla', 'Smara', 'Boujdour'],
|
||||||
|
'Yemen': ['Sana\'a', 'Aden', 'Socotra', 'Taiz', 'Mukalla', 'Shibam'],
|
||||||
|
'Zambia': ['Lusaka', 'Victoria Falls', 'Livingstone', 'South Luangwa National Park', 'Kitwe', 'Ndola', 'Kasama'],
|
||||||
|
'Zimbabwe': ['Harare', 'Victoria Falls', 'Bulawayo', 'Hwange National Park', 'Mutare', 'Gweru', 'Masvingo (Great Zimbabwe)'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get curated city suggestions for a given country name.
|
||||||
|
* @param {string} countryName
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getCitiesForCountry(countryName) {
|
||||||
|
return ALL_CITIES[countryName] || [];
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { get } from 'svelte/store';
|
import { getEntries } from '../stores/entriesStore.svelte.js';
|
||||||
import { journals, addJournal, updateJournal } from '../stores/journalStore.js';
|
import { addEntry, updateEntry } from '../stores/entriesStore.svelte.js';
|
||||||
import { countryNames } from '../shared/countries.js';
|
import { countryNames } from '../shared/countries.js';
|
||||||
|
import { getCitiesForCountry, ALL_CITIES } from '../shared/cities.js';
|
||||||
import SearchInput from '../shared/SearchInput.svelte';
|
import SearchInput from '../shared/SearchInput.svelte';
|
||||||
import PhotoEditor from './PhotoEditor.svelte';
|
import PhotoEditor from './PhotoEditor.svelte';
|
||||||
|
|
||||||
@@ -58,10 +59,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Suggest cities — when a country is selected show only cities from that country.
|
// Suggest cities — when a country is selected show only cities from that country.
|
||||||
|
let allEntries = $derived(getEntries());
|
||||||
let cityOptions = $derived(
|
let cityOptions = $derived(
|
||||||
country.trim()
|
country.trim()
|
||||||
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
|
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
|
||||||
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
|
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
|
||||||
);
|
);
|
||||||
|
|
||||||
function addCity(val) {
|
function addCity(val) {
|
||||||
@@ -89,7 +91,7 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
await addJournal({
|
await addEntry({
|
||||||
title: `${cities.join(', ')}, ${country}`,
|
title: `${cities.join(', ')}, ${country}`,
|
||||||
date,
|
date,
|
||||||
days: Number(days),
|
days: Number(days),
|
||||||
@@ -100,8 +102,7 @@
|
|||||||
location: { cities, country },
|
location: { cities, country },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await updateJournal({
|
await updateEntry(entry.id, {
|
||||||
...entry,
|
|
||||||
date,
|
date,
|
||||||
days: Number(days),
|
days: Number(days),
|
||||||
tripType,
|
tripType,
|
||||||
@@ -113,7 +114,7 @@
|
|||||||
}
|
}
|
||||||
onBack();
|
onBack();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Failed to save. Please try again.');
|
console.error('Save failed:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -240,7 +241,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 52px;
|
height: 60px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
@@ -255,8 +256,8 @@
|
|||||||
.topbar-right { justify-content: flex-end; }
|
.topbar-right { justify-content: flex-end; }
|
||||||
|
|
||||||
.topbar-title {
|
.topbar-title {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,13 +266,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 6px 12px;
|
padding: 8px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { removeJournal } from '../stores/journalStore.js';
|
import { removeEntry } from '../stores/entriesStore.svelte.js';
|
||||||
import { flagEmoji } from '../shared/countries.js';
|
import { flagEmoji } from '../shared/countries.js';
|
||||||
import DeleteConfirm from './DeleteConfirm.svelte';
|
import DeleteConfirm from './DeleteConfirm.svelte';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
removeJournal(entry.id);
|
removeEntry(entry.id);
|
||||||
onBack();
|
onBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: 52px;
|
height: 60px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
@@ -196,13 +196,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 6px 12px;
|
padding: 8px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { get } from 'svelte/store';
|
import { getEntries } from '../stores/entriesStore.svelte.js';
|
||||||
import { journals, addJournal } from '../stores/journalStore.js';
|
import { addEntry } from '../stores/entriesStore.svelte.js';
|
||||||
import { countryNames } from '../shared/countries.js';
|
import { countryNames } from '../shared/countries.js';
|
||||||
|
import { getCitiesForCountry, ALL_CITIES } from '../shared/cities.js';
|
||||||
import SearchInput from '../shared/SearchInput.svelte';
|
import SearchInput from '../shared/SearchInput.svelte';
|
||||||
import PhotoEditor from './PhotoEditor.svelte';
|
import PhotoEditor from './PhotoEditor.svelte';
|
||||||
import airplaneImg from '../../assets/airplane.png';
|
import airplaneImg from '../../assets/airplane.png';
|
||||||
@@ -53,10 +54,11 @@
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
// Suggest cities — if a country is selected, show cities only from that country;
|
// Suggest cities — if a country is selected, show cities only from that country;
|
||||||
// otherwise show all known cities.
|
// otherwise show all known cities.
|
||||||
|
let allEntries = $derived(getEntries());
|
||||||
let cityOptions = $derived(
|
let cityOptions = $derived(
|
||||||
country.trim()
|
country.trim()
|
||||||
? [...new Set(get(journals).filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities))].sort()
|
? [...new Set([...getCitiesForCountry(country), ...allEntries.filter(j => (j.location.country || '').toLowerCase() === country.trim().toLowerCase()).flatMap(e => e.location.cities)])].sort()
|
||||||
: [...new Set(get(journals).flatMap(e => e.location.cities))].sort()
|
: [...new Set([...Object.values(ALL_CITIES).flat(), ...allEntries.flatMap(e => e.location.cities)])].sort()
|
||||||
);
|
);
|
||||||
|
|
||||||
function addCity(val) {
|
function addCity(val) {
|
||||||
@@ -114,7 +116,7 @@
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
try {
|
try {
|
||||||
await addJournal({
|
await addEntry({
|
||||||
title: `${cities.join(', ')}, ${country}`,
|
title: `${cities.join(', ')}, ${country}`,
|
||||||
date,
|
date,
|
||||||
days: Number(days),
|
days: Number(days),
|
||||||
@@ -125,8 +127,10 @@
|
|||||||
location: { cities, country },
|
location: { cities, country },
|
||||||
});
|
});
|
||||||
onBack();
|
onBack();
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error('Save failed:', e);
|
||||||
saving = false;
|
saving = false;
|
||||||
|
alert('Failed to save. Check console for details.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -161,17 +165,26 @@
|
|||||||
<div class="form">
|
<div class="form">
|
||||||
|
|
||||||
{#if step === 1}
|
{#if step === 1}
|
||||||
|
<!-- headline -->
|
||||||
|
<h1 class="page-headline">
|
||||||
|
{#if country.trim()}
|
||||||
|
Journal your trip to <strong>{country}</strong>!
|
||||||
|
{:else}
|
||||||
|
Journal your trip!
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<!-- ── STEP 1: Details ── -->
|
<!-- ── STEP 1: Details ── -->
|
||||||
<h2 class="step-title">Trip details</h2>
|
<h2 class="step-title">Trip details</h2>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="nc-country">Country <span class="req">*</span></label>
|
<label class="label" for="nc-country">Which <span class="kw">country</span> did you visit? <span class="req">*</span></label>
|
||||||
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
|
<SearchInput id="nc-country" bind:value={country} options={countryNames} />
|
||||||
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
{#if errors.country}<span class="ferr">{errors.country}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="nc-city">Cities <span class="req">*</span></label>
|
<label class="label" for="nc-city">Which <span class="kw">cities</span> did you visit? <span class="req">*</span></label>
|
||||||
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
<SearchInput id="nc-city" bind:value={cityInput} options={cityOptions} onselect={addCity} />
|
||||||
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
{#if errors.cities}<span class="ferr">{errors.cities}</span>{/if}
|
||||||
{#if cities.length > 0}
|
{#if cities.length > 0}
|
||||||
@@ -186,24 +199,24 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="nc-date">Date <span class="req">*</span></label>
|
<label class="label" for="nc-date">When did you <span class="kw">arrive</span>? <span class="req">*</span></label>
|
||||||
<input id="nc-date" class="input" type="date" bind:value={date} />
|
<input id="nc-date" class="input" type="date" bind:value={date} />
|
||||||
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
{#if errors.date}<span class="ferr">{errors.date}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="nc-days">Days <span class="req">*</span></label>
|
<label class="label" for="nc-days">How many <span class="kw">days</span> did you stay? <span class="req">*</span></label>
|
||||||
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
<input id="nc-days" class="input" type="number" min="1" bind:value={days} />
|
||||||
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
{#if errors.days}<span class="ferr">{errors.days}</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Trip type <span class="req">*</span></label>
|
<label class="label"><span class="kw">Who</span> did you go <span class="kw">with</span>? <span class="req">*</span></label>
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
{#each ['solo','friends','family'] as t}
|
{#each ['solo','friends','family'] as t}
|
||||||
<label class="toggle-opt" class:active={tripType === t}>
|
<label class="toggle-opt" class:active={tripType === t}>
|
||||||
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
|
<input type="radio" name="nc-tripType" value={t} bind:group={tripType} />
|
||||||
{t === 'solo' ? 'Solo' : t === 'friends' ? 'With friends' : 'With family'}
|
{t === 'solo' ? '🧑 Solo' : t === 'friends' ? '👥 With friends' : '👨👩👧👦 With family'}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,13 +224,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">How did you get there? <span class="req">*</span></label>
|
<label class="label">How did you <span class="kw">get</span> there? <span class="req">*</span></label>
|
||||||
<div class="transport-grid">
|
<div class="transport-grid">
|
||||||
{#each transportOptions as opt}
|
{#each transportOptions as opt}
|
||||||
<label class="transport-opt" class:active={transport === opt.value}>
|
<label class="toggle-opt" class:active={transport === opt.value}>
|
||||||
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
|
<input type="radio" name="nc-transport" value={opt.value} bind:group={transport} />
|
||||||
<img src={opt.img} alt={opt.label} class="transport-img" />
|
<img src={opt.img} alt={opt.label} class="transport-img" />
|
||||||
<span class="transport-label">{opt.label}</span>
|
{opt.label}
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -232,11 +245,16 @@
|
|||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- ── STEP 3: Questions ── -->
|
<!-- ── STEP 3: Questions ── -->
|
||||||
<h2 class="step-title">Your memories</h2>
|
<h2 class="step-title">
|
||||||
|
Your memories{cities.length > 0 ? ` of ${cities.join(', ')}` : country.trim() ? ` of ${country}` : ''}
|
||||||
|
</h2>
|
||||||
|
{#if cities.length > 0 || country.trim()}
|
||||||
|
<p class="step-sub">{cities.join(', ')}{cities.length > 0 && country.trim() ? `, ${country}` : country.trim()}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#each questions as q, i}
|
{#each questions as q, i}
|
||||||
<div class="q-card">
|
<div class="q-card">
|
||||||
<p class="q-text">{q}</p>
|
<p class="q-text">{q}{country.trim() ? ` in ${country}` : ''}</p>
|
||||||
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
<textarea class="q-input" rows="3" placeholder="Your answer…" bind:value={answers[i]}></textarea>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -299,13 +317,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 6px 10px;
|
padding: 8px 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
@@ -313,13 +331,13 @@
|
|||||||
|
|
||||||
.save-btn {
|
.save-btn {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
padding: 7px 14px;
|
padding: 8px 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -346,6 +364,14 @@
|
|||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
margin: 0 0 2px;
|
margin: 0 0 2px;
|
||||||
}
|
}
|
||||||
|
.page-headline {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
.page-headline strong { font-weight: 600; }
|
||||||
.step-sub {
|
.step-sub {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
@@ -366,8 +392,9 @@
|
|||||||
color: var(--text-sub);
|
color: var(--text-sub);
|
||||||
}
|
}
|
||||||
.req { color: var(--accent); font-size: 11px; }
|
.req { color: var(--accent); font-size: 11px; }
|
||||||
|
.kw { color: var(--accent); }
|
||||||
|
|
||||||
.ferr { font-size: 11px; color: #dc2626; }
|
.ferr { font-size: 13px; font-weight: 500; color: #dc2626; }
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
@@ -385,33 +412,22 @@
|
|||||||
}
|
}
|
||||||
.input:focus { border-color: var(--accent-border); }
|
.input:focus { border-color: var(--accent-border); }
|
||||||
|
|
||||||
.toggle-row { display: flex; gap: 8px; }
|
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||||
.toggle-opt {
|
.toggle-opt {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
font-size: 13px; font-weight: 300; color: var(--text);
|
font-size: 16px; font-weight: 400; color: var(--text);
|
||||||
padding: 7px 14px; border-radius: 8px;
|
padding: 12px 14px; border-radius: 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
|
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg-subtle);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.toggle-opt input { display: none; }
|
.toggle-opt input { display: none; }
|
||||||
.toggle-opt.active { border-color: var(--accent-border); background: var(--accent-bg); color: var(--accent); }
|
.toggle-opt.active { border-color: var(--accent); background: var(--accent-bg); color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.toggle-opt.active img { filter: brightness(0) saturate(100%) invert(27%) sepia(98%) saturate(1169%) hue-rotate(239deg) brightness(80%) contrast(92%); }
|
||||||
|
|
||||||
.transport-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
.transport-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
.transport-opt {
|
.transport-img { width: 30px; height: 30px; object-fit: contain; flex-shrink: 0; }
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
||||||
gap: 8px; aspect-ratio: 1;
|
|
||||||
border-radius: 12px; border: 1px solid var(--border); background: var(--bg-subtle);
|
|
||||||
cursor: pointer; transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
.transport-opt input { display: none; }
|
|
||||||
.transport-opt.active { border-color: var(--accent-border); background: var(--accent-bg); }
|
|
||||||
.transport-img { width: 60px; height: 60px; object-fit: contain; }
|
|
||||||
.transport-label {
|
|
||||||
font-size: 12px; font-weight: 300; color: var(--text-sub);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.transport-opt.active .transport-label { color: var(--accent); }
|
|
||||||
|
|
||||||
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
||||||
.tag {
|
.tag {
|
||||||
@@ -430,15 +446,16 @@
|
|||||||
.q-card {
|
.q-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 14px;
|
||||||
background: var(--bg-subtle);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1.5px solid var(--accent-border);
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
padding: 20px;
|
padding: 28px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
.q-text {
|
.q-text {
|
||||||
font-size: 14px;
|
font-size: 20px;
|
||||||
font-weight: 400;
|
font-weight: 500;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -446,10 +463,10 @@
|
|||||||
}
|
}
|
||||||
.q-input {
|
.q-input {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 13px;
|
font-size: 16px;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
color: var(--text-h);
|
color: var(--text-h);
|
||||||
background: var(--bg);
|
background: var(--bg-subtle);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { get } from 'svelte/store';
|
import { getEntries } from '../stores/entriesStore.svelte.js';
|
||||||
import { journals } from '../stores/journalStore.js';
|
|
||||||
import TimelineToolbar from './TimelineToolbar.svelte';
|
import TimelineToolbar from './TimelineToolbar.svelte';
|
||||||
import TimelineCard from './TimelineCard.svelte';
|
import TimelineCard from './TimelineCard.svelte';
|
||||||
import JournalDetail from './JournalDetail.svelte';
|
import JournalDetail from './JournalDetail.svelte';
|
||||||
@@ -26,11 +25,7 @@
|
|||||||
});
|
});
|
||||||
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
|
let selected = $derived(selectedId ? (entries.find(e => e.id === selectedId) ?? null) : null);
|
||||||
|
|
||||||
let entries = $state(get(journals));
|
let entries = $derived(getEntries());
|
||||||
$effect(() => {
|
|
||||||
const unsub = journals.subscribe((v) => { entries = v; });
|
|
||||||
return unsub;
|
|
||||||
});
|
|
||||||
|
|
||||||
let sortKey = $state('date-desc');
|
let sortKey = $state('date-desc');
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { feature } from 'topojson-client';
|
import { feature } from 'topojson-client';
|
||||||
import worldData from 'world-atlas/countries-50m.json';
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
let { onclose, onprogress } = $props();
|
let { onclose, onprogress, mode = 'map', onmodechange } = $props();
|
||||||
|
|
||||||
const HOME_CODE = '203';
|
const HOME_CODE = '203';
|
||||||
|
|
||||||
@@ -15,13 +15,16 @@
|
|||||||
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
|
{ countryName: 'United States', countryCode: '840', date: '2025-01-05', city: 'New York' },
|
||||||
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
|
{ countryName: 'Thailand', countryCode: '764', date: '2025-04-18', city: 'Bangkok' },
|
||||||
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
|
{ countryName: 'Australia', countryCode: '036', date: '2025-08-22', city: 'Sydney' },
|
||||||
|
{ countryName: 'Kenya', countryCode: '404', date: '2021-11-10', city: 'Nairobi' },
|
||||||
|
{ countryName: 'South Africa', countryCode: '710', date: '2026-02-05', city: 'Cape Town' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const HOME_COLOR = '#8b5cf6';
|
const HOME_COLOR = '#8b5cf6';
|
||||||
const VISITED_COLOR = '#22c55e';
|
const VISITED_COLOR = '#22c55e';
|
||||||
const ARC_COLOR = '#000000';
|
const ARC_COLOR = '#666666';
|
||||||
const PLANE_COLOR = '#7c3aed';
|
const PLANE_COLOR = '#7c3aed';
|
||||||
const PLANE_PATH = 'M14,0 L4,-3 L0,-7 L-3,-5 L0,-2 L-5,-1 L-9,-5 L-11,-4 L-7,0 L-11,4 L-9,5 L-5,1 L0,2 L-3,5 L0,7 L4,3 Z';
|
const PLANE_IMG = '/airplane.png';
|
||||||
|
const PLANE_SIZE = 28;
|
||||||
const UNVISITED = '#ffffff';
|
const UNVISITED = '#ffffff';
|
||||||
|
|
||||||
const TERRITORY_PARENT = {
|
const TERRITORY_PARENT = {
|
||||||
@@ -40,18 +43,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frameEl;
|
let frameEl;
|
||||||
let svg, g, pathFn, projection;
|
let svg, gBase, gCountries, gAnim, pathFn, projection;
|
||||||
let countryPaths;
|
let countryPaths;
|
||||||
let homeFeature;
|
let homeFeature;
|
||||||
let featuresById = {};
|
let featuresById = {};
|
||||||
|
let countriesData = [];
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
let isPlaying = $state(false);
|
let isPlaying = $state(false);
|
||||||
let isFinished = $state(false);
|
let isFinished = $state(false);
|
||||||
|
let visitedCodes = new Set();
|
||||||
|
let animId = 0;
|
||||||
|
let currentDateLabel = $state('');
|
||||||
|
|
||||||
function fitProjection(proj, w, h) {
|
function formatDateLabel(dateStr) {
|
||||||
proj.fitSize([w, h], { type: 'Sphere' });
|
const d = new Date(dateStr);
|
||||||
const s = proj.scale() * 1.5;
|
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
proj.scale(s).translate([w / 2, h * 0.70]);
|
return `${months[d.getMonth()]} ${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeArc(p1, p2) {
|
function computeArc(p1, p2) {
|
||||||
@@ -87,7 +94,11 @@
|
|||||||
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
|
return Math.atan2(p2.y - p1.y, p2.x - p1.x) * 180 / Math.PI;
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration) {
|
function planeTransform(x, y, angle, flip) {
|
||||||
|
return `translate(${x},${y}) rotate(${angle})${flip ? ' scale(1,-1)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateStroke(pathEl, tipEl, startOffset, endOffset, duration, flip = false, maskEl = null) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const node = pathEl.node();
|
const node = pathEl.node();
|
||||||
if (!node) { resolve(); return; }
|
if (!node) { resolve(); return; }
|
||||||
@@ -102,15 +113,15 @@
|
|||||||
const offset = startOffset + (endOffset - startOffset) * t;
|
const offset = startOffset + (endOffset - startOffset) * t;
|
||||||
|
|
||||||
pathEl.attr('stroke-dashoffset', offset);
|
pathEl.attr('stroke-dashoffset', offset);
|
||||||
|
if (maskEl) maskEl.attr('stroke-dashoffset', offset);
|
||||||
|
|
||||||
const drawn = totalLength - offset;
|
const drawn = totalLength - offset;
|
||||||
const clamped = Math.max(0, Math.min(drawn, totalLength));
|
const clamped = Math.max(0, Math.min(drawn, totalLength));
|
||||||
try {
|
try {
|
||||||
const pt = node.getPointAtLength(clamped);
|
const pt = node.getPointAtLength(clamped);
|
||||||
const angle = getAngleAtLength(node, clamped);
|
const angle = getAngleAtLength(node, clamped);
|
||||||
tipEl.attr('transform', `translate(${pt.x}, ${pt.y}) rotate(${angle}) scale(1.4)`).attr('opacity', 1);
|
tipEl.attr('transform', planeTransform(pt.x, pt.y, angle, flip)).attr('opacity', 1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore SVG errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t >= 1) {
|
if (t >= 1) {
|
||||||
@@ -128,58 +139,237 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupProjection(width, height) {
|
||||||
|
if (mode === 'map') {
|
||||||
|
projection = d3.geoMercator();
|
||||||
|
projection.fitSize([width, height], { type: 'Sphere' });
|
||||||
|
const s = projection.scale() * 1.5;
|
||||||
|
projection.scale(s).translate([width / 2, height * 0.70]);
|
||||||
|
} else {
|
||||||
|
const size = Math.min(width, height) * 0.92;
|
||||||
|
projection = d3.geoOrthographic()
|
||||||
|
.rotate([0, 0])
|
||||||
|
.fitSize([size, size], { type: 'Sphere' })
|
||||||
|
.translate([width / 2, height / 2]);
|
||||||
|
}
|
||||||
|
pathFn = d3.geoPath().projection(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap() {
|
||||||
|
gBase.selectAll('*').remove();
|
||||||
|
gCountries.selectAll('*').remove();
|
||||||
|
gAnim.selectAll('*').remove();
|
||||||
|
|
||||||
|
const fillFn = d => {
|
||||||
|
const id = effId(d);
|
||||||
|
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
||||||
|
if (visitedCodes.has(id)) return VISITED_COLOR;
|
||||||
|
return UNVISITED;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'globe') {
|
||||||
|
gBase.append('path')
|
||||||
|
.attr('class', 'sphere')
|
||||||
|
.datum({ type: 'Sphere' })
|
||||||
|
.attr('d', pathFn)
|
||||||
|
.attr('fill', '#a4c8e0')
|
||||||
|
.attr('stroke', '#8b9bb0')
|
||||||
|
.attr('stroke-width', 1.5);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
countryPaths = gCountries.selectAll('path')
|
||||||
|
.data(countriesData, d => effId(d))
|
||||||
|
.join('path')
|
||||||
|
.attr('d', pathFn)
|
||||||
|
.attr('fill', fillFn)
|
||||||
|
.attr('stroke', mode === 'globe' ? '#4a6a8c' : '#d4d4d4')
|
||||||
|
.attr('stroke-width', mode === 'globe' ? 0.3 : 0.5);
|
||||||
|
|
||||||
|
if (mode === 'map') {
|
||||||
|
renderMicrostates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMicrostates() {
|
||||||
|
gBase.selectAll('.micro-state-j').remove();
|
||||||
|
const threshold = 4;
|
||||||
|
const fillFn = d => {
|
||||||
|
const id = effId(d);
|
||||||
|
if (id === HOME_CODE) return visitedCodes.has(id) ? VISITED_COLOR : HOME_COLOR;
|
||||||
|
if (visitedCodes.has(id)) return VISITED_COLOR;
|
||||||
|
return UNVISITED;
|
||||||
|
};
|
||||||
|
countryPaths.each(function (d) {
|
||||||
|
if (effId(d) !== d.id) return;
|
||||||
|
const { width, height } = this.getBBox();
|
||||||
|
if (width < threshold && height < threshold) {
|
||||||
|
const [cx, cy] = pathFn.centroid(d);
|
||||||
|
gBase.append('circle')
|
||||||
|
.attr('class', 'micro-state-j')
|
||||||
|
.datum(d)
|
||||||
|
.attr('cx', cx)
|
||||||
|
.attr('cy', cy)
|
||||||
|
.attr('r', 2)
|
||||||
|
.attr('fill', fillFn(d))
|
||||||
|
.attr('stroke', '#94a3b8')
|
||||||
|
.attr('stroke-width', 0.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawBase() {
|
||||||
|
countryPaths.attr('d', pathFn);
|
||||||
|
if (mode === 'globe') {
|
||||||
|
gBase.select('.sphere').attr('d', pathFn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateGlobeTo(lon, lat, duration = 1500) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (isCancelled) { resolve(); return; }
|
||||||
|
|
||||||
|
const current = projection.rotate();
|
||||||
|
const from = [-current[0], -current[1]];
|
||||||
|
const to = [lon, lat];
|
||||||
|
|
||||||
|
const interpolate = d3.geoInterpolate(from, to);
|
||||||
|
|
||||||
|
const timer = d3.timer(elapsed => {
|
||||||
|
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||||
|
|
||||||
|
const t = Math.min(elapsed / duration, 1);
|
||||||
|
const point = interpolate(t);
|
||||||
|
projection.rotate([-point[0], -point[1]]);
|
||||||
|
redrawBase();
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
timer.stop();
|
||||||
|
resolve();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateIncrementalPath(el, tip, pts, duration, flip = false) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const steps = pts.length - 1;
|
||||||
|
const lineGen = d3.line().curve(d3.curveBasis);
|
||||||
|
|
||||||
|
d3.timer(elapsed => {
|
||||||
|
if (isCancelled) { resolve(); return true; }
|
||||||
|
|
||||||
|
const t = Math.min(elapsed / duration, 1);
|
||||||
|
const count = Math.max(2, Math.floor(t * steps) + 1);
|
||||||
|
const visible = pts.slice(0, count);
|
||||||
|
|
||||||
|
if (visible.length >= 2) {
|
||||||
|
el.attr('d', lineGen(visible));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.length > 0) {
|
||||||
|
const last = visible[visible.length - 1];
|
||||||
|
let angle = 0;
|
||||||
|
if (visible.length >= 2) {
|
||||||
|
const prev = visible[visible.length - 2];
|
||||||
|
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
resolve();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateReprojectingArc(el, tip, geoPts, lineGen, duration, flip = false) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const steps = geoPts.length - 1;
|
||||||
|
|
||||||
|
const timer = d3.timer(elapsed => {
|
||||||
|
if (isCancelled) { timer.stop(); resolve(); return true; }
|
||||||
|
|
||||||
|
const t = Math.min(elapsed / duration, 1);
|
||||||
|
const count = Math.max(2, Math.floor(t * steps) + 1);
|
||||||
|
const visible = geoPts.slice(0, count);
|
||||||
|
const screenPts = [];
|
||||||
|
for (const p of visible) {
|
||||||
|
const pt = projection(p);
|
||||||
|
if (pt) screenPts.push(pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenPts.length >= 2) {
|
||||||
|
el.attr('d', lineGen(screenPts));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (screenPts.length > 0) {
|
||||||
|
const last = screenPts[screenPts.length - 1];
|
||||||
|
let angle = 0;
|
||||||
|
if (screenPts.length >= 2) {
|
||||||
|
const prev = screenPts[screenPts.length - 2];
|
||||||
|
angle = Math.atan2(last[1] - prev[1], last[0] - prev[0]) * 180 / Math.PI;
|
||||||
|
}
|
||||||
|
tip.attr('transform', planeTransform(last[0], last[1], angle, flip)).attr('opacity', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
timer.stop();
|
||||||
|
resolve();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function animateTrip(destCode, destFeature) {
|
async function animateTrip(destCode, destFeature) {
|
||||||
if (!homeFeature || !destFeature) return;
|
if (!homeFeature || !destFeature) return;
|
||||||
|
|
||||||
const homeCentroid = d3.geoCentroid(homeFeature);
|
const homeCentroid = d3.geoCentroid(homeFeature);
|
||||||
const destCentroid = d3.geoCentroid(destFeature);
|
const destCentroid = d3.geoCentroid(destFeature);
|
||||||
|
|
||||||
|
if (mode === 'map') {
|
||||||
|
await animateMapTrip(homeCentroid, destCentroid, destCode);
|
||||||
|
} else {
|
||||||
|
await animateGlobeTrip(homeCentroid, destCentroid, destCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateMapTrip(homeCentroid, destCentroid, destCode) {
|
||||||
const pts = computeArc(homeCentroid, destCentroid);
|
const pts = computeArc(homeCentroid, destCentroid);
|
||||||
if (pts.length < 2) return;
|
if (pts.length < 2) return;
|
||||||
|
|
||||||
const lineGen = d3.line().curve(d3.curveBasis);
|
const outFlip = pts[pts.length - 1][0] < pts[0][0];
|
||||||
const pathData = lineGen(pts);
|
|
||||||
|
|
||||||
if (!pathData) return;
|
const outEl = gAnim.append('path')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', ARC_COLOR)
|
||||||
|
.attr('stroke-width', 2.5)
|
||||||
|
.attr('stroke-opacity', 0.8)
|
||||||
|
.attr('stroke-linecap', 'round')
|
||||||
|
.attr('stroke-dasharray', '10, 6');
|
||||||
|
const outTip = gAnim.append('image')
|
||||||
|
.attr('href', PLANE_IMG)
|
||||||
|
.attr('width', PLANE_SIZE)
|
||||||
|
.attr('height', PLANE_SIZE)
|
||||||
|
.attr('x', -PLANE_SIZE / 2)
|
||||||
|
.attr('y', -PLANE_SIZE / 2)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
.attr('opacity', 0);
|
||||||
|
|
||||||
function createArc(pathData) {
|
await animateIncrementalPath(outEl, outTip, pts, 2500, outFlip);
|
||||||
const el = g.append('path')
|
|
||||||
.attr('d', pathData)
|
|
||||||
.attr('fill', 'none')
|
|
||||||
.attr('stroke', ARC_COLOR)
|
|
||||||
.attr('stroke-width', 2.5)
|
|
||||||
.attr('stroke-opacity', 0.8)
|
|
||||||
.attr('stroke-linecap', 'round');
|
|
||||||
const tip = g.append('path')
|
|
||||||
.attr('d', PLANE_PATH)
|
|
||||||
.attr('fill', PLANE_COLOR)
|
|
||||||
.attr('opacity', 0);
|
|
||||||
return { el, tip };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Outbound: home -> dest ---
|
|
||||||
let { el: outEl, tip: outTip } = createArc(pathData);
|
|
||||||
const outLen = outEl.node().getTotalLength();
|
|
||||||
outEl.attr('stroke-dasharray', outLen).attr('stroke-dashoffset', outLen);
|
|
||||||
|
|
||||||
const homeDot = g.append('circle')
|
|
||||||
.attr('r', 4)
|
|
||||||
.attr('fill', PLANE_COLOR)
|
|
||||||
.attr('cx', pts[0][0])
|
|
||||||
.attr('cy', pts[0][1])
|
|
||||||
.attr('opacity', 1);
|
|
||||||
|
|
||||||
await animateStroke(outEl, outTip, outLen, 0, 2500);
|
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
outEl.remove();
|
outEl.remove();
|
||||||
outTip.remove();
|
outTip.remove();
|
||||||
homeDot.remove();
|
|
||||||
|
|
||||||
// Color the destination country
|
|
||||||
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
||||||
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
g.selectAll('.micro-state-j')
|
visitedCodes.add(destCode);
|
||||||
|
gBase.selectAll('.micro-state-j')
|
||||||
.filter(d => effId(d) === destCode)
|
.filter(d => effId(d) === destCode)
|
||||||
.transition().duration(500)
|
.transition().duration(500)
|
||||||
.attr('fill', VISITED_COLOR);
|
.attr('fill', VISITED_COLOR);
|
||||||
@@ -187,42 +377,148 @@
|
|||||||
await delay(800);
|
await delay(800);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
// --- Return: dest -> home ---
|
|
||||||
const revPts = [...pts].reverse();
|
const revPts = [...pts].reverse();
|
||||||
const revData = d3.line().curve(d3.curveBasis)(revPts);
|
const retFlip = revPts[revPts.length - 1][0] < revPts[0][0];
|
||||||
let { el: retEl, tip: retTip } = createArc(revData);
|
|
||||||
const retLen = retEl.node().getTotalLength();
|
|
||||||
retEl.attr('stroke-dasharray', retLen).attr('stroke-dashoffset', retLen);
|
|
||||||
|
|
||||||
const destDot = g.append('circle')
|
const retEl = gAnim.append('path')
|
||||||
.attr('r', 4)
|
.attr('fill', 'none')
|
||||||
.attr('fill', PLANE_COLOR)
|
.attr('stroke', ARC_COLOR)
|
||||||
.attr('cx', revPts[0][0])
|
.attr('stroke-width', 2.5)
|
||||||
.attr('cy', revPts[0][1])
|
.attr('stroke-opacity', 0.8)
|
||||||
.attr('opacity', 1);
|
.attr('stroke-linecap', 'round')
|
||||||
|
.attr('stroke-dasharray', '10, 6');
|
||||||
|
const retTip = gAnim.append('image')
|
||||||
|
.attr('href', PLANE_IMG)
|
||||||
|
.attr('width', PLANE_SIZE)
|
||||||
|
.attr('height', PLANE_SIZE)
|
||||||
|
.attr('x', -PLANE_SIZE / 2)
|
||||||
|
.attr('y', -PLANE_SIZE / 2)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
.attr('opacity', 0);
|
||||||
|
|
||||||
await animateStroke(retEl, retTip, retLen, 0, 2200);
|
await animateIncrementalPath(retEl, retTip, revPts, 2200, retFlip);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
retEl.remove();
|
||||||
|
retTip.remove();
|
||||||
|
|
||||||
|
await delay(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animateGlobeTrip(homeCentroid, destCentroid, destCode) {
|
||||||
|
const interp = d3.geoInterpolate(homeCentroid, destCentroid);
|
||||||
|
const steps = 80;
|
||||||
|
const geoPts = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
geoPts.push(interp(i / steps));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = d3.geoDistance(homeCentroid, destCentroid);
|
||||||
|
const dur = Math.round(1500 + dist * 2500);
|
||||||
|
|
||||||
|
const lineGen = d3.line().curve(d3.curveBasis);
|
||||||
|
|
||||||
|
const outEl = gAnim.append('path')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', ARC_COLOR)
|
||||||
|
.attr('stroke-width', 2.5)
|
||||||
|
.attr('stroke-opacity', 0.8)
|
||||||
|
.attr('stroke-linecap', 'round')
|
||||||
|
.attr('stroke-dasharray', '10, 6');
|
||||||
|
const outTip = gAnim.append('image')
|
||||||
|
.attr('href', PLANE_IMG)
|
||||||
|
.attr('width', PLANE_SIZE)
|
||||||
|
.attr('height', PLANE_SIZE)
|
||||||
|
.attr('x', -PLANE_SIZE / 2)
|
||||||
|
.attr('y', -PLANE_SIZE / 2)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
.attr('opacity', 0);
|
||||||
|
|
||||||
|
const outGcs = geoPts.map(p => projection(p)).filter(Boolean);
|
||||||
|
const outFlipGlobe = outGcs.length >= 2 && outGcs[outGcs.length - 1][0] < outGcs[0][0];
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
rotateGlobeTo(destCentroid[0], destCentroid[1], dur),
|
||||||
|
animateReprojectingArc(outEl, outTip, geoPts, lineGen, dur, outFlipGlobe)
|
||||||
|
]);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
outEl.remove();
|
||||||
|
outTip.remove();
|
||||||
|
|
||||||
|
const targetPath = countryPaths.filter(d => effId(d) === destCode);
|
||||||
|
targetPath.transition().duration(500).attr('fill', VISITED_COLOR);
|
||||||
|
visitedCodes.add(destCode);
|
||||||
|
|
||||||
|
await delay(600);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const revGeoPts = [...geoPts].reverse();
|
||||||
|
|
||||||
|
const retEl = gAnim.append('path')
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', ARC_COLOR)
|
||||||
|
.attr('stroke-width', 2.5)
|
||||||
|
.attr('stroke-opacity', 0.8)
|
||||||
|
.attr('stroke-linecap', 'round')
|
||||||
|
.attr('stroke-dasharray', '10, 6');
|
||||||
|
const retTip = gAnim.append('image')
|
||||||
|
.attr('href', PLANE_IMG)
|
||||||
|
.attr('width', PLANE_SIZE)
|
||||||
|
.attr('height', PLANE_SIZE)
|
||||||
|
.attr('x', -PLANE_SIZE / 2)
|
||||||
|
.attr('y', -PLANE_SIZE / 2)
|
||||||
|
.attr('preserveAspectRatio', 'xMidYMid meet')
|
||||||
|
.attr('opacity', 0);
|
||||||
|
|
||||||
|
const retGcs = revGeoPts.map(p => projection(p)).filter(Boolean);
|
||||||
|
const retFlipGlobe = retGcs.length >= 2 && retGcs[retGcs.length - 1][0] < retGcs[0][0];
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
rotateGlobeTo(homeCentroid[0], homeCentroid[1], dur),
|
||||||
|
animateReprojectingArc(retEl, retTip, revGeoPts, lineGen, dur, retFlipGlobe)
|
||||||
|
]);
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
retEl.remove();
|
retEl.remove();
|
||||||
retTip.remove();
|
retTip.remove();
|
||||||
destDot.remove();
|
|
||||||
|
|
||||||
await delay(300);
|
await delay(300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startJourney() {
|
async function startJourney() {
|
||||||
|
const myId = ++animId;
|
||||||
isPlaying = true;
|
isPlaying = true;
|
||||||
isFinished = false;
|
isFinished = false;
|
||||||
isCancelled = false;
|
isCancelled = false;
|
||||||
|
visitedCodes = new Set();
|
||||||
|
|
||||||
|
const width = frameEl.clientWidth;
|
||||||
|
const height = frameEl.clientHeight;
|
||||||
|
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
gBase = svg.append('g');
|
||||||
|
gCountries = svg.append('g');
|
||||||
|
gAnim = svg.append('g');
|
||||||
|
|
||||||
|
setupProjection(width, height);
|
||||||
|
|
||||||
|
if (mode === 'globe' && homeFeature) {
|
||||||
|
const c = d3.geoCentroid(homeFeature);
|
||||||
|
projection.rotate([-c[0], -c[1]]);
|
||||||
|
pathFn = d3.geoPath().projection(projection);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMap();
|
||||||
|
|
||||||
const trips = MOCK_TRIPS;
|
const trips = MOCK_TRIPS;
|
||||||
const total = trips.length;
|
const total = trips.length;
|
||||||
|
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
if (isCancelled) break;
|
if (isCancelled || myId !== animId) break;
|
||||||
|
|
||||||
const trip = trips[i];
|
const trip = trips[i];
|
||||||
|
currentDateLabel = formatDateLabel(trip.date);
|
||||||
const destFeature = featuresById[trip.countryCode];
|
const destFeature = featuresById[trip.countryCode];
|
||||||
if (!destFeature) continue;
|
if (!destFeature) continue;
|
||||||
|
|
||||||
@@ -232,11 +528,11 @@
|
|||||||
await animateTrip(trip.countryCode, destFeature);
|
await animateTrip(trip.countryCode, destFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCancelled) {
|
if (!isCancelled && myId === animId) {
|
||||||
isFinished = true;
|
isFinished = true;
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
if (onprogress) onprogress({ index: trips.length, total: trips.length, label: 'Journey complete!' });
|
||||||
} else {
|
} else if (myId === animId) {
|
||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,23 +542,37 @@
|
|||||||
isPlaying = false;
|
isPlaying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replay() {
|
||||||
|
stopJourney();
|
||||||
|
setTimeout(() => startJourney(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(target) {
|
||||||
|
if (target === mode) return;
|
||||||
|
onmodechange?.(target);
|
||||||
|
stopJourney();
|
||||||
|
setTimeout(() => startJourney(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
stopJourney();
|
||||||
|
onclose?.();
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const width = frameEl.clientWidth;
|
const width = frameEl.clientWidth;
|
||||||
const height = frameEl.clientHeight;
|
const height = frameEl.clientHeight;
|
||||||
|
|
||||||
projection = d3.geoMercator();
|
setupProjection(width, height);
|
||||||
fitProjection(projection, width, height);
|
|
||||||
|
|
||||||
pathFn = d3.geoPath().projection(projection);
|
countriesData = feature(worldData, worldData.objects.countries)
|
||||||
|
|
||||||
const countries = feature(worldData, worldData.objects.countries)
|
|
||||||
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
|
||||||
|
|
||||||
countries.forEach(f => {
|
countriesData.forEach(f => {
|
||||||
if (!f.id) f.id = 'XK';
|
if (!f.id) f.id = 'XK';
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const f of countries) {
|
for (const f of countriesData) {
|
||||||
featuresById[effId(f)] = f;
|
featuresById[effId(f)] = f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,46 +584,32 @@
|
|||||||
.attr('height', height)
|
.attr('height', height)
|
||||||
.style('cursor', 'default');
|
.style('cursor', 'default');
|
||||||
|
|
||||||
g = svg.append('g');
|
gBase = svg.append('g');
|
||||||
|
gCountries = svg.append('g');
|
||||||
|
gAnim = svg.append('g');
|
||||||
|
|
||||||
countryPaths = g.selectAll('path')
|
renderMap();
|
||||||
.data(countries)
|
|
||||||
.join('path')
|
|
||||||
.attr('d', pathFn)
|
|
||||||
.attr('fill', d => effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
|
||||||
.attr('stroke', '#d4d4d4')
|
|
||||||
.attr('stroke-width', 0.5);
|
|
||||||
|
|
||||||
function renderMicrostates() {
|
|
||||||
g.selectAll('.micro-state-j').remove();
|
|
||||||
const threshold = 4;
|
|
||||||
countryPaths.each(function (d) {
|
|
||||||
if (effId(d) !== d.id) return;
|
|
||||||
const { width, height } = this.getBBox();
|
|
||||||
if (width < threshold && height < threshold) {
|
|
||||||
const [cx, cy] = pathFn.centroid(d);
|
|
||||||
g.append('circle')
|
|
||||||
.attr('class', 'micro-state-j')
|
|
||||||
.datum(d)
|
|
||||||
.attr('cx', cx)
|
|
||||||
.attr('cy', cy)
|
|
||||||
.attr('r', 2)
|
|
||||||
.attr('fill', effId(d) === HOME_CODE ? HOME_COLOR : UNVISITED)
|
|
||||||
.attr('stroke', '#94a3b8')
|
|
||||||
.attr('stroke-width', 0.5);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderMicrostates();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const { width, height } = entry.contentRect;
|
const { width, height } = entry.contentRect;
|
||||||
svg.attr('width', width).attr('height', height);
|
svg.attr('width', width).attr('height', height);
|
||||||
fitProjection(projection, width, height);
|
const prevProj = projection;
|
||||||
countryPaths.attr('d', pathFn);
|
if (mode === 'map') {
|
||||||
renderMicrostates();
|
projection = d3.geoMercator();
|
||||||
|
projection.fitSize([width, height], { type: 'Sphere' });
|
||||||
|
const s = projection.scale() * 1.5;
|
||||||
|
projection.scale(s).translate([width / 2, height * 0.70]);
|
||||||
|
} else {
|
||||||
|
const size = Math.min(width, height) * 0.92;
|
||||||
|
projection = d3.geoOrthographic()
|
||||||
|
.rotate(prevProj.rotate())
|
||||||
|
.fitSize([size, size], { type: 'Sphere' })
|
||||||
|
.translate([width / 2, height / 2]);
|
||||||
|
}
|
||||||
|
pathFn = d3.geoPath().projection(projection);
|
||||||
|
redrawBase();
|
||||||
|
if (mode === 'map') renderMicrostates();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,11 +625,25 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={frameEl} class="journey-frame">
|
<div bind:this={frameEl} class="journey-frame" class:globe-mode={mode === 'globe'}>
|
||||||
<button class="close-btn" onclick={() => { stopJourney(); onclose?.(); }}>✕</button>
|
<div class="top-label">
|
||||||
{#if isFinished}
|
{#if isFinished}
|
||||||
<div class="done-badge">Journey complete!</div>
|
Journey complete!
|
||||||
{/if}
|
{:else if currentDateLabel}
|
||||||
|
{currentDateLabel}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="control-bar">
|
||||||
|
<button class="control-btn" onclick={replay}>
|
||||||
|
⟳ Replay
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick={() => switchMode(mode === 'map' ? 'globe' : 'map')}>
|
||||||
|
{mode === 'map' ? 'Globe animation' : 'Map animation'}
|
||||||
|
</button>
|
||||||
|
<button class="control-btn" onclick={close}>
|
||||||
|
✕ Back to Journaling
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -345,47 +655,59 @@
|
|||||||
background: #a4c8e0;
|
background: #a4c8e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.journey-frame.globe-mode {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.journey-frame :global(svg) {
|
.journey-frame :global(svg) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.top-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 16px;
|
||||||
right: 12px;
|
left: 16px;
|
||||||
z-index: 10;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: rgba(0,0,0,0.55);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-btn:hover {
|
|
||||||
background: rgba(0,0,0,0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.done-badge {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: rgba(0,0,0,0.65);
|
background: rgba(0,0,0,0.65);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: var(--heading, sans-serif);
|
font-family: var(--heading, sans-serif);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
min-width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-bar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
background: #7c3aed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
<script>
|
<script>
|
||||||
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
|
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
|
||||||
import { getSelected } from '../layout/selection.svelte.js';
|
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
|
||||||
|
import worldData from 'world-atlas/countries-50m.json';
|
||||||
|
|
||||||
let hoveredSeg = $state(null);
|
let collapsed = $state(false);
|
||||||
|
|
||||||
const continentColors = {
|
const continentColors = {
|
||||||
'Europe': '#6366f1',
|
'Europe': '#3b82f6',
|
||||||
'Asia': '#f43f5e',
|
'Asia': '#ef4444',
|
||||||
'Africa': '#fb923c',
|
'Africa': '#f97316',
|
||||||
'N. America': '#06b6d4',
|
'N. America': '#ec4899',
|
||||||
'S. America': '#f59e0b',
|
'S. America': '#eab308',
|
||||||
'Oceania': '#8b5cf6'
|
'Oceania': '#a16207'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const countryNameById = $derived.by(() => {
|
||||||
|
const map = { XK: 'Kosovo' };
|
||||||
|
for (const g of worldData.objects.countries.geometries) {
|
||||||
|
map[g.id] = g.properties?.name || g.id;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
let visitedCountries = $derived(
|
||||||
|
[...getSelected()].map(id => countryNameById[id]).filter(Boolean).sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
let visitedByContinent = $derived.by(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const id of getSelected()) {
|
||||||
|
const cont = getContinent(id);
|
||||||
|
if (cont) {
|
||||||
|
if (!map[cont]) map[cont] = [];
|
||||||
|
map[cont].push(countryNameById[id] || id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cont of Object.keys(map)) {
|
||||||
|
map[cont].sort();
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
let counts = $derived.by(() => {
|
let counts = $derived.by(() => {
|
||||||
const c = {};
|
const c = {};
|
||||||
for (const cont of CONTINENTS) c[cont] = 0;
|
for (const cont of CONTINENTS) c[cont] = 0;
|
||||||
@@ -36,9 +64,11 @@
|
|||||||
if (angle > 0) {
|
if (angle > 0) {
|
||||||
const startDeg = deg;
|
const startDeg = deg;
|
||||||
const endDeg = deg + angle;
|
const endDeg = deg + angle;
|
||||||
|
const midDeg = (startDeg + endDeg) / 2;
|
||||||
|
const rad = (midDeg - 90) * Math.PI / 180;
|
||||||
const sr = (startDeg - 90) * Math.PI / 180;
|
const sr = (startDeg - 90) * Math.PI / 180;
|
||||||
const er = (endDeg - 90) * Math.PI / 180;
|
const er = (endDeg - 90) * Math.PI / 180;
|
||||||
const cx = 50, cy = 50, outerR = 44, innerR = 22;
|
const cx = 90, cy = 90, outerR = 65, innerR = 30;
|
||||||
const x1 = cx + outerR * Math.cos(sr);
|
const x1 = cx + outerR * Math.cos(sr);
|
||||||
const y1 = cy + outerR * Math.sin(sr);
|
const y1 = cy + outerR * Math.sin(sr);
|
||||||
const x2 = cx + outerR * Math.cos(er);
|
const x2 = cx + outerR * Math.cos(er);
|
||||||
@@ -49,7 +79,9 @@
|
|||||||
const y4 = cy + innerR * Math.sin(sr);
|
const y4 = cy + innerR * Math.sin(sr);
|
||||||
const largeArc = angle > 180 ? 1 : 0;
|
const largeArc = angle > 180 ? 1 : 0;
|
||||||
const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`;
|
const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`;
|
||||||
segs.push({ cont, color: continentColors[cont], path, angle });
|
const lx = cx + 82 * Math.cos(rad);
|
||||||
|
const ly = cy + 82 * Math.sin(rad);
|
||||||
|
segs.push({ cont, color: continentColors[cont], path, lx, ly, angle });
|
||||||
deg += angle;
|
deg += angle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,199 +89,296 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card">
|
<div class="panel" class:collapsed>
|
||||||
<!-- count -->
|
<button class="collapse-btn" onclick={() => collapsed = !collapsed} data-tip={collapsed ? 'see statistics' : 'close statistics'}>
|
||||||
<div class="stat-block">
|
{collapsed ? '◀' : '▶'}
|
||||||
<span class="big-num">{total}</span>
|
</button>
|
||||||
<span class="stat-sub">countries visited</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="vdivider"></div>
|
{#if !collapsed}
|
||||||
|
<div class="panel-content">
|
||||||
|
<h2 class="headline">your statistics</h2>
|
||||||
|
|
||||||
<!-- world % -->
|
<div class="total-bar-bg">
|
||||||
<div class="stat-block">
|
<div class="total-bar-fill" style="width: {pct}%"></div>
|
||||||
<span class="big-num accent">{pct}%</span>
|
<span class="bar-pct">{pct}%</span>
|
||||||
<span class="stat-sub">of the world</span>
|
</div>
|
||||||
</div>
|
<span class="total-bar-text">{total} / {grandTotal} countries visited</span>
|
||||||
|
|
||||||
<div class="vdivider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<!-- donut -->
|
<span class="bar-label">by continent</span>
|
||||||
<div class="donut-block">
|
{#each CONTINENTS as continent}
|
||||||
<svg viewBox="0 0 100 100" class="donut-svg">
|
{@const contTotal = continentTotals[continent]}
|
||||||
{#if segments.length > 0}
|
<div class="row tooltip-wrap">
|
||||||
{#each segments as seg}
|
<span class="dot" style="background: {continentColors[continent]}"></span>
|
||||||
<g class="seg-group"
|
<span class="label">{continent}</span>
|
||||||
onmouseenter={() => hoveredSeg = seg}
|
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
|
||||||
onmouseleave={() => hoveredSeg = null}>
|
{#if visitedByContinent[continent]?.length > 0}
|
||||||
<path d={seg.path} fill={seg.color} />
|
<div class="tooltip-list">
|
||||||
</g>
|
{#each visitedByContinent[continent].slice(0, 10) as country}
|
||||||
{/each}
|
<span class="tooltip-item">{country}</span>
|
||||||
<circle cx="50" cy="50" r="22" fill="#fff" />
|
{/each}
|
||||||
{:else}
|
{#if visitedByContinent[continent].length > 10}
|
||||||
<circle cx="50" cy="50" r="44" fill="#f1f5f9" />
|
<span class="tooltip-item tooltip-more">...</span>
|
||||||
<circle cx="50" cy="50" r="22" fill="#fff" />
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
</svg>
|
{/if}
|
||||||
|
|
||||||
<div class="donut-info">
|
|
||||||
<span class="section-label">by continent</span>
|
|
||||||
{#if hoveredSeg}
|
|
||||||
<div class="tooltip" style="--dot:{hoveredSeg.color}">
|
|
||||||
<span class="tt-name">{hoveredSeg.cont}</span>
|
|
||||||
<span class="tt-val">{counts[hoveredSeg.cont]} / {continentTotals[hoveredSeg.cont]}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<span class="hint">hover a slice</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="vdivider"></div>
|
<div class="donut-wrap">
|
||||||
|
{#if segments.length > 0}
|
||||||
|
<svg viewBox="-25 -25 230 230" class="donut-svg">
|
||||||
|
{#each segments as seg}
|
||||||
|
<g class="seg-group">
|
||||||
|
<path d={seg.path} fill={seg.color} />
|
||||||
|
<text x={seg.lx} y={seg.ly} text-anchor="middle" dominant-baseline="middle" class="donut-label" style="font-size: {seg.angle < 20 ? 12 : 15}px">{seg.cont}</text>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="-25 -25 230 230" class="donut-svg">
|
||||||
|
<circle cx="90" cy="90" r="65" fill="var(--border)" />
|
||||||
|
<circle cx="90" cy="90" r="30" fill="var(--bg-raised)" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- progress bar -->
|
<div class="divider"></div>
|
||||||
<div class="bar-block">
|
|
||||||
<span class="section-label" style="margin-bottom:6px">world coverage</span>
|
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</div>
|
||||||
<div class="bar-bg">
|
|
||||||
<div class="bar-fill" style="width:{pct}%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="disclaimer">All UN countries · Kosovo · HK · Taiwan</span>
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.card {
|
.panel {
|
||||||
position: absolute;
|
flex: 0 0 min(360px, 25vw);
|
||||||
top: 16px;
|
background: var(--bg-raised);
|
||||||
left: 50%;
|
border-left: 1px solid var(--border);
|
||||||
transform: translateX(-50%);
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.10), 0 1px 4px rgba(0,0,0,0.06);
|
|
||||||
border: 1px solid rgba(0,0,0,0.06);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0 4px;
|
|
||||||
height: 110px;
|
|
||||||
z-index: 10;
|
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
white-space: nowrap;
|
transition: flex-basis 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-block {
|
.panel.collapsed {
|
||||||
display: flex;
|
flex: 0 0 28px;
|
||||||
flex-direction: column;
|
border-left: none;
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 0 36px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.big-num {
|
.panel-content {
|
||||||
font-size: 40px;
|
flex: 1;
|
||||||
font-weight: 300;
|
padding: 24px 28px;
|
||||||
letter-spacing: -2px;
|
overflow-y: auto;
|
||||||
color: var(--text-h);
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
padding: 14px 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
transition: background 0.15s ease, padding 0.15s ease;
|
||||||
|
margin-top: 24px;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.big-num.accent { color: var(--accent); }
|
|
||||||
|
|
||||||
.stat-sub {
|
.collapse-btn:hover {
|
||||||
|
background: var(--lavender-bg);
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-btn::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 8px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--text-h);
|
||||||
|
color: var(--bg-raised);
|
||||||
|
font-family: var(--sans);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
color: var(--text-sub);
|
padding: 6px 12px;
|
||||||
letter-spacing: 0.03em;
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vdivider {
|
.collapse-btn:hover::after {
|
||||||
width: 1px;
|
opacity: 1;
|
||||||
height: 56px;
|
|
||||||
background: var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* donut */
|
.headline {
|
||||||
.donut-block {
|
font-family: var(--heading);
|
||||||
display: flex;
|
font-size: var(--text-sm);
|
||||||
flex-direction: row;
|
font-weight: 400;
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 0 28px;
|
|
||||||
}
|
|
||||||
.donut-svg {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.seg-group { cursor: pointer; }
|
|
||||||
.seg-group:hover path { opacity: 0.8; }
|
|
||||||
|
|
||||||
.donut-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 130px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.tooltip::before {
|
|
||||||
content: '';
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--dot);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.tt-name { font-weight: 400; color: var(--text-h); }
|
|
||||||
.tt-val { font-weight: 300; color: var(--text-sub); }
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-sub);
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.bar-label {
|
||||||
font-size: 12px;
|
font-family: var(--sans);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
color: var(--text-sub);
|
color: var(--text-sub);
|
||||||
opacity: 0.45;
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* bar */
|
.total-bar-bg {
|
||||||
.bar-block {
|
position: relative;
|
||||||
display: flex;
|
height: 28px;
|
||||||
flex-direction: column;
|
background: var(--accent-bg);
|
||||||
padding: 0 28px;
|
border-radius: 10px;
|
||||||
gap: 0;
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
.bar-bg {
|
|
||||||
width: 100%;
|
|
||||||
height: 5px;
|
|
||||||
background: var(--bg-subtle);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
}
|
||||||
.bar-fill {
|
|
||||||
|
.total-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--accent), #a78bfa);
|
background: linear-gradient(90deg, var(--accent-dark), var(--lavender));
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.disclaimer {
|
|
||||||
font-size: 11px;
|
.bar-pct {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-bar-text {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-h);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
font-weight: 400;
|
||||||
color: var(--text-sub);
|
color: var(--text-sub);
|
||||||
opacity: 0.5;
|
font-size: var(--text-xs);
|
||||||
letter-spacing: 0.02em;
|
}
|
||||||
|
|
||||||
|
.donut-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 8px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-svg {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.donut-label {
|
||||||
|
fill: var(--text-h);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-weight: 300;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-group:hover .donut-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-list {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
background: var(--text-h);
|
||||||
|
color: var(--bg-raised);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
z-index: 20;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-wrap:hover .tooltip-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-item {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-item + .tooltip-item {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-sub);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user