All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
236 lines
7.1 KiB
Svelte
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>
|