All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
World map with switchable layers (what "liberal" means, what left vs right is about, traditional/secular and survival/self-expression values), country cards, a shared-scale party compass, a values scatter and national narratives of major events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
5.8 KiB
Svelte
175 lines
5.8 KiB
Svelte
<script lang="ts" module>
|
|
import { geoEqualEarth, geoPath } from 'd3-geo';
|
|
import { feature } from 'topojson-client';
|
|
import type { Feature, FeatureCollection, Geometry } from 'geojson';
|
|
import type { GeometryCollection, Topology } from 'topojson-specification';
|
|
import world from 'world-atlas/countries-110m.json';
|
|
|
|
export type MapMode = 'tradition' | 'expression' | 'liberal' | 'divide';
|
|
|
|
const WIDTH = 960;
|
|
const HEIGHT = 470;
|
|
const topology = world as unknown as Topology<{
|
|
countries: GeometryCollection<{ name: string }>;
|
|
}>;
|
|
const collection = feature(topology, topology.objects.countries) as FeatureCollection<
|
|
Geometry,
|
|
{ name: string }
|
|
>;
|
|
// Antarctica only adds a white band along the bottom.
|
|
const land = collection.features.filter((shape) => shape.id !== '010');
|
|
const projection = geoEqualEarth().fitSize([WIDTH, HEIGHT], {
|
|
type: 'FeatureCollection',
|
|
features: land
|
|
});
|
|
const path = geoPath(projection);
|
|
const shapes = land.map((shape: Feature<Geometry, { name: string }>) => ({
|
|
iso: String(shape.id),
|
|
name: shape.properties.name,
|
|
d: path(shape) ?? ''
|
|
}));
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { countries, divides, liberalMeanings, type Country } from './data';
|
|
|
|
let {
|
|
mode,
|
|
selected = null,
|
|
onselect
|
|
}: { mode: MapMode; selected?: string | null; onselect?: (id: string) => void } = $props();
|
|
|
|
const byIso = new Map(countries.map((country) => [country.iso, country]));
|
|
const categoryColor: Record<string, string> = {
|
|
left: 'var(--wo-s1)',
|
|
market: 'var(--wo-s2)',
|
|
western: 'var(--wo-s3)',
|
|
economy: 'var(--wo-s1)',
|
|
culture: 'var(--wo-s2)',
|
|
history: 'var(--wo-s3)',
|
|
none: 'var(--wo-none)'
|
|
};
|
|
|
|
function score(country: Country | undefined) {
|
|
if (!country?.values) return undefined;
|
|
return mode === 'tradition' ? country.values.y : country.values.x;
|
|
}
|
|
|
|
function fill(country: Country | undefined) {
|
|
if (mode === 'liberal')
|
|
return country?.liberal ? categoryColor[country.liberal] : 'var(--wo-nodata)';
|
|
if (mode === 'divide')
|
|
return country?.divide ? categoryColor[country.divide] : 'var(--wo-nodata)';
|
|
const value = score(country);
|
|
if (value === undefined) return 'var(--wo-nodata)';
|
|
const strength = Math.round(Math.min(Math.abs(value) / 2, 1) * 100);
|
|
const pole = value >= 0 ? 'var(--wo-pos)' : 'var(--wo-neg)';
|
|
return `color-mix(in oklab, ${pole} ${strength}%, var(--wo-mid))`;
|
|
}
|
|
|
|
const poles: Record<'tradition' | 'expression', [string, string]> = {
|
|
tradition: ['traditional', 'secular'],
|
|
expression: ['survival', 'self-expression']
|
|
};
|
|
|
|
function describe(country: Country) {
|
|
if (mode === 'liberal')
|
|
return country.liberal ? `"liberal" = ${liberalMeanings[country.liberal]}` : 'no data';
|
|
if (mode === 'divide') return country.divide ? divides[country.divide] : 'no data';
|
|
const value = score(country);
|
|
if (value === undefined) return 'not surveyed';
|
|
const [negative, positive] = poles[mode];
|
|
const strength = Math.abs(value);
|
|
const word =
|
|
strength < 0.3 ? 'middle' : strength < 1 ? 'somewhat' : strength < 1.7 ? 'quite' : 'very';
|
|
return word === 'middle' ? 'in the middle' : `${word} ${value >= 0 ? positive : negative}`;
|
|
}
|
|
|
|
let hovered = $state<{ iso: string; name: string; x: number; y: number } | null>(null);
|
|
let hoveredCountry = $derived(hovered ? byIso.get(hovered.iso) : undefined);
|
|
let selectedShape = $derived(
|
|
shapes.find((shape) => shape.iso === countries.find((c) => c.id === selected)?.iso)
|
|
);
|
|
|
|
function onmove(event: PointerEvent) {
|
|
const target = event.target as SVGElement;
|
|
const iso = target.dataset?.iso;
|
|
if (!iso) {
|
|
hovered = null;
|
|
return;
|
|
}
|
|
const box = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
hovered = {
|
|
iso,
|
|
name: target.dataset.name ?? '',
|
|
x: event.clientX - box.left,
|
|
y: event.clientY - box.top
|
|
};
|
|
}
|
|
|
|
function onclick(event: MouseEvent) {
|
|
const iso = (event.target as SVGElement).dataset?.iso;
|
|
const country = iso ? byIso.get(iso) : undefined;
|
|
if (country) onselect?.(country.id);
|
|
}
|
|
</script>
|
|
|
|
<!-- Pointer-only shortcut; the country picker next to the map is the keyboard path. -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
<div class="relative" onpointermove={onmove} onpointerleave={() => (hovered = null)} {onclick}>
|
|
<svg
|
|
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
|
class="w-full"
|
|
role="img"
|
|
aria-label="World map colored by the selected layer"
|
|
>
|
|
{#each shapes as shape (shape.iso + shape.name)}
|
|
{@const country = byIso.get(shape.iso)}
|
|
<path
|
|
d={shape.d}
|
|
data-iso={shape.iso}
|
|
data-name={country?.name ?? shape.name}
|
|
fill={fill(country)}
|
|
class={country ? 'wo-country cursor-pointer' : 'wo-country'}
|
|
class:wo-country-hover={hovered?.iso === shape.iso}
|
|
/>
|
|
{/each}
|
|
{#if selectedShape}
|
|
<path d={selectedShape.d} class="wo-country-selected" />
|
|
{/if}
|
|
</svg>
|
|
|
|
{#if hovered}
|
|
<div class="wo-tooltip" style:left={`${hovered.x}px`} style:top={`${hovered.y}px`}>
|
|
<strong>{hovered.name}</strong>
|
|
{#if hoveredCountry}
|
|
<span>{describe(hoveredCountry)}</span>
|
|
{#if hoveredCountry.note && (mode === 'liberal' || mode === 'divide')}
|
|
<span class="wo-tooltip-note">{hoveredCountry.note}</span>
|
|
{/if}
|
|
{:else}
|
|
<span>no data</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-[var(--site-muted)]">
|
|
{#if mode === 'tradition' || mode === 'expression'}
|
|
<span class="flex items-center gap-2">
|
|
{poles[mode][0]}
|
|
<span class="wo-gradient" aria-hidden="true"></span>
|
|
{poles[mode][1]}
|
|
</span>
|
|
{:else}
|
|
{#each Object.entries(mode === 'liberal' ? liberalMeanings : divides) as [key, label] (key)}
|
|
<span class="flex items-center gap-2">
|
|
<span class="wo-swatch" style:background={categoryColor[key]}></span>{label}
|
|
</span>
|
|
{/each}
|
|
{/if}
|
|
<span class="flex items-center gap-2">
|
|
<span class="wo-swatch wo-swatch-nodata"></span>no data
|
|
</span>
|
|
</div>
|