307 lines
9.3 KiB
Svelte
307 lines
9.3 KiB
Svelte
<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, setTotalCount, getFlashing } from '../layout/selection.svelte.js';
|
|
import { getUserProfile } from '../auth/userStore.svelte.js';
|
|
import { nameToId } from '../shared/countries.js';
|
|
import homeIconUrl from '../../assets/home.png';
|
|
import crayonCursorUrl from '../../assets/logo-cursor.png';
|
|
|
|
let { onCountryClick = (_name) => {} } = $props();
|
|
|
|
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;
|
|
}
|
|
|
|
const HOME_COLOR = '#8b5cf6';
|
|
const HOME_COLOR_HOVER = '#7c3aed';
|
|
const VISITED_COLOR = '#22c55e';
|
|
const VISITED_COLOR_HOVER = '#16a34a';
|
|
const UNVISITED_COLOR = '#ffffff';
|
|
const UNVISITED_COLOR_HOVER = '#f0f6fa';
|
|
|
|
function countryColor(d, sel, homeCode) {
|
|
const id = effId(d);
|
|
if (id === homeCode) return HOME_COLOR;
|
|
if (!sel.has(id)) return UNVISITED_COLOR;
|
|
return VISITED_COLOR;
|
|
}
|
|
|
|
function countryHoverColor(d, sel, homeCode) {
|
|
const id = effId(d);
|
|
if (id === homeCode) return HOME_COLOR_HOVER;
|
|
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
|
|
return VISITED_COLOR_HOVER;
|
|
}
|
|
|
|
let frameEl;
|
|
let _paths = $state(null);
|
|
let _g = null;
|
|
let _pathFn = null;
|
|
let _countries = null;
|
|
|
|
|
|
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]);
|
|
}
|
|
|
|
function getHomeCode() {
|
|
const name = getUserProfile()?.homeCountry;
|
|
return name ? (nameToId[name] ?? null) : null;
|
|
}
|
|
|
|
function updateAllFills() {
|
|
const sel = getSelected();
|
|
const hc = getHomeCode();
|
|
if (!_paths || !_g) return;
|
|
_paths.attr('fill', d => countryColor(d, sel, hc));
|
|
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
|
|
}
|
|
|
|
$effect(updateAllFills);
|
|
|
|
function placeHomeMarker() {
|
|
if (!_g || !_pathFn || !_countries) return;
|
|
_g.selectAll('.home-marker').remove();
|
|
const name = getUserProfile()?.homeCountry;
|
|
if (!name) return;
|
|
const found = _countries.find(f => f.properties.name === name);
|
|
if (!found) return;
|
|
const [cx, cy] = _pathFn.centroid(found);
|
|
if (isNaN(cx) || isNaN(cy)) return;
|
|
const SIZE = 14;
|
|
_g.append('image')
|
|
.attr('class', 'home-marker')
|
|
.attr('href', homeIconUrl)
|
|
.attr('x', cx - SIZE / 2)
|
|
.attr('y', cy - SIZE / 2)
|
|
.attr('width', SIZE)
|
|
.attr('height', SIZE)
|
|
.style('pointer-events', 'none');
|
|
}
|
|
|
|
$effect(placeHomeMarker);
|
|
|
|
$effect(() => {
|
|
const flashSet = getFlashing();
|
|
const paths = _paths; // reactive read so effect re-runs when _paths is set
|
|
if (!paths || flashSet.size === 0) return;
|
|
paths
|
|
.filter(d => flashSet.has(effId(d)))
|
|
.each(function() {
|
|
d3.select(this).interrupt()
|
|
.transition().duration(200).attr('fill', '#facc15')
|
|
.transition().duration(200).attr('fill', '#fb923c')
|
|
.transition().duration(200).attr('fill', '#facc15')
|
|
.transition().duration(200).attr('fill', '#fb923c')
|
|
.transition().duration(200).attr('fill', '#facc15')
|
|
.transition().duration(400).attr('fill', VISITED_COLOR);
|
|
});
|
|
});
|
|
|
|
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';
|
|
});
|
|
|
|
_pathFn = path;
|
|
_countries = countries;
|
|
|
|
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);
|
|
|
|
_g = svg.append('g');
|
|
|
|
const tooltip = d3.select(frameEl)
|
|
.append('div')
|
|
.attr('class', 'tooltip')
|
|
.style('display', 'none');
|
|
|
|
function attachEvents(sel) {
|
|
sel
|
|
.on('click', (event, d) => {
|
|
onCountryClick(d.properties.name);
|
|
})
|
|
.on('mouseenter', (event, d) => {
|
|
const s = getSelected();
|
|
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, getHomeCode()));
|
|
tooltip.style('display', 'block').text(d.properties.name);
|
|
})
|
|
.on('mousemove', (event) => {
|
|
const [x, y] = d3.pointer(event, frameEl);
|
|
tooltip.style('left', (x + 22) + 'px').style('top', (y - 28) + 'px');
|
|
})
|
|
.on('mouseleave', (event, d) => {
|
|
const s = getSelected();
|
|
d3.select(event.currentTarget).attr('fill', countryColor(d, s, getHomeCode()));
|
|
tooltip.style('display', 'none');
|
|
});
|
|
}
|
|
|
|
_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', 2)
|
|
.attr('fill', countryColor(d, getSelected(), getHomeCode()))
|
|
.attr('stroke', '#94a3b8')
|
|
.attr('stroke-width', 0.5);
|
|
attachEvents(c);
|
|
}
|
|
});
|
|
}
|
|
|
|
renderMicrostates();
|
|
placeHomeMarker();
|
|
|
|
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);
|
|
updateAllFills();
|
|
renderMicrostates();
|
|
placeHomeMarker();
|
|
}
|
|
});
|
|
|
|
observer.observe(frameEl);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
svg.remove();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div bind:this={frameEl} class="map-frame" style="cursor: url({crayonCursorUrl}) 4 28, crosshair;"></div>
|
|
|
|
<style>
|
|
.map-frame {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
position: relative;
|
|
background: #a4c8e0;
|
|
}
|
|
|
|
.map-frame :global(svg) {
|
|
display: block;
|
|
cursor: inherit;
|
|
}
|
|
|
|
.map-frame :global(svg:active) {
|
|
cursor: inherit;
|
|
}
|
|
|
|
.map-frame :global(svg path) {
|
|
cursor: inherit;
|
|
}
|
|
|
|
.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>
|