add world opinions project
All checks were successful
Test and deploy staging / staging (push) Successful in 1m49s
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>
This commit is contained in:
174
src/lib/world-opinions/WorldMap.svelte
Normal file
174
src/lib/world-opinions/WorldMap.svelte
Normal file
@@ -0,0 +1,174 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user