add side stats panel
This commit is contained in:
@@ -23,7 +23,7 @@
|
|||||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
* Disable this if you'd like to use dynamic types.
|
* Disable this if you'd like to use dynamic types.
|
||||||
*/
|
*/
|
||||||
"checkJs": true
|
"checkJs": false
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Use global.d.ts instead of compilerOptions.types
|
* Use global.d.ts instead of compilerOptions.types
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Layout from './lib/Layout.svelte';
|
import Layout from './lib/layout/Layout.svelte';
|
||||||
import WorldMap from './lib/WorldMap.svelte';
|
import WorldMap from './lib/world-map/WorldMap.svelte';
|
||||||
|
import StatsPanel from './lib/world-map/StatsPanel.svelte';
|
||||||
import Timeline from './lib/Timeline.svelte';
|
import Timeline from './lib/Timeline.svelte';
|
||||||
|
|
||||||
let screen = $state('worldmap');
|
let screen = $state('worldmap');
|
||||||
@@ -12,8 +13,27 @@
|
|||||||
|
|
||||||
<Layout {screen} {onNavigate}>
|
<Layout {screen} {onNavigate}>
|
||||||
{#if screen === 'worldmap'}
|
{#if screen === 'worldmap'}
|
||||||
<WorldMap />
|
<div class="worldmap-page">
|
||||||
|
<div class="map-area">
|
||||||
|
<WorldMap />
|
||||||
|
</div>
|
||||||
|
<StatsPanel />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Timeline />
|
<Timeline />
|
||||||
{/if}
|
{/if}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.worldmap-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -9,8 +9,11 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-right: 24px;
|
padding-right: 24px;
|
||||||
background: #dce8f0;
|
background: #334155;
|
||||||
font: 14px/1.5 sans-serif;
|
font: 15px/1.6 sans-serif;
|
||||||
color: #555;
|
color: #cbd5e1;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -30,8 +30,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
background: #dce8f0;
|
background: #1e2937;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
@@ -50,7 +53,7 @@
|
|||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font: 700 20px/1.2 sans-serif;
|
font: 700 20px/1.2 sans-serif;
|
||||||
color: #1f2937;
|
color: #f1f5f9;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@
|
|||||||
.segmented {
|
.segmented {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
@@ -90,7 +93,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: 500 16px/1.4 sans-serif;
|
font: 500 16px/1.4 sans-serif;
|
||||||
color: #555;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
28
src/lib/layout/selection.svelte.js
Normal file
28
src/lib/layout/selection.svelte.js
Normal 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;
|
||||||
|
}
|
||||||
204
src/lib/world-map/StatsPanel.svelte
Normal file
204
src/lib/world-map/StatsPanel.svelte
Normal 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>
|
||||||
219
src/lib/world-map/WorldMap.svelte
Normal file
219
src/lib/world-map/WorldMap.svelte
Normal 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>
|
||||||
213
src/lib/world-map/continents.js
Normal file
213
src/lib/world-map/continents.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user