Files
moral-maps/scripts/wvs_react/src/layout.js
T
2026-09-17 07:52:58 +08:00

100 lines
4.9 KiB
JavaScript

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 roundedHull(points, geometry) {
const p = points.slice(0, -1).map(([x, y]) => ({ x: geometry.x(x), y: geometry.y(y) }));
if (p.length < 3) throw new Error('a closed hull needs three points');
const at = index => p[(index + p.length) % p.length];
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const toward = (a, b, amount) => ({ x: a.x + (b.x - a.x) * amount, y: a.y + (b.y - a.y) * amount });
const corner = index => {
const previous = at(index - 1), current = at(index), next = at(index + 1);
const radius = Math.min(0.24, 14 / distance(previous, current), 14 / distance(current, next));
return { before: toward(current, previous, radius), current, after: toward(current, next, radius) };
};
const first = corner(0);
let path = `M ${first.after.x} ${first.after.y}`;
for (let index = 1; index <= p.length; index += 1) {
const c = corner(index % p.length);
path += ` L ${c.before.x} ${c.before.y} Q ${c.current.x} ${c.current.y} ${c.after.x} ${c.after.y}`;
}
return `${path} Z`;
}
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 };
}