mirror of
https://github.com/wassname/moral-maps.git
synced 2026-10-07 11:25:55 +08:00
Co-Authored-By: PI[gpt-5.6-terra] <288921227+claudypoo@users.noreply.github.com>
100 lines
4.9 KiB
JavaScript
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 };
|
|
}
|