changed font style, added journey summary, changed card ui

This commit is contained in:
haerikimmm
2026-06-11 13:21:09 +09:00
parent 6b13522d6d
commit 0848418453
17 changed files with 1955 additions and 430 deletions

View File

@@ -0,0 +1,307 @@
<script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
let collapsed = $state(false);
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 segments = $derived.by(() => {
if (total === 0) return [];
const segs = [];
let deg = 0;
for (const cont of CONTINENTS) {
const angle = Math.min(counts[cont] / total * 360, 359.99);
if (angle > 0) {
const startDeg = deg;
const endDeg = deg + angle;
const midDeg = (startDeg + endDeg) / 2;
const rad = (midDeg - 90) * Math.PI / 180;
const sr = (startDeg - 90) * Math.PI / 180;
const er = (endDeg - 90) * Math.PI / 180;
const cx = 90, cy = 90, outerR = 65, innerR = 30;
const x1 = cx + outerR * Math.cos(sr);
const y1 = cy + outerR * Math.sin(sr);
const x2 = cx + outerR * Math.cos(er);
const y2 = cy + outerR * Math.sin(er);
const x3 = cx + innerR * Math.cos(er);
const y3 = cy + innerR * Math.sin(er);
const x4 = cx + innerR * Math.cos(sr);
const y4 = cy + innerR * Math.sin(sr);
const largeArc = angle > 180 ? 1 : 0;
const path = `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${largeArc} 0 ${x4} ${y4} Z`;
const lx = cx + 82 * Math.cos(rad);
const ly = cy + 82 * Math.sin(rad);
segs.push({ cont, color: continentColors[cont], path, lx, ly, angle });
deg += angle;
}
}
return segs;
});
</script>
<div class="panel" class:collapsed>
<button class="collapse-btn" onclick={() => collapsed = !collapsed} data-tip={collapsed ? 'see statistics' : 'close statistics'}>
{collapsed ? '◀' : '▶'}
</button>
{#if !collapsed}
<div class="panel-content">
<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">
{#if segments.length > 0}
<svg viewBox="0 0 180 180" class="donut-svg">
{#each segments as seg}
<g class="seg-group">
<path d={seg.path} fill={seg.color} />
<text x={seg.lx} y={seg.ly} text-anchor="middle" dominant-baseline="middle" class="donut-label" style="font-size: {seg.angle < 20 ? 12 : 15}px">{seg.cont}</text>
</g>
{/each}
<circle cx="90" cy="90" r="30" fill="#f8fafc" />
</svg>
{:else}
<svg viewBox="0 0 180 180" class="donut-svg">
<circle cx="90" cy="90" r="65" fill="#e2e8f0" />
<circle cx="90" cy="90" r="30" fill="#f8fafc" />
</svg>
{/if}
</div>
<div class="divider"></div>
<div class="disclaimer">Contains all UN countries, Kosovo, Hong Kong and Taiwan</div>
</div>
{/if}
</div>
<style>
.panel {
flex: 0 0 min(360px, 25vw);
background: var(--bg-raised);
border-left: 1px solid var(--border);
display: flex;
flex-direction: row;
font-family: var(--sans);
transition: flex-basis 0.25s ease;
}
.panel.collapsed {
flex: 0 0 28px;
border-left: none;
}
.panel-content {
flex: 1;
padding: 24px 28px;
overflow-y: auto;
min-width: 0;
}
.collapse-btn {
flex: 0 0 auto;
align-self: flex-start;
background: var(--accent-bg);
border: none;
border-radius: 0 8px 8px 0;
padding: 14px 5px;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: var(--accent);
transition: background 0.15s ease, padding 0.15s ease;
margin-top: 24px;
position: relative;
}
.collapse-btn:hover {
background: var(--lavender-bg);
padding-right: 8px;
}
.collapse-btn::after {
content: attr(data-tip);
position: absolute;
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
background: var(--text-h);
color: var(--bg-raised);
font-family: var(--sans);
font-size: 12px;
font-weight: 300;
padding: 6px 12px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.collapse-btn:hover::after {
opacity: 1;
}
.headline {
font-family: var(--heading);
font-size: var(--text-sm);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent);
margin: 0 0 20px 0;
}
.bar-label {
font-family: var(--sans);
font-size: var(--text-xs);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-sub);
display: block;
margin-bottom: 8px;
}
.total-bar-wrap {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.total-bar-bg {
flex: 1;
height: 18px;
background: var(--accent-bg);
border-radius: 10px;
overflow: hidden;
}
.total-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-dark), var(--lavender));
border-radius: 10px;
transition: width 0.3s ease;
min-width: 0;
}
.total-bar-text {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
white-space: nowrap;
}
.divider {
height: 1px;
background: var(--border);
margin: 16px 0;
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.label {
flex: 1;
font-size: var(--text-sm);
font-weight: 300;
color: var(--text);
}
.value {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-h);
}
.total {
font-weight: 400;
color: var(--text-sub);
font-size: var(--text-xs);
}
.donut-wrap {
display: flex;
justify-content: center;
margin: 20px 0;
}
.donut-svg {
width: 160px;
height: 160px;
filter: drop-shadow(0 2px 8px rgba(99,102,241,0.15));
}
.donut-label {
fill: var(--text-h);
font-family: var(--sans);
font-weight: 300;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.seg-group:hover .donut-label {
opacity: 1;
}
.disclaimer {
font-size: var(--text-xs);
color: var(--text-sub);
line-height: 1.5;
text-align: center;
}
</style>

View File

@@ -0,0 +1,159 @@
<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>

View File

@@ -0,0 +1,212 @@
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;
}