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>
This commit is contained in:
+62
@@ -0,0 +1,62 @@
|
||||
/** Grid placement for architecture IR (#8). Not auto-layout — fixed cell math only. */
|
||||
|
||||
export const DEFAULT_GRID = {
|
||||
mode: 'grid',
|
||||
origin: [40, 80],
|
||||
cols: 4,
|
||||
gapX: 30,
|
||||
gapY: 40,
|
||||
cellW: 130,
|
||||
cellH: 64,
|
||||
};
|
||||
|
||||
export function gridLayout(arch) {
|
||||
const raw = arch.layout;
|
||||
if (!raw || raw.mode !== 'grid') return null;
|
||||
return { ...DEFAULT_GRID, ...raw };
|
||||
}
|
||||
|
||||
export function resolveComponentPos(component, grid) {
|
||||
if (Array.isArray(component.pos) && component.pos.length === 2) {
|
||||
return component.pos;
|
||||
}
|
||||
if (!grid) return [NaN, NaN];
|
||||
if (!Number.isInteger(component.row) || !Number.isInteger(component.col)) {
|
||||
return [NaN, NaN];
|
||||
}
|
||||
const [ox, oy] = grid.origin;
|
||||
const stepX = grid.cellW + grid.gapX;
|
||||
const stepY = grid.cellH + grid.gapY;
|
||||
return [ox + component.col * stepX, oy + component.row * stepY];
|
||||
}
|
||||
|
||||
export function validateGridPlacement(arch, grid, problems) {
|
||||
if (!grid) return;
|
||||
if (arch.layout !== undefined && arch.layout.mode !== 'grid') {
|
||||
problems.push('layout.mode must be "grid" when layout is set (free placement omits layout entirely).');
|
||||
return;
|
||||
}
|
||||
const seen = new Map();
|
||||
for (const c of arch.components ?? []) {
|
||||
const hasPos = Array.isArray(c.pos) && c.pos.length === 2;
|
||||
const hasCell = Number.isInteger(c.row) && Number.isInteger(c.col);
|
||||
if (hasPos) continue; // pos wins; row/col are optional hints only
|
||||
if (!hasPos && !hasCell) {
|
||||
problems.push(`Component "${c.id}" needs pos [x,y] or grid row/col when layout.mode is "grid".`);
|
||||
continue;
|
||||
}
|
||||
if (c.row < 0 || c.col < 0) {
|
||||
problems.push(`Component "${c.id}" row/col must be non-negative integers.`);
|
||||
continue;
|
||||
}
|
||||
if (c.col >= grid.cols) {
|
||||
problems.push(`Component "${c.id}" col ${c.col} exceeds layout.cols ${grid.cols} (valid: 0..${grid.cols - 1}).`);
|
||||
}
|
||||
const key = `${c.row},${c.col}`;
|
||||
if (seen.has(key)) {
|
||||
problems.push(`Components "${seen.get(key)}" and "${c.id}" share grid cell row ${c.row} col ${c.col}.`);
|
||||
} else {
|
||||
seen.set(key, c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1157
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user