Files
root-at-skicandClaude Opus 5.5 22815a9940 Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:44:15 +03:00

216 lines
10 KiB
JavaScript

import { normalizeRoutePoints, rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
import { createSpatialGrid } from '../shared/spatial-grid.mjs';
// A bounded fallback for an unpinned label whose usual position collides.
// It never routes an edge, moves a node, expands the canvas, or rewrites input.
export function placeAutomaticLabels({
labels, routes, components, titles, viewBox, placementBottom = viewBox[1], fallbackRing = true, keepFallbackNearRoute = false,
gridSweep = false,
}) {
const placed = [...labels];
const obstacles = [...components, ...titles];
const segments = routes.flatMap(({ relationIndex, points }) => {
const normalized = normalizeRoutePoints(points);
return normalized.slice(1).map((end, index) => ({ relationIndex, start: normalized[index], end }));
});
const inside = rect => (
rect.x >= 0 && rect.y >= 0
&& rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= viewBox[1]
);
// The mask test asked every segment about every candidate position. Segments
// go into a uniform grid once, and a candidate only asks the cells it covers.
const SEGMENT_CELL = 120;
const segmentGrid = createSpatialGrid(SEGMENT_CELL);
for (const segment of segments) {
const [sx, sy] = segment.start;
const [ex, ey] = segment.end;
segmentGrid.insert({
minX: Math.min(sx, ex), maxX: Math.max(sx, ex),
minY: Math.min(sy, ey), maxY: Math.max(sy, ey),
}, segment);
}
const segmentsNear = (rect, margin) => segmentGrid.query({
minX: rect.x - margin, maxX: rect.x + rect.width + margin,
minY: rect.y - margin, maxY: rect.y + rect.height + margin,
});
const masksRoute = rect => {
for (const segment of segmentsNear(rect, 4)) {
if (segment.relationIndex === rect.relationIndex) continue;
if (segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4) return true;
}
return false;
};
const overlapsLabel = (rect, index, gap = 0) => placed.some((other, otherIndex) => (
otherIndex !== index && rectsOverlap(rect, other, gap)
));
const clear = (rect, index) => (
inside(rect) && rect.y + rect.height <= placementBottom
&& !obstacles.some(obstacle => rectsOverlap(rect, obstacle, 2))
&& !overlapsLabel(rect, index, 2) && !masksRoute(rect)
);
const rectAt = (label, lx, ly) => ({
...label, lx, ly, x: lx - label.width / 2, y: ly - 10,
});
// An opted-in grid layout runs parallel lines through shared gaps. Rank
// every position along all of the label's own segments: beside the line
// first, then centred on its own line (the plate interrupts only that
// line), then stepping outward past neighbouring parallels.
const gridCandidates = (label) => {
const fractions = [0.5, 0.25, 0.75, 0.375, 0.625, 0.125, 0.875];
const ranked = [];
// A close parallel of another relationship on one side (a reciprocal
// pair) makes a label on that side read as the neighbour's: prefer the
// far side.
const parallelOnLowSide = (a, b, axis) => {
const across = 1 - axis;
const [low, high] = [Math.min(a[axis], b[axis]), Math.max(a[axis], b[axis])];
let nearest = null;
for (const other of segments) {
if (other.relationIndex === label.relationIndex) continue;
if (Math.abs(other.start[across] - other.end[across]) > 0.0001) continue;
const distance = other.start[across] - a[across];
if (Math.abs(distance) < 0.0001 || Math.abs(distance) > 36) continue;
const overlap = Math.min(high, Math.max(other.start[axis], other.end[axis]))
- Math.max(low, Math.min(other.start[axis], other.end[axis]));
if (overlap <= 0) continue;
if (nearest === null || Math.abs(distance) < Math.abs(nearest)) nearest = distance;
}
return nearest !== null && nearest < 0;
};
for (const { start: a, end: b } of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
const [above, below] = parallelOnLowSide(a, b, 0) ? [1, 0] : [0, 1];
for (const fraction of fractions) {
const x = a[0] + (b[0] - a[0]) * fraction;
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < label.width / 2 + 6) continue;
ranked.push([above, x, a[1] - 10], [below, x, a[1] + 20], [2, x, a[1] + 3], [3, x, a[1] - 18], [3, x, a[1] + 28]);
}
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
const leftFirst = !parallelOnLowSide(a, b, 1);
for (const fraction of fractions) {
const y = a[1] + (b[1] - a[1]) * fraction;
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < label.height / 2 + 6) continue;
ranked.push([2, a[0], y + 3]);
[6, 14, 22, 30, 38, 46, 54].forEach((offset, step) => {
const tier = step === 0 ? 0 : step === 1 ? 1 : 2 + step;
const left = [tier + (leftFirst ? 0 : 0.5), a[0] - label.width / 2 - offset, y + 3];
const right = [tier + (leftFirst ? 0.5 : 0), a[0] + label.width / 2 + offset, y + 3];
ranked.push(left, right);
});
}
}
}
return ranked.map((entry, order) => [...entry, order])
.sort((left, right) => left[0] - right[0] || left[3] - right[3])
.map(([, lx, ly]) => [lx, ly]);
};
for (const [index, label] of placed.entries()) {
const relation = label.relation;
if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => relation[key] !== undefined)) continue;
// Match actual defect thresholds before searching; a valid placement is
// not a reason to restyle the diagram. New placements leave extra space.
// The grid ranking already starts from the preferred position, so it
// also re-ranks labels whose default spot is merely valid.
if (!gridSweep && inside(label) && !components.some(component => rectsOverlap(label, component, -2))
&& !titles.some(title => rectsOverlap(label, title))
&& !overlapsLabel(label, index) && !masksRoute(label)) continue;
if (gridSweep) {
// Callers measure the plate one pixel higher than rectAt; test with a
// pixel of slack so the chosen position also passes their clearance.
const replacement = gridCandidates(label).map(([lx, ly]) => rectAt(label, lx, ly))
.find(rect => clear({ ...rect, x: rect.x - 1, y: rect.y - 1, width: rect.width + 2, height: rect.height + 2 }, index));
if (replacement) {
placed[index] = replacement;
continue;
}
}
for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
const [a, b] = [segment.start, segment.end];
let candidates = [];
if (Math.abs(a[1] - b[1]) < 0.0001 && Math.abs(a[0] - b[0]) >= label.width + 16) {
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
const x = a[0] + (b[0] - a[0]) * fraction;
if (Math.min(Math.abs(x - a[0]), Math.abs(x - b[0])) < 8) return [];
return [
[x, a[1] - 10],
[x, a[1] + 20],
[x, a[1] - 18],
[x, a[1] + 28],
];
});
} else if (Math.abs(a[0] - b[0]) < 0.0001 && Math.abs(a[1] - b[1]) >= label.height + 16) {
candidates = [0.5, 0.25, 0.75, 0.125, 0.875].flatMap(fraction => {
const y = a[1] + (b[1] - a[1]) * fraction;
if (Math.min(Math.abs(y - a[1]), Math.abs(y - b[1])) < 8) return [];
return [
[a[0] - label.width / 2 - 6, y + 3],
[a[0] + label.width / 2 + 6, y + 3],
[a[0] - label.width / 2 - 14, y + 3],
[a[0] + label.width / 2 + 14, y + 3],
];
});
}
const replacement = candidates.map(([lx, ly]) => rectAt(label, lx, ly))
.find(rect => clear(rect, index));
if (replacement) {
placed[index] = replacement;
break;
}
}
if (placed[index] !== label || !fallbackRing) continue;
// Dense but valid topologies can leave every point directly beside the
// relationship occupied by another route. Search a small deterministic
// ring around the current anchor and the relationship's segment centres.
// A collision-free island above a node is not a readable edge label.
// Architecture opts into keeping the mask within two label heights of
// its own route; shared callers retain their existing policy. If no nearby
// slot fits, retain the collision so validation can request more space.
const ownSegments = segments.filter(segment => segment.relationIndex === label.relationIndex);
const baseAnchors = [
[label.lx, label.ly],
...ownSegments.map(segment => [
(segment.start[0] + segment.end[0]) / 2,
(segment.start[1] + segment.end[1]) / 2,
]),
];
const horizontalStep = label.width / 2 + 12;
const ringOffsets = [
[0, -28], [0, 38],
[-horizontalStep, -28], [horizontalStep, -28],
[-horizontalStep, 38], [horizontalStep, 38],
[-(label.width + 20), -52], [label.width + 20, -52],
[-(label.width + 20), 62], [label.width + 20, 62],
[-(label.width + 20), -76], [label.width + 20, -76],
[-(label.width + 20), 86], [label.width + 20, 86],
];
const fallback = baseAnchors.flatMap(([baseX, baseY]) => (
ringOffsets.map(([dx, dy]) => rectAt(label, baseX + dx, baseY + dy))
)).find(rect => clear(rect, index) && (!keepFallbackNearRoute || ownSegments.some(segment => (
segmentRectClearanceWithin(segment, rect, label.height * 2) <= label.height * 2
))));
if (fallback) placed[index] = fallback;
}
return placed;
}
// The rect a single unpinned label would occupy given only its own route and
// the nodes: what the planner reserves before the remaining routes are laid.
// Only a placement beside the route itself is worth reserving; a label that
// would already need the fallback ring is left to the final placement pass.
export function reservedLabelRect({
label, points, routes = [], labels = [], components, viewBox = [Infinity, Infinity], placementBottom = Infinity,
}) {
const [rect] = placeAutomaticLabels({
labels: [{ ...label, relationIndex: -1 }, ...labels.map(other => ({ ...other, relationIndex: -2 }))],
routes: [{ relationIndex: -1, points }, ...routes],
components,
titles: [],
viewBox,
placementBottom,
fallbackRing: false,
});
return components.some(component => rectsOverlap(rect, component, -2)) ? null : rect;
}