Files
website/src/lib/world-opinions/CultureMap.svelte
Stan Runge abac077541
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
ui changes and cs prices
2026-09-25 00:44:06 +00:00

236 lines
7.1 KiB
Svelte

<script lang="ts">
import { countries, regions, type RegionId } from './data';
import { Button } from '$lib/components/ui/button/index.js';
const culturalMap = countries.flatMap((country) =>
country.values ? [{ ...country, x: country.values.x, y: country.values.y }] : []
);
let { selected = null, onselect }: { selected?: string | null; onselect?: (id: string) => void } =
$props();
const width = 640;
const height = 440;
const pad = { top: 28, right: 20, bottom: 44, left: 48 };
const domain = { x: [-2, 2.8], y: [-2.2, 2.2] } as const;
const sx = (value: number) =>
pad.left +
((value - domain.x[0]) / (domain.x[1] - domain.x[0])) * (width - pad.left - pad.right);
const sy = (value: number) =>
height -
pad.bottom -
((value - domain.y[0]) / (domain.y[1] - domain.y[0])) * (height - pad.top - pad.bottom);
const ticks = [-2, -1, 0, 1, 2];
const usedRegions = [...new Set(culturalMap.map((point) => point.region))];
let region = $state<RegionId | 'all'>('all');
let hovered = $state<string | null>(null);
let showTable = $state(false);
const highlighted = (id: string, pointRegion: RegionId) =>
id === selected || (region !== 'all' && pointRegion === region);
// Background dots first so highlighted ones paint on top.
let ordered = $derived(
[...culturalMap].sort(
(a, b) => Number(highlighted(a.id, a.region)) - Number(highlighted(b.id, b.region))
)
);
let hoveredPoint = $derived(culturalMap.find((point) => point.id === hovered));
type Box = { x: number; y: number; w: number; h: number };
type Label = { x: number; y: number; anchor: 'start' | 'end' | 'middle' };
const overlaps = (a: Box, b: Box) =>
a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
// Greedy placement: highlighted labels first, each tries right, left, above,
// below. Background labels that fit nowhere are dropped (hover still names them).
let labels = $derived.by(() => {
const dots: Box[] = culturalMap.map((point) => ({
x: sx(point.x) - 6,
y: sy(point.y) - 6,
w: 12,
h: 12
}));
const placed: Box[] = [];
const result: Record<string, Label> = {};
const candidates = [...ordered].reverse().filter((point) => {
return highlighted(point.id, point.region) || region === 'all';
});
for (const point of candidates) {
const active = highlighted(point.id, point.region);
const w = point.name.length * (active ? 7.4 : 6.4);
const h = 13;
const cx = sx(point.x);
const cy = sy(point.y);
const options: [Box, Label][] = [
[
{ x: cx + 8, y: cy - 7, w, h },
{ x: cx + 8, y: cy + 4, anchor: 'start' }
],
[
{ x: cx - 8 - w, y: cy - 7, w, h },
{ x: cx - 8, y: cy + 4, anchor: 'end' }
],
[
{ x: cx - w / 2, y: cy - 22, w, h },
{ x: cx, y: cy - 11, anchor: 'middle' }
],
[
{ x: cx - w / 2, y: cy + 8, w, h },
{ x: cx, y: cy + 19, anchor: 'middle' }
]
];
const fits = options.find(
([box]) =>
box.x >= 0 &&
box.x + box.w <= width &&
![...placed, ...dots].some((other) => overlaps(box, other))
);
const choice = fits ?? (active ? options[0] : undefined);
if (!choice) continue;
placed.push(choice[0]);
result[point.id] = choice[1];
}
return result;
});
</script>
<div class="wo-chart">
<div class="mb-4 flex flex-wrap gap-2" role="group" aria-label="Highlight a region">
<Button
variant={region === 'all' ? 'secondary' : 'outline'}
size="sm"
aria-pressed={region === 'all'}
onclick={() => (region = 'all')}
>
all
</Button>
{#each usedRegions as id (id)}
<Button
variant={region === id ? 'secondary' : 'outline'}
size="sm"
aria-pressed={region === id}
onclick={() => (region = id)}
>
{regions[id].toLowerCase()}
</Button>
{/each}
</div>
<div class="relative">
<svg
viewBox={`0 0 ${width} ${height}`}
class="w-full"
role="img"
aria-label="Cultural map: countries placed by traditional versus secular values and survival versus self-expression values"
>
{#each ticks as tick (tick)}
<line class="wo-grid" x1={sx(tick)} x2={sx(tick)} y1={pad.top} y2={height - pad.bottom} />
<line class="wo-grid" x1={pad.left} x2={width - pad.right} y1={sy(tick)} y2={sy(tick)} />
{/each}
<line class="wo-axis" x1={sx(0)} x2={sx(0)} y1={pad.top} y2={height - pad.bottom} />
<line class="wo-axis" x1={pad.left} x2={width - pad.right} y1={sy(0)} y2={sy(0)} />
<text class="wo-axis-label" x={pad.left} y={height - 12}>← survival</text>
<text class="wo-axis-label" x={width - pad.right} y={height - 12} text-anchor="end">
self-expression →
</text>
<text
class="wo-axis-label"
x={14}
y={height - pad.bottom}
transform={`rotate(-90 14 ${height - pad.bottom})`}
>
← traditional
</text>
<text
class="wo-axis-label"
x={14}
y={pad.top}
text-anchor="end"
transform={`rotate(-90 14 ${pad.top})`}
>
secular-rational →
</text>
{#each ordered as point (point.id)}
{@const active = highlighted(point.id, point.region)}
{@const label = labels[point.id]}
<g
role="button"
tabindex="0"
aria-label={`${point.name}: ${regions[point.region]}`}
class="cursor-pointer outline-none"
onmouseenter={() => (hovered = point.id)}
onmouseleave={() => (hovered = null)}
onfocus={() => (hovered = point.id)}
onblur={() => (hovered = null)}
onclick={() => onselect?.(point.id)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onselect?.(point.id);
}
}}
>
<circle cx={sx(point.x)} cy={sy(point.y)} r="12" fill="transparent" />
<circle
cx={sx(point.x)}
cy={sy(point.y)}
r={point.id === selected ? 7 : 5}
class={active ? 'wo-dot-active' : region === 'all' ? 'wo-dot' : 'wo-dot-dim'}
/>
{#if label}
<text
x={label.x}
y={label.y}
text-anchor={label.anchor}
class={active ? 'wo-label-strong' : 'wo-label'}>{point.name}</text
>
{/if}
</g>
{/each}
</svg>
{#if hoveredPoint}
<div
class="wo-tooltip"
style:left={`${(sx(hoveredPoint.x) / width) * 100}%`}
style:top={`${(sy(hoveredPoint.y) / height) * 100}%`}
>
<strong>{hoveredPoint.name}</strong>
<span>{regions[hoveredPoint.region]}</span>
<span
>{hoveredPoint.y >= 0 ? 'secular' : 'traditional'}
{Math.abs(hoveredPoint.y).toFixed(1)} ·
{hoveredPoint.x >= 0 ? 'self-expression' : 'survival'}
{Math.abs(hoveredPoint.x).toFixed(1)}</span
>
</div>
{/if}
</div>
<button class="wo-link mt-2" onclick={() => (showTable = !showTable)}>
{showTable ? 'hide table' : 'show as table'}
</button>
{#if showTable}
<table class="wo-table mt-3">
<thead>
<tr
><th>country</th><th>region</th><th>traditional ↔ secular</th><th
>survival ↔ self-expression</th
></tr
>
</thead>
<tbody>
{#each [...culturalMap].sort((a, b) => a.name.localeCompare(b.name)) as point (point.id)}
<tr>
<td>{point.name}</td><td>{regions[point.region]}</td><td>{point.y.toFixed(1)}</td><td
>{point.x.toFixed(1)}</td
>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>