160 lines
5.2 KiB
Svelte
160 lines
5.2 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, toggle, setTotalCount } from '../layout/selection.svelte.js';
|
|
|
|
const TERRITORY_PARENT = {
|
|
'016': '840', '060': '826', '086': '826', '092': '826', '136': '826',
|
|
'184': '554', '234': '208', '238': '826', '239': '826', '248': '246',
|
|
'258': '250', '260': '250', '304': '208', '316': '840', '334': '036',
|
|
'446': '156', '500': '826', '531': '528', '533': '528', '534': '528',
|
|
'540': '250', '570': '554', '574': '036', '580': '840', '612': '826',
|
|
'630': '840', '652': '250', '654': '826', '660': '826', '663': '250',
|
|
'666': '250', '796': '826', '831': '826', '832': '826', '833': '826',
|
|
'850': '840', '876': '250',
|
|
};
|
|
|
|
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', 2)
|
|
.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>
|