display home country in map

This commit is contained in:
2026-06-12 19:19:20 +09:00
parent 6701398da7
commit f198c05063
6 changed files with 140 additions and 34 deletions

View File

@@ -7,36 +7,32 @@
const countries = $derived.by(() => {
if (!worldData?.objects?.countries?.geometries) return [];
const names = worldData.objects.countries.geometries
.map(g => g.properties?.name)
.filter(Boolean);
return [...new Set(names)].sort();
return worldData.objects.countries.geometries
.map(g => ({ name: g.properties?.name, code: g.id }))
.filter(c => c.name && c.code)
.sort((a, b) => a.name.localeCompare(b.name));
});
let search = $state('');
let selectedCountry = $state('');
let selectedCountry = $state(null);
let filtered = $derived(
search
? countries.filter(c => c.toLowerCase().includes(search.toLowerCase()))
? countries.filter(c => c.name.toLowerCase().includes(search.toLowerCase()))
: countries
);
let open = $state(false);
function toggleDropdown() {
open = !open;
}
function select(c) {
selectedCountry = c;
search = c;
search = c.name;
open = false;
}
function handleSubmit() {
if (selectedCountry) {
setHomeCountry(selectedCountry);
setHomeCountry(selectedCountry.name, selectedCountry.code);
}
}
@@ -61,7 +57,7 @@
placeholder="Search for a country..."
bind:value={search}
onfocus={() => { open = true; }}
oninput={() => { open = true; selectedCountry = ''; }}
oninput={() => { open = true; selectedCountry = null; }}
onkeydown={handleKeydown}
class="search-input"
/>
@@ -70,13 +66,13 @@
{#each filtered as country}
<li
role="option"
aria-selected={selectedCountry === country}
class:selected={selectedCountry === country}
aria-selected={selectedCountry?.name === country.name}
class:selected={selectedCountry?.name === country.name}
onclick={() => select(country)}
onkeydown={(e) => { if (e.key === 'Enter') select(country); }}
tabindex="0"
>
{country}
{country.name}
</li>
{/each}
{#if filtered.length === 0}

View File

@@ -27,17 +27,18 @@ export async function signOut() {
needsCountry = false;
}
export async function setHomeCountry(country) {
export async function setHomeCountry(name, code) {
if (!user) return;
await setDoc(doc(db, 'users', user.uid), {
displayName: user.displayName,
photoURL: user.photoURL,
email: user.email,
homeCountry: country,
visitedCountries: [],
homeCountry: name,
homeCountryCode: code,
visitedCountries: [code],
createdAt: serverTimestamp(),
});
userProfile = { ...userProfile, homeCountry: country, visitedCountries: [] };
userProfile = { ...userProfile, homeCountry: name, homeCountryCode: code, visitedCountries: [code] };
needsCountry = false;
}

View File

@@ -58,11 +58,11 @@
</div>
{/if}
</div>
</div>
{#if menuOpen}
{#if menuOpen}
<button class="backdrop" aria-label="Close menu" onclick={() => { menuOpen = false; }}></button>
{/if}
{/if}
</div>
<style>
.topbar {
@@ -217,7 +217,7 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
z-index: 30;
border: none;
background: transparent;
cursor: default;

View File

@@ -3,6 +3,7 @@ import { doc, onSnapshot, updateDoc, arrayUnion, arrayRemove } from 'firebase/fi
let selected = $state(new Set());
let totalCountries = $state(0);
let homeCountryCode = $state(null);
let _uid = null;
let _unsubscribe = null;
@@ -14,6 +15,7 @@ export function initSelectionListener(uid) {
if (snap.exists()) {
const codes = snap.data().visitedCountries || [];
selected = new Set(codes);
homeCountryCode = snap.data().homeCountryCode || null;
}
});
}
@@ -54,3 +56,7 @@ export function setTotalCount(n) {
export function getTotalCount() {
return totalCountries;
}
export function getHomeCountryCode() {
return homeCountryCode;
}

View File

@@ -1,6 +1,7 @@
<script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
let collapsed = $state(false);
@@ -13,6 +14,33 @@
'Oceania': '#a855f7'
};
const countryNameById = $derived.by(() => {
const map = { XK: 'Kosovo' };
for (const g of worldData.objects.countries.geometries) {
map[g.id] = g.properties?.name || g.id;
}
return map;
});
let visitedCountries = $derived(
[...getSelected()].map(id => countryNameById[id]).filter(Boolean).sort()
);
let visitedByContinent = $derived.by(() => {
const map = {};
for (const id of getSelected()) {
const cont = getContinent(id);
if (cont) {
if (!map[cont]) map[cont] = [];
map[cont].push(countryNameById[id] || id);
}
}
for (const cont of Object.keys(map)) {
map[cont].sort();
}
return map;
});
let counts = $derived.by(() => {
const c = {};
for (const cont of CONTINENTS) c[cont] = 0;
@@ -83,10 +111,20 @@
<span class="bar-label">by continent</span>
{#each CONTINENTS as continent}
{@const contTotal = continentTotals[continent]}
<div class="row">
<div class="row tooltip-wrap">
<span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></span>
{#if visitedByContinent[continent]?.length > 0}
<div class="tooltip-list">
{#each visitedByContinent[continent].slice(0, 10) as country}
<span class="tooltip-item">{country}</span>
{/each}
{#if visitedByContinent[continent].length > 10}
<span class="tooltip-item tooltip-more">...</span>
{/if}
</div>
{/if}
</div>
{/each}
@@ -273,12 +311,14 @@
display: flex;
justify-content: center;
margin: 24px 0;
padding: 0 10px;
}
.donut-svg {
width: 160px;
height: 160px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
overflow: visible;
}
.donut-label {
@@ -293,6 +333,40 @@
opacity: 1;
}
.tooltip-wrap {
position: relative;
}
.tooltip-list {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
background: #1e293b;
color: #f1f5f9;
font-size: 12px;
line-height: 1.5;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
z-index: 20;
white-space: nowrap;
min-width: 120px;
}
.tooltip-wrap:hover .tooltip-list {
display: block;
}
.tooltip-item {
display: block;
padding: 2px 0;
}
.tooltip-item + .tooltip-item {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.disclaimer {
font-size: 11px;
color: #94a3b8;

View File

@@ -3,7 +3,7 @@
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';
import { getSelected, toggle, setTotalCount, getHomeCountryCode } from '../layout/selection.svelte.js';
const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States
@@ -49,6 +49,27 @@
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 (!sel.has(id)) return UNVISITED_COLOR;
if (id === homeCode) return HOME_COLOR;
return VISITED_COLOR;
}
function countryHoverColor(d, sel, homeCode) {
const id = effId(d);
if (!sel.has(id)) return UNVISITED_COLOR_HOVER;
if (id === homeCode) return HOME_COLOR_HOVER;
return VISITED_COLOR_HOVER;
}
let frameEl;
let _paths = null;
let _g = null;
@@ -62,8 +83,9 @@
function updateAllFills() {
if (!_paths || !_g) return;
const sel = getSelected();
_paths.attr('fill', d => sel.has(effId(d)) ? '#22c55e' : '#ffffff');
_g.selectAll('.micro-state').attr('fill', d => sel.has(effId(d)) ? '#22c55e' : '#ffffff');
const hc = getHomeCountryCode();
_paths.attr('fill', d => countryColor(d, sel, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
}
$effect(updateAllFills);
@@ -100,8 +122,10 @@
.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');
const s = getSelected();
const hc = getHomeCountryCode();
sel.attr('fill', d => countryColor(d, s, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, s, hc));
}
function attachEvents(sel) {
@@ -111,7 +135,9 @@
updateFill(d3.select(event.currentTarget));
})
.on('mouseenter', (event, d) => {
d3.select(event.currentTarget).attr('fill', getSelected().has(effId(d)) ? '#16a34a' : '#f0f6fa');
const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryHoverColor(d, s, hc));
tooltip.style('display', 'block').text(d.properties.name);
})
.on('mousemove', (event) => {
@@ -119,7 +145,9 @@
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');
const s = getSelected();
const hc = getHomeCountryCode();
d3.select(event.currentTarget).attr('fill', countryColor(d, s, hc));
tooltip.style('display', 'none');
});
}
@@ -141,13 +169,14 @@
const { width, height } = this.getBBox();
if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d);
const hc = getHomeCountryCode();
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('fill', countryColor(d, getSelected(), hc))
.attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5);
attachEvents(c);