add side stats panel

This commit is contained in:
2026-06-09 15:56:01 +09:00
parent 8976b94c41
commit 65e16f3502
10 changed files with 701 additions and 155 deletions

View File

@@ -23,7 +23,7 @@
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable this if you'd like to use dynamic types.
*/
"checkJs": true
"checkJs": false
},
/**
* Use global.d.ts instead of compilerOptions.types

View File

@@ -1,6 +1,7 @@
<script>
import Layout from './lib/Layout.svelte';
import WorldMap from './lib/WorldMap.svelte';
import Layout from './lib/layout/Layout.svelte';
import WorldMap from './lib/world-map/WorldMap.svelte';
import StatsPanel from './lib/world-map/StatsPanel.svelte';
import Timeline from './lib/Timeline.svelte';
let screen = $state('worldmap');
@@ -12,8 +13,27 @@
<Layout {screen} {onNavigate}>
{#if screen === 'worldmap'}
<WorldMap />
<div class="worldmap-page">
<div class="map-area">
<WorldMap />
</div>
<StatsPanel />
</div>
{:else}
<Timeline />
{/if}
</Layout>
<style>
.worldmap-page {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
}
.map-area {
flex: 1;
overflow: hidden;
}
</style>

View File

@@ -1,144 +0,0 @@
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-110m.json';
let frameEl;
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
const s = proj.scale() * 1.5;
proj.scale(s).translate([w / 2, h * 0.70]);
}
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
const projection = d3.geoMercator();
fitProjection(projection, width, height);
const path = d3.geoPath().projection(projection);
const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => f.id !== '010');
const svg = d3.select(frameEl)
.append('svg')
.attr('width', width)
.attr('height', height);
const g = svg.append('g');
const selected = new Set();
const tooltip = d3.select(frameEl)
.append('div')
.attr('class', 'tooltip')
.style('display', 'none');
function updateFill(sel) {
sel.attr('fill', d => selected.has(d.id) ? '#22c55e' : '#ffffff');
}
g.selectAll('path')
.data(countries)
.join('path')
.attr('d', path)
.attr('fill', '#ffffff')
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5)
.on('click', (event, d) => {
if (selected.has(d.id)) {
selected.delete(d.id);
} else {
selected.add(d.id);
}
updateFill(d3.select(event.currentTarget));
})
.on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#16a34a' : '#f0f6fa');
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
const [x, y] = d3.pointer(event, frameEl);
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
})
.on('mouseleave', (event, d) => {
d3.select(event.currentTarget).attr('fill', selected.has(d.id) ? '#22c55e' : '#ffffff');
tooltip.style('display', 'none');
});
const zoom = d3.zoom()
.scaleExtent([1, 8])
.on('zoom', (event) => {
g.attr('transform', event.transform);
});
svg.call(zoom);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
const countryPaths = g.selectAll('path');
countryPaths.attr('d', path);
updateFill(countryPaths);
}
});
observer.observe(frameEl);
return () => {
observer.disconnect();
svg.remove();
};
});
</script>
<div class="wrapper">
<div bind:this={frameEl} class="map-frame"></div>
</div>
<style>
.wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.map-frame {
width: min(100%, calc((100vh - 96px) * 16 / 9));
aspect-ratio: 16 / 9;
overflow: hidden;
position: relative;
background: #dce8f0;
}
.map-frame :global(svg) {
display: block;
cursor: grab;
}
.map-frame :global(svg:active) {
cursor: grabbing;
}
.map-frame :global(svg path) {
cursor: pointer;
}
.map-frame :global(.tooltip) {
position: absolute;
padding: 4px 10px;
background: #1f2937;
color: #fff;
font: 14px/1.4 sans-serif;
border-radius: 4px;
pointer-events: none;
white-space: nowrap;
}
</style>

View File

@@ -9,8 +9,11 @@
align-items: center;
justify-content: flex-end;
padding-right: 24px;
background: #dce8f0;
font: 14px/1.5 sans-serif;
color: #555;
background: #334155;
font: 15px/1.6 sans-serif;
color: #cbd5e1;
position: relative;
z-index: 10;
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -30,8 +30,11 @@
display: flex;
align-items: center;
padding: 0 24px;
background: #dce8f0;
background: #1e2937;
gap: 16px;
position: relative;
z-index: 10;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.left {
@@ -50,7 +53,7 @@
.app-name {
font: 700 20px/1.2 sans-serif;
color: #1f2937;
color: #f1f5f9;
white-space: nowrap;
}
@@ -63,7 +66,7 @@
.segmented {
position: relative;
display: flex;
background: rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.1);
border-radius: 999px;
padding: 4px;
width: 300px;
@@ -90,7 +93,7 @@
background: none;
cursor: pointer;
font: 500 16px/1.4 sans-serif;
color: #555;
color: #cbd5e1;
}
.right {

View File

@@ -0,0 +1,28 @@
let selected = $state(new Set());
let totalCountries = $state(0);
export function toggle(id) {
const next = new Set(selected);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selected = next;
}
export function clearAll() {
selected = new Set();
}
export function getSelected() {
return selected;
}
export function setTotalCount(n) {
totalCountries = n;
}
export function getTotalCount() {
return totalCountries;
}

View File

@@ -0,0 +1,204 @@
<script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
const continentColors = {
'Europe': '#3b82f6',
'Asia': '#ef4444',
'Africa': '#f97316',
'N. America': '#22c55e',
'S. America': '#eab308',
'Oceania': '#a855f7'
};
let counts = $derived.by(() => {
const c = {};
for (const cont of CONTINENTS) c[cont] = 0;
for (const id of getSelected()) {
const cont = getContinent(id);
if (cont) c[cont]++;
}
return c;
});
let total = $derived(getSelected().size);
let grandTotal = $derived(Object.values(continentTotals).reduce((a, b) => a + b, 0));
let pct = $derived(grandTotal > 0 ? Math.round(total / grandTotal * 100) : 0);
let donutStyle = $derived.by(() => {
if (total === 0) return 'background: #e2e8f0';
let deg = 0;
const stops = [];
for (const cont of CONTINENTS) {
const angle = counts[cont] / total * 360;
if (angle > 0) {
stops.push(`${continentColors[cont]} ${deg}deg ${deg + angle}deg`);
deg += angle;
}
}
return `background: conic-gradient(${stops.join(', ')})`;
});
</script>
<div class="panel">
<h2 class="headline">your statistics</h2>
<span class="bar-label">visited countries</span>
<div class="total-bar-wrap">
<div class="total-bar-bg">
<div class="total-bar-fill" style="width: {pct}%"></div>
</div>
<span class="total-bar-text">{total} / {grandTotal}</span>
</div>
<div class="divider"></div>
<span class="bar-label">by continent</span>
{#each CONTINENTS as continent}
{@const contTotal = continentTotals[continent]}
<div class="row">
<span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
</div>
{/each}
<div class="donut-wrap">
<div class="donut" style={donutStyle}>
<div class="donut-hole"></div>
</div>
</div>
<div class="divider"></div>
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</div>
</div>
<style>
.panel {
flex: 0 0 min(360px, 25vw);
background: #f8fafc;
border-left: 1px solid #dce8f0;
padding: 24px 28px;
overflow-y: auto;
font-family: sans-serif;
}
.headline {
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #1f2937;
margin: 0 0 20px 0;
}
.bar-label {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
display: block;
margin-bottom: 8px;
}
.total-bar-wrap {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.total-bar-bg {
flex: 1;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
.total-bar-fill {
height: 100%;
background: #3b82f6;
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.total-bar-text {
font-size: 15px;
font-weight: 700;
color: #1f2937;
white-space: nowrap;
}
.divider {
height: 1px;
background: #e2e8f0;
margin: 16px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
flex: 1;
font-size: 14px;
color: #334155;
}
.value {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.total {
font-weight: 350;
color: #94a3b8;
font-size: 13px;
}
.donut-wrap {
display: flex;
justify-content: center;
margin: 24px 0;
}
.donut {
width: 130px;
height: 130px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
outline: 2px solid #fff;
}
.donut-hole {
width: 60px;
height: 60px;
border-radius: 50%;
background: #f8fafc;
}
.disclaimer {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
text-align: center;
}
</style>

View File

@@ -0,0 +1,219 @@
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json';
import { getSelected, toggle, setTotalCount } from '../layout/selection.svelte.js';
const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States
'060': '826', // Bermuda -> United Kingdom
'086': '826', // Br. Indian Ocean Ter. -> United Kingdom
'092': '826', // British Virgin Is. -> United Kingdom
'136': '826', // Cayman Is. -> United Kingdom
'184': '554', // Cook Is. -> New Zealand
'234': '208', // Faeroe Is. -> Denmark
'238': '826', // Falkland Is. -> United Kingdom
'239': '826', // S. Geo. and the Is. -> United Kingdom
'248': '246', // Aland -> Finland
'258': '250', // Fr. Polynesia -> France
'260': '250', // Fr. S. Antarctic Lands -> France
'304': '208', // Greenland -> Denmark
'316': '840', // Guam -> United States
'334': '036', // Heard I. and McDonald Is. -> Australia
'446': '156', // Macao -> China
'500': '826', // Montserrat -> United Kingdom
'531': '528', // Curacao -> Netherlands
'533': '528', // Aruba -> Netherlands
'534': '528', // Sint Maarten -> Netherlands
'540': '250', // New Caledonia -> France
'570': '554', // Niue -> New Zealand
'574': '036', // Norfolk Island -> Australia
'580': '840', // N. Mariana Is. -> United States
'612': '826', // Pitcairn Is. -> United Kingdom
'630': '840', // Puerto Rico -> United States
'652': '250', // St-Barthelemy -> France
'654': '826', // Saint Helena -> United Kingdom
'660': '826', // Anguilla -> United Kingdom
'663': '250', // St-Martin -> France
'666': '250', // St. Pierre and Miquelon -> France
'796': '826', // Turks and Caicos Is. -> United Kingdom
'831': '826', // Guernsey -> United Kingdom
'832': '826', // Jersey -> United Kingdom
'833': '826', // Isle of Man -> United Kingdom
'850': '840', // U.S. Virgin Is. -> United States
'876': '250', // Wallis and Futuna Is. -> France
};
function effId(d) {
return TERRITORY_PARENT[d.id] || d.id;
}
let frameEl;
function fitProjection(proj, w, h) {
proj.fitSize([w, h], { type: 'Sphere' });
const s = proj.scale() * 1.5;
proj.scale(s).translate([w / 2, h * 0.70]);
}
onMount(() => {
const width = frameEl.clientWidth;
const height = frameEl.clientHeight;
const projection = d3.geoMercator();
fitProjection(projection, width, height);
const path = d3.geoPath().projection(projection);
const countries = feature(worldData, worldData.objects.countries)
.features.filter(f => (f.id || f.properties.name === 'Kosovo') && f.id !== '010');
countries.forEach(f => {
if (!f.id) f.id = 'XK';
});
const sovereignIds = new Set(countries.map(f => effId(f)));
setTotalCount(sovereignIds.size);
const svg = d3.select(frameEl)
.append('svg')
.attr('width', width)
.attr('height', height);
const g = svg.append('g');
const tooltip = d3.select(frameEl)
.append('div')
.attr('class', 'tooltip')
.style('display', 'none');
function updateFill(sel) {
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
}
function attachEvents(sel) {
sel
.on('click', (event, d) => {
toggle(effId(d));
updateFill(d3.select(event.currentTarget));
})
.on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
const [x, y] = d3.pointer(event, frameEl);
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
})
.on('mouseleave', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff');
tooltip.style('display', 'none');
});
}
const paths = g.selectAll('path')
.data(countries)
.join('path')
.attr('d', path)
.attr('fill', '#ffffff')
.attr('stroke', '#d4d4d4')
.attr('stroke-width', 0.5);
attachEvents(paths);
function renderMicrostates() {
g.selectAll('.micro-state').remove();
const threshold = Math.max(4, 16 / d3.zoomTransform(svg.node()).k);
paths.each(function (d) {
if (effId(d) !== d.id) return;
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d);
const c = g.append('circle')
.attr('class', 'micro-state')
.datum(d)
.attr('cx', cx)
.attr('cy', cy)
.attr('r', 3)
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff')
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c);
}
});
}
renderMicrostates();
const zoom = d3.zoom()
.scaleExtent([1, 32])
.on('zoom', (event) => {
g.attr('transform', event.transform);
renderMicrostates();
});
svg.call(zoom);
svg.on('dblclick.zoom', null);
svg.on('dblclick', (event) => {
const [x, y] = d3.pointer(event);
svg.transition().duration(300).call(zoom.scaleBy, 2, [x, y]);
});
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
svg.attr('width', width).attr('height', height);
fitProjection(projection, width, height);
const countryPaths = g.selectAll('path');
countryPaths.attr('d', path);
updateFill(countryPaths);
renderMicrostates();
}
});
observer.observe(frameEl);
return () => {
observer.disconnect();
svg.remove();
};
});
</script>
<div bind:this={frameEl} class="map-frame"></div>
<style>
.map-frame {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
background: #a4c8e0;
}
.map-frame :global(svg) {
display: block;
cursor: grab;
}
.map-frame :global(svg:active) {
cursor: grabbing;
}
.map-frame :global(svg path) {
cursor: pointer;
}
.map-frame :global(.tooltip) {
position: absolute;
padding: 4px 10px;
background: #1f2937;
color: #fff;
font: 14px/1.4 sans-serif;
border-radius: 4px;
pointer-events: none;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,213 @@
export const CONTINENTS = ['Europe', 'Asia', 'Africa', 'N. America', 'S. America', 'Oceania'];
const map = {
'004': 'Asia', // Afghanistan
'008': 'Europe', // Albania
'012': 'Africa', // Algeria
'020': 'Europe', // Andorra
'024': 'Africa', // Angola
'028': 'N. America', // Antigua and Barb.
'031': 'Asia', // Azerbaijan
'032': 'S. America', // Argentina
'036': 'Oceania', // Australia
'040': 'Europe', // Austria
'044': 'N. America', // Bahamas
'048': 'Asia', // Bahrain
'050': 'Asia', // Bangladesh
'051': 'Asia', // Armenia
'052': 'N. America', // Barbados
'056': 'Europe', // Belgium
'064': 'Asia', // Bhutan
'068': 'S. America', // Bolivia
'070': 'Europe', // Bosnia and Herz.
'072': 'Africa', // Botswana
'076': 'S. America', // Brazil
'084': 'N. America', // Belize
'090': 'Oceania', // Solomon Is.
'096': 'Asia', // Brunei
'100': 'Europe', // Bulgaria
'104': 'Asia', // Myanmar
'108': 'Africa', // Burundi
'112': 'Europe', // Belarus
'116': 'Asia', // Cambodia
'120': 'Africa', // Cameroon
'124': 'N. America', // Canada
'132': 'Africa', // Cabo Verde
'140': 'Africa', // Central African Rep.
'144': 'Asia', // Sri Lanka
'148': 'Africa', // Chad
'152': 'S. America', // Chile
'156': 'Asia', // China
'158': 'Asia', // Taiwan
'170': 'S. America', // Colombia
'174': 'Africa', // Comoros
'178': 'Africa', // Congo
'180': 'Africa', // Dem. Rep. Congo
'188': 'N. America', // Costa Rica
'191': 'Europe', // Croatia
'192': 'N. America', // Cuba
'196': 'Asia', // Cyprus
'203': 'Europe', // Czechia
'204': 'Africa', // Benin
'208': 'Europe', // Denmark
'212': 'N. America', // Dominica
'214': 'N. America', // Dominican Rep.
'218': 'S. America', // Ecuador
'222': 'N. America', // El Salvador
'226': 'Africa', // Eq. Guinea
'231': 'Africa', // Ethiopia
'232': 'Africa', // Eritrea
'233': 'Europe', // Estonia
'242': 'Oceania', // Fiji
'246': 'Europe', // Finland
'250': 'Europe', // France
'262': 'Africa', // Djibouti
'266': 'Africa', // Gabon
'268': 'Asia', // Georgia
'270': 'Africa', // Gambia
'275': 'Asia', // Palestine
'276': 'Europe', // Germany
'288': 'Africa', // Ghana
'296': 'Oceania', // Kiribati
'300': 'Europe', // Greece
'308': 'N. America', // Grenada
'320': 'N. America', // Guatemala
'324': 'Africa', // Guinea
'328': 'S. America', // Guyana
'332': 'N. America', // Haiti
'336': 'Europe', // Vatican
'340': 'N. America', // Honduras
'344': 'Asia', // Hong Kong
'348': 'Europe', // Hungary
'352': 'Europe', // Iceland
'356': 'Asia', // India
'360': 'Asia', // Indonesia
'364': 'Asia', // Iran
'368': 'Asia', // Iraq
'372': 'Europe', // Ireland
'376': 'Asia', // Israel
'380': 'Europe', // Italy
'384': 'Africa', // Cote d'Ivoire
'388': 'N. America', // Jamaica
'392': 'Asia', // Japan
'398': 'Asia', // Kazakhstan
'400': 'Asia', // Jordan
'404': 'Africa', // Kenya
'408': 'Asia', // North Korea
'410': 'Asia', // South Korea
'414': 'Asia', // Kuwait
'417': 'Asia', // Kyrgyzstan
'418': 'Asia', // Laos
'422': 'Asia', // Lebanon
'426': 'Africa', // Lesotho
'428': 'Europe', // Latvia
'430': 'Africa', // Liberia
'434': 'Africa', // Libya
'438': 'Europe', // Liechtenstein
'440': 'Europe', // Lithuania
'442': 'Europe', // Luxembourg
'450': 'Africa', // Madagascar
'454': 'Africa', // Malawi
'458': 'Asia', // Malaysia
'462': 'Asia', // Maldives
'466': 'Africa', // Mali
'470': 'Europe', // Malta
'478': 'Africa', // Mauritania
'480': 'Africa', // Mauritius
'484': 'N. America', // Mexico
'492': 'Europe', // Monaco
'496': 'Asia', // Mongolia
'498': 'Europe', // Moldova
'499': 'Europe', // Montenegro
'504': 'Africa', // Morocco
'508': 'Africa', // Mozambique
'512': 'Asia', // Oman
'516': 'Africa', // Namibia
'520': 'Oceania', // Nauru
'524': 'Asia', // Nepal
'528': 'Europe', // Netherlands
'548': 'Oceania', // Vanuatu
'554': 'Oceania', // New Zealand
'558': 'N. America', // Nicaragua
'562': 'Africa', // Niger
'566': 'Africa', // Nigeria
'578': 'Europe', // Norway
'583': 'Oceania', // Micronesia
'584': 'Oceania', // Marshall Is.
'585': 'Oceania', // Palau
'586': 'Asia', // Pakistan
'591': 'N. America', // Panama
'598': 'Oceania', // Papua New Guinea
'600': 'S. America', // Paraguay
'604': 'S. America', // Peru
'608': 'Asia', // Philippines
'616': 'Europe', // Poland
'620': 'Europe', // Portugal
'624': 'Africa', // Guinea-Bissau
'626': 'Asia', // Timor-Leste
'634': 'Asia', // Qatar
'642': 'Europe', // Romania
'643': 'Europe', // Russia
'646': 'Africa', // Rwanda
'659': 'N. America', // St. Kitts and Nevis
'662': 'N. America', // Saint Lucia
'670': 'N. America', // St. Vin. and Gren.
'674': 'Europe', // San Marino
'678': 'Africa', // Sao Tome and Principe
'682': 'Asia', // Saudi Arabia
'686': 'Africa', // Senegal
'688': 'Europe', // Serbia
'690': 'Africa', // Seychelles
'694': 'Africa', // Sierra Leone
'702': 'Asia', // Singapore
'703': 'Europe', // Slovakia
'704': 'Asia', // Vietnam
'705': 'Europe', // Slovenia
'706': 'Africa', // Somalia
'710': 'Africa', // South Africa
'716': 'Africa', // Zimbabwe
'724': 'Europe', // Spain
'728': 'Africa', // S. Sudan
'729': 'Africa', // Sudan
'732': 'Africa', // W. Sahara
'740': 'S. America', // Suriname
'748': 'Africa', // eSwatini
'752': 'Europe', // Sweden
'756': 'Europe', // Switzerland
'760': 'Asia', // Syria
'762': 'Asia', // Tajikistan
'764': 'Asia', // Thailand
'768': 'Africa', // Togo
'776': 'Oceania', // Tonga
'780': 'N. America', // Trinidad and Tobago
'784': 'Asia', // United Arab Emirates
'788': 'Africa', // Tunisia
'792': 'Asia', // Turkey
'795': 'Asia', // Turkmenistan
'800': 'Africa', // Uganda
'804': 'Europe', // Ukraine
'807': 'Europe', // Macedonia
'818': 'Africa', // Egypt
'826': 'Europe', // United Kingdom
'834': 'Africa', // Tanzania
'840': 'N. America', // United States of America
'854': 'Africa', // Burkina Faso
'858': 'S. America', // Uruguay
'860': 'Asia', // Uzbekistan
'862': 'S. America', // Venezuela
'882': 'Oceania', // Samoa
'887': 'Asia', // Yemen
'894': 'Africa', // Zambia
'XK': 'Europe', // Kosovo
};
export const continentTotals = CONTINENTS.reduce((acc, c) => (acc[c] = 0, acc), {});
for (const id of Object.keys(map)) {
const cont = map[id];
if (cont) continentTotals[cont]++;
}
export function getContinent(id) {
return map[id] ?? null;
}