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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script> <script>
import { CONTINENTS, getContinent, continentTotals } from './continents.js'; import { CONTINENTS, getContinent, continentTotals } from './continents.js';
import { getSelected, getTotalCount } from '../layout/selection.svelte.js'; import { getSelected, getTotalCount } from '../layout/selection.svelte.js';
import worldData from 'world-atlas/countries-50m.json';
let collapsed = $state(false); let collapsed = $state(false);
@@ -13,6 +14,33 @@
'Oceania': '#a855f7' '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(() => { let counts = $derived.by(() => {
const c = {}; const c = {};
for (const cont of CONTINENTS) c[cont] = 0; for (const cont of CONTINENTS) c[cont] = 0;
@@ -83,10 +111,20 @@
<span class="bar-label">by continent</span> <span class="bar-label">by continent</span>
{#each CONTINENTS as continent} {#each CONTINENTS as continent}
{@const contTotal = continentTotals[continent]} {@const contTotal = continentTotals[continent]}
<div class="row"> <div class="row tooltip-wrap">
<span class="dot" style="background: {continentColors[continent]}"></span> <span class="dot" style="background: {continentColors[continent]}"></span>
<span class="label">{continent}</span> <span class="label">{continent}</span>
<span class="value">{counts[continent]}<span class="total">/{contTotal}</span></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> </div>
{/each} {/each}
@@ -273,12 +311,14 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 24px 0; margin: 24px 0;
padding: 0 10px;
} }
.donut-svg { .donut-svg {
width: 160px; width: 160px;
height: 160px; height: 160px;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1)); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
overflow: visible;
} }
.donut-label { .donut-label {
@@ -293,6 +333,40 @@
opacity: 1; 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 { .disclaimer {
font-size: 11px; font-size: 11px;
color: #94a3b8; color: #94a3b8;

View File

@@ -3,7 +3,7 @@
import * as d3 from 'd3'; import * as d3 from 'd3';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import worldData from 'world-atlas/countries-50m.json'; 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 = { const TERRITORY_PARENT = {
'016': '840', // American Samoa -> United States '016': '840', // American Samoa -> United States
@@ -49,6 +49,27 @@
return TERRITORY_PARENT[d.id] || d.id; 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 frameEl;
let _paths = null; let _paths = null;
let _g = null; let _g = null;
@@ -62,8 +83,9 @@
function updateAllFills() { function updateAllFills() {
if (!_paths || !_g) return; if (!_paths || !_g) return;
const sel = getSelected(); const sel = getSelected();
_paths.attr('fill', d => sel.has(effId(d)) ? '#22c55e' : '#ffffff'); const hc = getHomeCountryCode();
_g.selectAll('.micro-state').attr('fill', d => sel.has(effId(d)) ? '#22c55e' : '#ffffff'); _paths.attr('fill', d => countryColor(d, sel, hc));
_g.selectAll('.micro-state').attr('fill', d => countryColor(d, sel, hc));
} }
$effect(updateAllFills); $effect(updateAllFills);
@@ -100,8 +122,10 @@
.style('display', 'none'); .style('display', 'none');
function updateFill(sel) { function updateFill(sel) {
sel.attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff'); const s = getSelected();
_g.selectAll('.micro-state').attr('fill', d => getSelected().has(effId(d)) ? '#22c55e' : '#ffffff'); 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) { function attachEvents(sel) {
@@ -111,7 +135,9 @@
updateFill(d3.select(event.currentTarget)); updateFill(d3.select(event.currentTarget));
}) })
.on('mouseenter', (event, d) => { .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); tooltip.style('display', 'block').text(d.properties.name);
}) })
.on('mousemove', (event) => { .on('mousemove', (event) => {
@@ -119,7 +145,9 @@
tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px'); tooltip.style('left', (x + 10) + 'px').style('top', (y - 28) + 'px');
}) })
.on('mouseleave', (event, d) => { .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'); tooltip.style('display', 'none');
}); });
} }
@@ -141,13 +169,14 @@
const { width, height } = this.getBBox(); const { width, height } = this.getBBox();
if (width < threshold && height < threshold) { if (width < threshold && height < threshold) {
const [cx, cy] = path.centroid(d); const [cx, cy] = path.centroid(d);
const hc = getHomeCountryCode();
const c = _g.append('circle') const c = _g.append('circle')
.attr('class', 'micro-state') .attr('class', 'micro-state')
.datum(d) .datum(d)
.attr('cx', cx) .attr('cx', cx)
.attr('cy', cy) .attr('cy', cy)
.attr('r', 2) .attr('r', 2)
.attr('fill', getSelected().has(effId(d)) ? '#22c55e' : '#ffffff') .attr('fill', countryColor(d, getSelected(), hc))
.attr('stroke', '#94a3b8') .attr('stroke', '#94a3b8')
.attr('stroke-width', 0.5); .attr('stroke-width', 0.5);
attachEvents(c); attachEvents(c);