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 <[email protected]>
This commit is contained in:
1 parent
499dd94cbb
commit
afb1f191df
16 files changed
+2338
-2
No files matched your search
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { countries, regions, type RegionId } from './data';
|
||||
|
||||
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 class="wo-chip" aria-pressed={region === 'all'} onclick={() => (region = 'all')}>
|
||||
all
|
||||
</button>
|
||||
{#each usedRegions as id (id)}
|
||||
<button class="wo-chip" 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>
|
||||
Reference in new issue
Block a user