Files
website/src/lib/world-opinions/WorldMap.svelte
Stan Runge afb1f191df
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
add world opinions project
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>
2026-09-24 16:24:40 +00:00

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>