add side stats panel
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user