All checks were successful
Deploy production / deploy (push) Successful in 1m18s
147 lines
5.3 KiB
Svelte
147 lines
5.3 KiB
Svelte
<script lang="ts">
|
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
|
import './world-opinions.css';
|
|
import CountryPanel from '#lib/world-opinions/CountryPanel.svelte';
|
|
import CultureMap from '#lib/world-opinions/CultureMap.svelte';
|
|
import PartyCompass from '#lib/world-opinions/PartyCompass.svelte';
|
|
import WorldMap, { type MapMode } from '#lib/world-opinions/WorldMap.svelte';
|
|
import { buildWorldOpinions, provideWorldOpinions } from '#lib/world-opinions/data.js';
|
|
import { Button } from '#lib/components/ui/button/index.js';
|
|
import { resolve } from '$app/paths';
|
|
|
|
let { data } = $props();
|
|
// Static per load (stats.wo_*), so it is read once and shared with the components.
|
|
const wo = provideWorldOpinions(buildWorldOpinions(data.wo));
|
|
const { countries, countryById, narratives, sources } = wo;
|
|
|
|
const modes: { id: MapMode; label: string }[] = [
|
|
{ id: 'liberal', label: 'what "liberal" means' },
|
|
{ id: 'divide', label: 'what left vs right is about' },
|
|
{ id: 'tradition', label: 'traditional ↔ secular' },
|
|
{ id: 'expression', label: 'survival ↔ self-expression' }
|
|
];
|
|
const sorted = [...countries].sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
let mode = $state<MapMode>('liberal');
|
|
let countryId = $state('us');
|
|
let eventIndex = $state(0);
|
|
let narrative = $derived(narratives[eventIndex]);
|
|
|
|
function select(id: string) {
|
|
countryId = id;
|
|
document.getElementById('country')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>world opinions · stats · Stan Runge</title>
|
|
<meta
|
|
name="description"
|
|
content="How political words, parties, values and national histories differ across 57 countries: what liberal means, what left and right are about, a shared party compass and a values map."
|
|
/>
|
|
</svelte:head>
|
|
|
|
<section class="stats-intro">
|
|
<span class="stats-eyebrow">stats / opinions / world</span>
|
|
<h1>World opinions</h1>
|
|
<p>
|
|
Same words, different politics. Hover the map, click a country. For measured party positions
|
|
(Chapel Hill Expert Survey, V-Party) see <a href={resolve('/stats/opinions') + '#parties'}
|
|
>where Dutch parties stand</a
|
|
>; for polls, happiness and Eurobarometer see <a href={resolve('/stats/opinions')}>opinions</a>.
|
|
</p>
|
|
</section>
|
|
|
|
<div class="wo">
|
|
<section aria-label="World map">
|
|
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
|
|
{#each modes as item (item.id)}
|
|
<Button
|
|
variant={mode === item.id ? 'secondary' : 'outline'}
|
|
size="sm"
|
|
role="tab"
|
|
aria-selected={mode === item.id}
|
|
onclick={() => (mode = item.id)}>{item.label}</Button
|
|
>
|
|
{/each}
|
|
</div>
|
|
<WorldMap {mode} selected={countryId} onselect={select} />
|
|
</section>
|
|
|
|
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
|
|
<label class="mb-3 block max-w-xs text-sm">
|
|
<span class="sr-only">Country</span>
|
|
<NativeSelect
|
|
bind:value={countryId}
|
|
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
|
|
>
|
|
{#each sorted as country (country.id)}
|
|
<option value={country.id}>{country.name}</option>
|
|
{/each}
|
|
</NativeSelect>
|
|
</label>
|
|
<CountryPanel id={countryId} />
|
|
</section>
|
|
|
|
<section class="mt-12 border-t border-[var(--site-border)] py-10">
|
|
<h2 class="stats-section-title mb-1">Party compass</h2>
|
|
<p class="mb-5 text-[var(--site-muted)]">
|
|
Every party on one shared scale, hand-placed for comparison. Measured CHES and V-Party
|
|
positions for Dutch parties are on <a
|
|
class="underline underline-offset-2"
|
|
href={resolve('/stats/opinions') + '#parties'}>opinions</a
|
|
>.
|
|
</p>
|
|
<PartyCompass focus={countryId} />
|
|
</section>
|
|
|
|
<section class="border-t border-[var(--site-border)] py-10">
|
|
<h2 class="stats-section-title mb-1">Values map</h2>
|
|
<p class="mb-5 text-[var(--site-muted)]">Both value axes at once.</p>
|
|
<CultureMap selected={countryId} onselect={select} />
|
|
</section>
|
|
|
|
<section class="border-t border-[var(--site-border)] py-10">
|
|
<h2 class="stats-section-title mb-4">One event, many stories</h2>
|
|
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
|
|
{#each narratives as item, index (item.event)}
|
|
<Button
|
|
variant={eventIndex === index ? 'secondary' : 'outline'}
|
|
size="sm"
|
|
role="tab"
|
|
aria-selected={eventIndex === index}
|
|
onclick={() => (eventIndex = index)}
|
|
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></Button
|
|
>
|
|
{/each}
|
|
</div>
|
|
<div role="tabpanel">
|
|
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each narrative.views as view (view.country)}
|
|
<li>
|
|
<button class="wo-card wo-quote w-full text-left" onclick={() => select(view.country)}>
|
|
<span class="text-sm text-[var(--site-muted)]"
|
|
>{countryById.get(view.country)?.name}</span
|
|
>
|
|
<span class="block text-lg">“{view.story}”</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="border-t border-[var(--site-border)] pt-6 text-sm text-[var(--site-muted)]">
|
|
<p class="mb-2">Approximate, hand-placed positions for comparison. Based on:</p>
|
|
<p class="flex flex-wrap gap-x-4 gap-y-1">
|
|
{#each sources as source (source.href)}
|
|
<a
|
|
class="underline underline-offset-2 hover:text-[var(--site-text)]"
|
|
href={source.href}
|
|
rel="external">{source.name}</a
|
|
>
|
|
{/each}
|
|
</p>
|
|
</footer>
|
|
</div>
|