mirror of
https://github.com/wassname/moral-maps.git
synced 2026-09-22 13:20:36 +08:00
Restyle React WVS map with static visual grammar
Co-Authored-By: PI[gpt-5.6-terra] <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
co-authored by
PI[gpt-5.6-terra]
parent
cb37fff44f
commit
92d7acef65
@@ -0,0 +1,79 @@
|
||||
export const VIEW = { width: 1200, height: 900, pad: { left: 76, right: 46, top: 55, bottom: 82 } };
|
||||
|
||||
// Ported from ml-bench: try near each anchor before growing an expanding ring.
|
||||
export const RINGS = [[0, -1], [0, 1], [-1, 0], [1, 0], [-1, -1], [1, -1], [-1, 1], [1, 1]];
|
||||
export const LABEL_STEPS = [22, 34, 48, 64, 82, 104, 130, 160, 196];
|
||||
|
||||
export const box = (cx, cy, width, height) => ({ cx, cy, width, height });
|
||||
export const hits = (a, b) => Math.abs(a.cx - b.cx) * 2 < a.width + b.width && Math.abs(a.cy - b.cy) * 2 < a.height + b.height;
|
||||
export const inside = (candidate, bounds) => candidate.cx - candidate.width / 2 >= bounds.left &&
|
||||
candidate.cx + candidate.width / 2 <= bounds.right && candidate.cy - candidate.height / 2 >= bounds.top &&
|
||||
candidate.cy + candidate.height / 2 <= bounds.bottom;
|
||||
|
||||
export function projectGeometry(data) {
|
||||
const points = [...data.countries, ...data.models];
|
||||
const minX = Math.min(...points.map(point => point.x));
|
||||
const maxX = Math.max(...points.map(point => point.x));
|
||||
const minY = Math.min(...points.map(point => point.y));
|
||||
const maxY = Math.max(...points.map(point => point.y));
|
||||
const xMargin = (maxX - minX) * 0.17;
|
||||
const yMargin = (maxY - minY) * 0.17;
|
||||
const limits = { x0: minX - xMargin, x1: maxX + xMargin, y0: minY - yMargin, y1: maxY + yMargin * 1.22 };
|
||||
const bounds = { left: VIEW.pad.left, right: VIEW.width - VIEW.pad.right, top: VIEW.pad.top, bottom: VIEW.height - VIEW.pad.bottom };
|
||||
const x = value => bounds.left + (value - limits.x0) / (limits.x1 - limits.x0) * (bounds.right - bounds.left);
|
||||
const y = value => bounds.bottom - (value - limits.y0) / (limits.y1 - limits.y0) * (bounds.bottom - bounds.top);
|
||||
return { x, y, bounds, limits };
|
||||
}
|
||||
|
||||
function labelSize(text, kind) {
|
||||
const font = kind === 'zone' ? 15 : kind === 'model' ? 13 : 11;
|
||||
return { width: text.length * font * 0.59 + 10, height: font + 7 };
|
||||
}
|
||||
|
||||
export function placeLabels(data, geometry) {
|
||||
const { x, y, bounds } = geometry;
|
||||
const taken = [
|
||||
...data.countries.map(point => box(x(point.x), y(point.y), 10, 10)),
|
||||
...data.models.map(point => box(x(point.x), y(point.y), 22, 22)),
|
||||
];
|
||||
const labels = [
|
||||
...data.models.filter(point => point.label).map(point => ({ id: `model:${point.name}`, point, text: point.label, kind: 'model' })),
|
||||
...data.countries.filter(point => point.label).map(point => ({ id: `country:${point.name}`, point, text: point.name, kind: 'country' })),
|
||||
...data.zone_hulls.map(zone => ({ id: `zone:${zone.name}`, point: { x: zone.label_anchor[0], y: zone.label_anchor[1] }, text: zone.name, kind: 'zone' })),
|
||||
];
|
||||
const placements = {};
|
||||
for (const label of labels) {
|
||||
const anchor = { x: x(label.point.x), y: y(label.point.y) };
|
||||
const size = labelSize(label.text, label.kind);
|
||||
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) throw new Error(`no collision-free label location for ${label.id}`);
|
||||
placements[label.id] = { ...found, anchor, kind: label.kind, text: label.text };
|
||||
taken.push(found);
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function assertLayout(data) {
|
||||
const geometry = projectGeometry(data);
|
||||
const labels = placeLabels(data, geometry);
|
||||
const entries = Object.entries(labels);
|
||||
for (const [id, candidate] of entries) {
|
||||
if (!inside(candidate, geometry.bounds)) throw new Error(`label out of bounds: ${id}`);
|
||||
}
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
for (let j = i + 1; j < entries.length; j += 1) {
|
||||
if (hits(entries[i][1], entries[j][1])) throw new Error(`label overlap: ${entries[i][0]}, ${entries[j][0]}`);
|
||||
}
|
||||
}
|
||||
return { labels, geometry };
|
||||
}
|
||||
@@ -1,29 +1,94 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { VIEW, assertLayout } from './layout.js';
|
||||
import './style.css';
|
||||
|
||||
const hull = points => {
|
||||
const p = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
||||
const cross = (o, a, b) => (a.x-o.x)*(b.y-o.y) - (a.y-o.y)*(b.x-o.x);
|
||||
const half = xs => { const out = []; for (const point of xs) { while (out.length > 1 && cross(out.at(-2), out.at(-1), point) <= 0) out.pop(); out.push(point); } return out; };
|
||||
return [...half(p), ...half([...p].reverse()).slice(1, -1)];
|
||||
};
|
||||
function Tooltip({ active, geometry, data }) {
|
||||
if (!active) return null;
|
||||
const { provenance } = active;
|
||||
const left = `${Math.min(86, geometry.x(active.x) / VIEW.width * 100)}%`;
|
||||
const top = `${Math.min(82, geometry.y(active.y) / VIEW.height * 100)}%`;
|
||||
const samples = provenance.items === null ? 'historical coordinate' : `${provenance.items} items x ${provenance.samples} samples`;
|
||||
return <aside id="model-tooltip" className="tooltip" style={{ left, top }} role="status">
|
||||
<strong>{active.name}</strong>
|
||||
<span>{active.family}</span>
|
||||
<span>{data.axis.x[0]} {'->'} {data.axis.x[1]}: {active.x.toFixed(3)}</span>
|
||||
<span>{data.axis.y[0]} {'->'} {data.axis.y[1]}: {active.y.toFixed(3)}</span>
|
||||
<span>{provenance.readout}, {samples}</span>
|
||||
<span>{provenance.release_created ? `release ${provenance.release_created}` : provenance.release_source}</span>
|
||||
</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="11" stroke={model.color} />
|
||||
<image href={`../${logo}`} x={cx - 7} y={cy - 7} width="14" height="14" preserveAspectRatio="xMidYMid meet" />
|
||||
{label && <text className="model-label" x={label.cx} y={label.cy + 4} textAnchor="middle">{model.label}</text>}
|
||||
</g>;
|
||||
}
|
||||
|
||||
function Map({ data }) {
|
||||
const [hidden, setHidden] = useState(() => new Set(new URLSearchParams(location.search).get('hide')?.split(',').filter(Boolean)));
|
||||
const all = [...data.countries, ...data.models];
|
||||
const [minX, maxX] = [Math.min(...all.map(p => p.x)), Math.max(...all.map(p => p.x))];
|
||||
const [minY, maxY] = [Math.min(...all.map(p => p.y)), Math.max(...all.map(p => p.y))];
|
||||
const x = v => 70 + (v-minX)/(maxX-minX)*860, y = v => 650 - (v-minY)/(maxY-minY)*580;
|
||||
const query = new URLSearchParams(location.search);
|
||||
const [hidden, setHidden] = useState(() => new Set(query.get('hide')?.split(',').filter(Boolean)));
|
||||
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 offsets = { grok:{dx:-72,dy:-30}, muse:{dx:12,dy:-11}, mistral:{dx:-96,dy:-26}, llama:{dx:-96,dy:12} };
|
||||
return <><div className="controls">{Object.entries(groups).map(([family, models]) => <button key={family} className={hidden.has(family) ? 'off' : ''} style={{'--color': models[0].color}} onClick={() => setHidden(old => { const next = new Set(old); next.has(family) ? next.delete(family) : next.add(family); return next; })}>{family}: {data.latest_by_family[family].name}</button>)}</div>
|
||||
<svg viewBox="0 0 1000 720" aria-label="React WVS cultural map"><line x1="70" y1={y(data.median.y)} x2="930" y2={y(data.median.y)} className="axis"/><line x1={x(data.median.x)} y1="70" x2={x(data.median.x)} y2="650" className="axis"/>
|
||||
{Object.entries(data.zones).map(([name, names]) => { const h=hull(data.countries.filter(p=>names.includes(p.name))); const c=h.reduce((s,p)=>({x:s.x+p.x/h.length,y:s.y+p.y/h.length}),{x:0,y:0}); return <g key={name}><polygon points={h.map(p=>`${x(p.x)},${y(p.y)}`).join(' ')} className="zone"/><text x={x(c.x)} y={y(c.y)} className="zoneLabel">{name}</text></g>; })}
|
||||
{data.countries.map(p => <circle key={p.name} cx={x(p.x)} cy={y(p.y)} r="3" className="country"><title>{p.name}</title></circle>)}
|
||||
{Object.entries(groups).map(([family, models]) => <g key={family} display={hidden.has(family) ? 'none' : 'inline'}>{models.map(p => { const o=offsets[family] ?? {dx:11,dy:-11}; return <g key={p.name}><path d={`M ${x(p.x)} ${y(p.y)-8} L ${x(p.x)+2.4} ${y(p.y)-2.4} L ${x(p.x)+8} ${y(p.y)-2.4} L ${x(p.x)+3.6} ${y(p.y)+1.6} L ${x(p.x)+5.2} ${y(p.y)+7} L ${x(p.x)} ${y(p.y)+4} L ${x(p.x)-5.2} ${y(p.y)+7} L ${x(p.x)-3.6} ${y(p.y)+1.6} L ${x(p.x)-8} ${y(p.y)-2.4} L ${x(p.x)-2.4} ${y(p.y)-2.4} Z`} fill={p.color} className="star"><title>{p.name}</title></path>{p.label && <><line x1={x(p.x)} y1={y(p.y)} x2={x(p.x)+o.dx*.82} y2={y(p.y)+o.dy*.82} stroke={p.color}/><text x={x(p.x)+o.dx} y={y(p.y)+o.dy} fill={p.color} className="modelLabel">{p.label}</text></>}</g>; })}</g>)}
|
||||
<text x="70" y="690" className="axisLabel">{data.axis.x[0]}</text><text x="930" y="690" textAnchor="end" className="axisLabel">{data.axis.x[1]}</text><text x="500" y="30" textAnchor="middle" className="axisLabel">{data.axis.y[1]}</text><text x="500" y="710" textAnchor="middle" className="axisLabel">{data.axis.y[0]}</text>
|
||||
</svg></>;
|
||||
const { labels, geometry } = useMemo(() => assertLayout(data), [data]);
|
||||
|
||||
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, models]) => {
|
||||
const visible = !hidden.has(family);
|
||||
const latest = data.latest_by_family[family].name;
|
||||
return <button key={family} className="chip" type="button" aria-pressed={visible} onClick={() => toggle(family)}>
|
||||
<img src={`../${data.logos[family]}`} alt="" />{family}: {latest}
|
||||
</button>;
|
||||
})}</section>
|
||||
<div className="chart-shell">
|
||||
<svg viewBox={`0 0 ${VIEW.width} ${VIEW.height}`} role="img" aria-label="Frontier LLMs on the World Values Survey" data-median-x={data.median.x} data-median-y={data.median.y}>
|
||||
<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 => <polygon key={zone.name} className="zone" points={zone.points.map(([px, py]) => `${geometry.x(px)},${geometry.y(py)}`).join(' ')} 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 - 34}>{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 - 7}>{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} data={data} />
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
function App() { const [data, setData] = useState(null); useEffect(() => { fetch('../wvs_map_data.json').then(r=>r.json()).then(setData); }, []); return <main><h1>Frontier LLMs on the World Values Survey</h1><p>React/SVG renderer using the shared WVS coordinate artifact. Filled chips show a family; outlined chips hide its stars and latest label.</p>{data && <Map data={data}/>}</main>; }
|
||||
|
||||
function App() {
|
||||
const [data, setData] = useState(null);
|
||||
useEffect(() => { fetch('../wvs_map_data.json').then(response => response.json()).then(setData); }, []);
|
||||
return <main><h1>Frontier LLMs on the World Values Survey</h1><p className="lede">React/SVG rendering of the shared WVS coordinate artifact. White-ring marks use locally saved lab logos. Focus or hover a model for its measured readout and release provenance.</p>{data && <Map data={data} />}<p className="caption">Historical coordinates are recovered rounded display values. Coder and vision-language Qwen variants are visible but not used in direct-instruct family trends. The static map remains available at <a href="../">/wvs/</a>.</p></main>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App/>);
|
||||
|
||||
@@ -1 +1 @@
|
||||
body{margin:0;background:#faf8f2;color:#292524;font:16px system-ui,sans-serif}main{max-width:1100px;margin:auto;padding:1rem}.controls{display:flex;flex-wrap:wrap;gap:.4rem;margin:1rem 0}button{border:2px solid var(--color);border-radius:1rem;padding:.3rem .7rem;background:var(--color);color:white;font-weight:600}button.off{background:white;color:#292524;opacity:.5;text-decoration:line-through}svg{width:100%;height:auto;background:#faf8f2;border:1px solid #292524}.axis{stroke:#c9c4b4}.zone{fill:none;stroke:#94a3b8;stroke-width:2;stroke-dasharray:5 3}.zoneLabel{fill:#475569;font-style:italic;font-weight:700;text-anchor:middle}.country{fill:#64748b;stroke:white;stroke-width:.6}.star{stroke:white;stroke-width:1}.modelLabel{font-size:10px;font-weight:700}
|
||||
:root{--ink:#333;--muted:#8a857a;--rule:#eceadf;--canvas:#faf8f2}*{box-sizing:border-box}body{margin:0;background:var(--canvas);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1200px;margin:auto;padding:1.5rem 1rem 3rem}h1{font-size:1.65rem;line-height:1.2;margin:0 0 .35rem}.lede,.caption{max-width:75ch;color:#59544b}.controls{display:flex;flex-wrap:wrap;gap:.35rem;margin:1rem 0}.chip{display:flex;align-items:center;gap:.3rem;padding:.22rem .48rem;border:1px solid #d9d5ca;border-radius:999px;background:white;color:var(--ink);font:600 .78rem/1 inherit;cursor:pointer}.chip img{width:13px;height:13px}.chip:hover{border-color:#8a857a}.chip:focus-visible,.model-mark:focus-visible{outline:2px solid #2563eb;outline-offset:2px}.chip[aria-pressed="false"]{color:#8a857a;background:#f6f4ef;text-decoration:line-through}.chip[aria-pressed="false"] img{opacity:.3}.chart-shell{position:relative}.chart-shell svg{display:block;width:100%;height:auto;border:1px solid #ded9cc;background:var(--canvas)}.canvas{fill:var(--canvas)}.grid line{stroke:var(--rule);stroke-width:.7}.median{stroke:#c9c4b4;stroke-width:1.2}.zone{fill:none;stroke-width:2;opacity:.9}.country{stroke:white;stroke-width:.7;opacity:.88}.country-label,.model-label,.zone-label,.poles text{paint-order:stroke;stroke:var(--canvas);stroke-width:4px;stroke-linejoin:round}.country-label{fill:#222;font-size:11px}.model-ring{fill:white;stroke-width:2}.model-mark{cursor:default}.model-mark:focus{outline:none}.model-label{fill:#222;font-size:13px;font-weight:700}.leader{stroke:#aaa398;stroke-width:1}.zone-label{font-size:15px;font-style:italic;font-weight:700}.poles line{stroke:#999;stroke-width:1.8}.arrow{fill:#999}.poles text{fill:#555;font-size:18px;font-weight:700}.map-title{fill:#333;font-size:14px;font-weight:700}.map-note{fill:var(--muted);font-size:10px}.tooltip{position:absolute;z-index:2;max-width:275px;transform:translate(14px,14px);pointer-events:none;border:1px solid #ddd7ca;border-radius:5px;padding:.45rem .6rem;background:rgba(255,255,255,.97);box-shadow:0 2px 8px #0002;font-size:.82rem;line-height:1.35}.tooltip strong,.tooltip span{display:block}.tooltip span{color:#5b554b}.caption{font-size:.9rem;margin-top:1rem}@media(max-width:700px){main{padding:.9rem .5rem 2rem}.chip{font-size:.7rem}.poles text{font-size:14px}.country-label,.model-label{font-size:10px}.tooltip{max-width:230px;font-size:.75rem}}
|
||||
Reference in New Issue
Block a user