Files
moral-maps/scripts/wvs_react/src/main.jsx
T
wassname 69fd56ba8c Refine WVS page introduction
Signed-off-by: PI[gpt-5.6-terra] <pi@local>
2026-09-18 18:29:32 +08:00

242 lines
21 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { VIEW, RINGS, LABEL_STEPS, assertLayout, box, hits, inside, roundedHull } from './layout.js';
import './style.css';
const LOGO_ROOT = 'wvs/';
function visibleFamilyNames(groups, hidden) {
return Object.keys(groups).filter(family => !hidden.has(family));
}
function Tooltip({ active, geometry, id = 'model-tooltip', panel }) {
if (!active) return null;
const left = active.tooltipLeft ?? `${Math.min(86, geometry.x(active.x) / VIEW.width * 100)}%`;
const top = active.tooltipTop ?? `${Math.min(82, geometry.y(active.y) / VIEW.height * 100)}%`;
const release = active.provenance.release_created && <span>release {active.provenance.release_created}</span>;
return <aside id={id} className="tooltip" style={{ left, top }} role="status">
<strong>{active.name}</strong>
{active.tooltipScore != null && <span>HLE score: {active.tooltipScore.toFixed(3)}</span>}
{panel === 'secular' && <span>Secular-Rational: {active.y.toFixed(3)}</span>}
{panel === 'self-expression' && <span>Self-expression: {active.tooltipValue.toFixed(3)}</span>}
{!panel && <><span>Self-expression/Survival: {active.x.toFixed(3)}</span><span>Traditional/Secular-Rational: {active.y.toFixed(3)}</span></>}
{active.tooltipScore == null && release}
</aside>;
}
function ModelMarker({ model, placement, geometry, setActive, clearActive, markerRef, logo }) {
const cx = geometry.x(model.x), cy = geometry.y(model.y);
const label = model.label ? placement[`model:${model.name}`] : null;
const leader = label && Math.hypot(label.cx - cx, label.cy - cy) > 20;
return <g ref={markerRef} className="model-mark" data-family={model.family} data-model={model.name} data-x={model.x} data-y={model.y}
tabIndex="0" role="button" aria-label={`${model.name}, ${model.family}`} aria-describedby="model-tooltip"
onPointerEnter={() => setActive(model)} onPointerLeave={clearActive}
onFocus={() => setActive(model)} onBlur={clearActive}>
{leader && <line className="leader" x1={cx} y1={cy} x2={label.cx} y2={label.cy} />}
<circle className="model-ring" cx={cx} cy={cy} r="8" stroke={model.color} />
<image href={`${LOGO_ROOT}${logo}`} x={cx - 5} y={cy - 5} width="10" height="10" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false" />
{label && <text className="model-label" x={label.cx} y={label.cy + 4} textAnchor="middle">{model.label}</text>}
</g>;
}
function ordinaryLeastSquares(models, xValue, yValue) {
const x = models.map(xValue);
const y = models.map(yValue);
const uniqueX = new Set(x);
if (uniqueX.size < 2) return null;
const xMean = x.reduce((sum, value) => sum + value, 0) / x.length;
const yMean = y.reduce((sum, value) => sum + value, 0) / y.length;
const slope = x.reduce((sum, value, index) => sum + (value - xMean) * (y[index] - yMean), 0)
/ x.reduce((sum, value) => sum + (value - xMean) ** 2, 0);
const intercept = yMean - slope * xMean;
const residual = y.reduce((sum, value, index) => sum + (value - (intercept + slope * x[index])) ** 2, 0);
const total = y.reduce((sum, value) => sum + (value - yMean) ** 2, 0);
return { slope, intercept, n: models.length, r2: total === 0 ? null : 1 - residual / total };
}
function ReleaseScatter({ data, hidden, field, title, axisMode }) {
const dated = useMemo(() => data.models.filter(model => Number.isFinite(Date.parse(model.provenance.release_created ?? '')))
.toSorted((a, b) => a.provenance.release_created.localeCompare(b.provenance.release_created) || a.name.localeCompare(b.name)), [data]);
const matched = useMemo(() => data.models.filter(model => Number.isFinite(model.provenance.hle_score))
.toSorted((a, b) => a.provenance.hle_score - b.provenance.hle_score || a.name.localeCompare(b.name)), [data]);
const plotted = axisMode === 'release-date' ? dated : matched;
const visible = plotted.filter(model => !hidden.has(model.family));
const coordinate = model => field === 'x' ? -model.x : model.y;
const xValue = model => axisMode === 'release-date' ? Date.parse(model.provenance.release_created) : model.provenance.hle_score;
const fit = ordinaryLeastSquares(visible, xValue, coordinate);
const [active, setActive] = useState(null);
const width = 1200, height = 320, left = 96, right = 35, top = 42, bottom = 48;
const xValues = plotted.map(xValue);
const values = plotted.map(coordinate);
const minX = Math.min(...xValues), maxX = Math.max(...xValues);
const minValue = Math.min(...values), maxValue = Math.max(...values);
const plotX = value => left + (value - minX) / (maxX - minX || 1) * (width - left - right);
const valueY = value => top + (maxValue - value) / (maxValue - minValue || 1) * (height - top - bottom);
const panelId = `release-${field}`;
const tooltipId = `${panelId}-tooltip`;
const yDirection = field === 'y' ? data.axis.y : [...data.axis.x].reverse();
const visibleX = visible.map(xValue);
const fitEnd = fit && [Math.min(...visibleX), Math.max(...visibleX)].map(value => [plotX(value), valueY(fit.intercept + fit.slope * value)]);
const frontier = useMemo(() => {
if (axisMode !== 'release-date') return [];
let high = -Infinity;
return visible.filter(model => Number.isFinite(model.provenance.hle_score)).filter(model => {
if (model.provenance.hle_score <= high) return false;
high = model.provenance.hle_score;
return true;
});
}, [axisMode, visible]);
const frontierPlacement = useMemo(() => {
const bounds = { left, right: width - right, top, bottom: height - bottom };
const taken = [
...plotted.map(model => box(plotX(xValue(model)), valueY(coordinate(model)), 16, 16)),
...(fit ? [box(left + 66, top + 9, 128, 18)] : []),
];
const placement = {};
for (const model of frontier) {
const anchor = { x: plotX(xValue(model)), y: valueY(coordinate(model)) };
const size = { width: model.name.length * 8 + 12, height: 18 };
let found = null;
for (const step of LABEL_STEPS) {
for (const [dx, dy] of RINGS) {
const candidate = box(anchor.x + dx * (step + size.width / 3), anchor.y + dy * step, size.width, size.height);
if (inside(candidate, bounds) && !taken.some(obstacle => hits(candidate, obstacle))) { found = candidate; break; }
}
if (found) break;
}
if (!found) {
for (let cy = bounds.top + size.height / 2; cy <= bounds.bottom - size.height / 2 && !found; cy += size.height + 4) {
for (let cx = bounds.left + size.width / 2; cx <= bounds.right - size.width / 2; cx += 12) {
const candidate = box(cx, cy, size.width, size.height);
if (!taken.some(obstacle => hits(candidate, obstacle))) { found = candidate; break; }
}
}
}
if (!found) found = box(anchor.x, Math.max(bounds.top + size.height / 2, anchor.y - size.height), size.width, size.height);
placement[model.name] = { ...found, anchor };
taken.push(found);
}
return placement;
}, [frontier, plotted, minX, maxX, minValue, maxValue]);
const activate = (model, event) => {
const box = event.currentTarget.closest('.scatter-shell').getBoundingClientRect();
const point = event.currentTarget.getBoundingClientRect();
setActive({ ...model, tooltipValue: coordinate(model), tooltipScore: axisMode === 'capability' ? model.provenance.hle_score : null, tooltipLeft: `${Math.min(82, (point.left - box.left) / box.width * 100)}%`, tooltipTop: `${Math.min(78, (point.top - box.top) / box.height * 100)}%` });
};
return <section className="release-panel" data-coordinate={field} aria-labelledby={`${panelId}-heading`}>
<h2 id={`${panelId}-heading`}>{title}</h2>
<div className="scatter-shell">
<svg id={`${panelId}-svg`} viewBox={`0 0 ${width} ${height}`} role="img" aria-labelledby={`${panelId}-svg-title ${panelId}-svg-desc`} data-axis-mode={axisMode} data-panel-model-count={visible.length} data-omitted-model-count={data.models.length - plotted.length} data-fit-n={fit?.n ?? 0} data-fit-r2={fit?.r2 ?? ''} data-fit-slope={fit?.slope ?? ''}>
<title id={`${panelId}-svg-title`}>{title}</title>
<desc id={`${panelId}-svg-desc`}>Scatter plot with {axisMode === 'release-date' ? 'release date' : 'HLE score'} on the horizontal axis and {yDirection.join(' to ')} increasing upward on the vertical axis. {visible.length} of {plotted.length} matched models are visible from {visibleFamilyNames(Object.groupBy(data.models, model => model.family), hidden).join(', ') || 'no families'}; {data.models.length - plotted.length} plotted models lack this x value and are omitted only here. Each white-ring logo mark is a model. {fit ? `The thin line is an ordinary least squares descriptive fit to the ${fit.n} currently visible matched models${fit.r2 === null ? '; R squared is unavailable because the y values are constant' : `; R squared is ${fit.r2.toFixed(2)}`}.` : 'The fit is hidden because fewer than two distinct x values are visible.'} Hover or keyboard focus a mark for model-specific details.</desc>
<defs><marker id={`${panelId}-arrow`} markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z" className="scatter-arrow" /></marker><clipPath id={`${panelId}-fit-clip`}><rect x={left} y={top} width={width - left - right} height={height - top - bottom} /></clipPath></defs>
<rect className="canvas" width={width} height={height} />
<g className="scatter-grid">{Array.from({ length: 5 }, (_, index) => <line key={index} x1={left} x2={width - right} y1={top + index * (height - top - bottom) / 4} y2={top + index * (height - top - bottom) / 4} />)}</g>
<line className="scatter-axis" x1={left} x2={width - right} y1={height - bottom} y2={height - bottom} />
<line className="scatter-axis" x1={left} x2={left} y1={height - bottom} y2={top} markerEnd={`url(#${panelId}-arrow)`} />
<text className="scatter-y-label" x="17" y={(top + height - bottom) / 2} textAnchor="middle" transform={`rotate(-90 17 ${(top + height - bottom) / 2})`}>{yDirection.join(' -> ')} {'↑'}</text>
<text className="scatter-tick" x={left} y={height - 18}>{axisMode === 'release-date' ? new Date(minX).toISOString().slice(0, 10) : minX.toFixed(1)}</text>
<text className="scatter-tick" x={width - right} y={height - 18} textAnchor="end">{axisMode === 'release-date' ? new Date(maxX).toISOString().slice(0, 10) : maxX.toFixed(1)}</text>
<text className="scatter-tick" x={left - 8} y={top + 4} textAnchor="end">{maxValue.toFixed(2)}</text>
<text className="scatter-tick" x={left - 8} y={height - bottom} textAnchor="end">{minValue.toFixed(2)}</text>
{fitEnd && <g className="release-fit" data-fit-n={fit.n} data-fit-r2={fit.r2 ?? ''}><line clipPath={`url(#${panelId}-fit-clip)`} x1={fitEnd[0][0]} y1={fitEnd[0][1]} x2={fitEnd[1][0]} y2={fitEnd[1][1]} /> <text x={left + 6} y={top + 13}>OLS, n={fit.n}, R² {fit.r2 === null ? 'unavailable' : fit.r2.toFixed(2)}</text></g>}
{!fit && <text className="release-fit-unavailable" x={left + 6} y={top + 13}>Fit unavailable: fewer than two release dates</text>}
{plotted.map(model => <g key={model.name} className="release-mark" data-family={model.family} data-release-model={model.name} data-release-date={model.provenance.release_created} data-hle-score={model.provenance.hle_score} data-coordinate-value={coordinate(model)} display={hidden.has(model.family) ? 'none' : 'inline'}
tabIndex="0" role="button" aria-label={`${model.name}, ${axisMode === 'release-date' ? model.provenance.release_created : `HLE score ${model.provenance.hle_score}`}`} aria-describedby={tooltipId}
onPointerEnter={event => activate(model, event)} onPointerLeave={() => setActive(null)} onFocus={event => activate(model, event)} onBlur={() => setActive(null)}>
<circle className="model-ring" cx={plotX(xValue(model))} cy={valueY(coordinate(model))} r="8" stroke={model.color} />
<image href={`${LOGO_ROOT}${data.logos[model.family]}`} x={plotX(xValue(model)) - 5} y={valueY(coordinate(model)) - 5} width="10" height="10" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false" />
</g>)}
{axisMode === 'release-date' && frontier.map(model => <g key={`frontier-${model.name}`} className="frontier-label" data-frontier-model={model.name}><text x={frontierPlacement[model.name].cx} y={frontierPlacement[model.name].cy + 4} textAnchor="middle">{model.name}</text></g>)}
</svg>
<Tooltip active={active} geometry={null} id={tooltipId} panel={field === 'y' ? 'secular' : 'self-expression'} />
</div>
</section>;
}
function ReleaseScatters({ data, hidden, axisMode, setAxisMode }) {
const xLabel = axisMode === 'release-date' ? 'Release date' : data.capability_x.label;
return <section className="release-panels" aria-label="Release-date and capability scatter panels">
<div className="release-axis-selector">
<label>Release-panel x axis <select aria-label="Release-panel x axis" aria-controls="release-y-svg release-x-svg" value={axisMode} onChange={event => setAxisMode(event.target.value)}>
<option value="release-date">Release date</option>
<option value="capability">{data.capability_x.label}</option>
</select></label>
{axisMode === 'capability' && <span><a href={data.capability_x.source_url}>Artificial Analysis HLE score</a>, saved {data.capability_x.fetched_utc.slice(0, 10)}. {data.capability_x.matched_models} matched, {data.models.length - data.capability_x.matched_models} omitted.</span>}
</div>
<ReleaseScatter data={data} hidden={hidden} field="y" axisMode={axisMode} title={`${xLabel} vs Secular-Rational`} />
<ReleaseScatter data={data} hidden={hidden} field="x" axisMode={axisMode} title={`${xLabel} vs Self-expression`} />
</section>;
}
function Map({ data }) {
const query = new URLSearchParams(location.search);
const [hidden, setHidden] = useState(() => new Set(query.get('hide')?.split(',').filter(Boolean)));
const [axisMode, setAxisMode] = useState(() => query.get('axis') === 'capability' ? 'capability' : 'release-date');
const [active, setActive] = useState(() => data.models.find(model => model.name === (query.get('tooltip') || query.get('focus'))) ?? null);
const focusName = query.get('focus');
const focusRef = useRef(null);
const groups = useMemo(() => Object.groupBy(data.models, model => model.family), [data]);
const { labels, geometry } = useMemo(() => assertLayout(data), [data]);
const visibleFamilies = visibleFamilyNames(groups, hidden);
const visibleModelCount = data.models.filter(model => !hidden.has(model.family)).length;
useEffect(() => {
if (focusName) focusRef.current?.focus();
}, [focusName]);
const clearActive = event => {
if (event.currentTarget.matches(':focus')) return;
setActive(null);
};
const toggle = family => setHidden(old => {
const next = new Set(old);
if (next.has(family)) next.delete(family); else next.add(family);
return next;
});
const xMedian = geometry.x(data.median.x), yMedian = geometry.y(data.median.y);
return <>
<section className="controls" aria-label="Model-family visibility">{Object.entries(groups).map(([family]) => {
const visible = !hidden.has(family);
return <button key={family} className="chip" type="button" aria-pressed={visible} onClick={() => toggle(family)}>
<img src={`${LOGO_ROOT}${data.logos[family]}`} alt="" aria-hidden="true" />{family}
</button>;
})}</section>
<div className="chart-shell">
<svg viewBox={`0 0 ${VIEW.width} ${VIEW.height}`} role="img" aria-labelledby="map-svg-title map-svg-desc" data-median-x={data.median.x} data-median-y={data.median.y} data-visible-model-count={visibleModelCount}>
<title id="map-svg-title">Moral Maps: Where Do Frontier Models' Cultural Values Lie?</title>
<desc id="map-svg-desc">World Values Survey cultural map. Horizontal direction runs from Self-expression on the left to Survival on the right. Vertical direction runs from Traditional below to Secular-Rational above. Coloured dots are selected WVS countries, outlines are cultural regions, and white-ring logo marks are models. {visibleModelCount} model marks are visible from {visibleFamilies.join(', ') || 'no families'}. Use the family controls to hide marks and labels. Hover or keyboard focus a model for model-specific details.</desc>
<defs><marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto"><path d="M0,0 L0,6 L6,3 z" className="arrow" /></marker></defs>
<rect className="canvas" width={VIEW.width} height={VIEW.height} />
<g className="grid">{Array.from({ length: 8 }, (_, index) => <line key={`v${index}`} x1={geometry.bounds.left + index * (geometry.bounds.right - geometry.bounds.left) / 7} y1={geometry.bounds.top} x2={geometry.bounds.left + index * (geometry.bounds.right - geometry.bounds.left) / 7} y2={geometry.bounds.bottom} />)}{Array.from({ length: 6 }, (_, index) => <line key={`h${index}`} x1={geometry.bounds.left} y1={geometry.bounds.top + index * (geometry.bounds.bottom - geometry.bounds.top) / 5} x2={geometry.bounds.right} y2={geometry.bounds.top + index * (geometry.bounds.bottom - geometry.bounds.top) / 5} />)}</g>
<line className="median" x1={geometry.bounds.left} y1={yMedian} x2={geometry.bounds.right} y2={yMedian} />
<line className="median" x1={xMedian} y1={geometry.bounds.top} x2={xMedian} y2={geometry.bounds.bottom} />
{data.zone_hulls.map(zone => <path key={zone.name} className="zone" d={roundedHull(zone.points, geometry)} stroke={zone.color} />)}
{data.countries.map(country => <g key={country.name}><circle className="country" data-country={country.name} data-x={country.x} data-y={country.y} cx={geometry.x(country.x)} cy={geometry.y(country.y)} r="3.5" fill={country.color} />{country.label && <text className="country-label" x={labels[`country:${country.name}`].cx} y={labels[`country:${country.name}`].cy + 4} textAnchor="middle">{country.name}</text>}</g>)}
{data.zone_hulls.map(zone => <text key={zone.name} className="zone-label" x={labels[`zone:${zone.name}`].cx} y={labels[`zone:${zone.name}`].cy + 5} textAnchor="middle" fill={zone.color}>{zone.name}</text>)}
{Object.entries(groups).map(([family, models]) => <g key={family} data-family={family} display={hidden.has(family) ? 'none' : 'inline'}>{models.map(model => <ModelMarker key={model.name} model={model} placement={labels} geometry={geometry} setActive={setActive} clearActive={clearActive} markerRef={model.name === focusName ? focusRef : null} logo={data.logos[model.family]} />)}</g>)}
<g className="poles"><line x1={xMedian} y1="62" x2={xMedian} y2={geometry.bounds.top} markerEnd="url(#arrow)" /><line x1={xMedian} y1={geometry.bounds.bottom} x2={xMedian} y2="838" markerEnd="url(#arrow)" /><line x1="64" y1={yMedian} x2={geometry.bounds.left} y2={yMedian} markerEnd="url(#arrow)" /><line x1={geometry.bounds.right} y1={yMedian} x2="1184" y2={yMedian} markerEnd="url(#arrow)" /><text x={xMedian} y="40" textAnchor="middle">{data.axis.y[1]}</text><text x={xMedian} y="870" textAnchor="middle">{data.axis.y[0]}</text><text x="25" y={yMedian + 7}>{data.axis.x[0]}</text><text x="1136" y={yMedian + 7} textAnchor="end">{data.axis.x[1]}</text></g>
<text className="map-title" x={geometry.bounds.left + 8} y={geometry.bounds.bottom - 78}>{data.title.split('\n').map((line, index) => <tspan key={line} x={geometry.bounds.left + 8} dy={index ? 17 : 0}>{line}</tspan>)}</text>
<text className="map-note" x={geometry.bounds.left + 8} y={geometry.bounds.bottom - 34} textAnchor="start">{data.note.split('\n').map((line, index) => <tspan key={line} x={geometry.bounds.left + 8} dy={index ? 11 : 0}>{line}</tspan>)}</text>
</svg>
<Tooltip active={active} geometry={geometry} />
</div>
<p className="map-explanation">This uses the <a href="https://www.worldvaluessurvey.org/">World Values Survey</a>, which social scientists designed to compare human cultures. My theory is that a survey broad enough to cover human cultures might also capture LLMs, which are potentially outside human culture.</p>
<p className="economist-quote">This updates <a href="https://www.economist.com/briefing/2026/06/25/ai-models-values-are-very-different-from-most-peoples">The Economist’s earlier plot</a>, which found that “the models are often more extreme than the average respondent in every country included in the polling.”</p>
<ReleaseScatters data={data} hidden={hidden} axisMode={axisMode} setAxisMode={setAxisMode} />
<p className="caption">Use the family controls to compare saved rated coordinates. The lines are weak descriptive correlations for the matched models currently shown; n and R² update with visibility. Hover or keyboard focus a mark for its values. See the <a href="https://github.com/wassname/moral-maps">code and records</a>.</p>
</>;
}
function App() {
const [data, setData] = useState(null);
useEffect(() => { fetch('wvs/wvs_map_data.json').then(response => response.json()).then(setData); }, []);
return <main>
<h1>Moral Maps: Where Do Frontier Models' Cultural Values Lie?</h1>
<p className="lede">Where do models sit in terms of cultural values? Are they moving? Are Chinese models different?</p>
{data && <Map data={data} />}
</main>;
}
createRoot(document.getElementById('root')).render(<App/>);