Skill Tracer
Skills › Archify › archify › label-capacity.mjs

label-capacity.mjs

81 lines · archify/renderers/dataflow/label-capacity.mjs at a406594

1import { textUnits } from '../shared/utils.mjs';
2import { rectsOverlap, segmentRectClearanceWithin } from '../shared/geometry.mjs';
3
4export const FLOW_LABEL_HEIGHT = 16;
5export function flowLabelSize(flow, lines = [flow.label]) {
6 const longestLine = Math.max(...lines.map(textUnits), textUnits(flow.classification || ''));
7 return {
8 width: Math.round(Math.max(34, longestLine * 4.9 + 12) * 10) / 10,
9 height: FLOW_LABEL_HEIGHT + 11 * (lines.length - 1 + Number(Boolean(flow.classification))),
10 };
13// A last capacity repair after routing: two complete text rows can fit beside
14// a short arrow where one wide row cannot. Never move a route or a valid label.
15export function wrapBlockedFlowLabels({ labels, routes, components, titles, viewBox, placementBottom }) {
16 const placed = [...labels];
17 const segments = routes.flatMap(route => route.points.slice(1).map((end, index) => ({
18 relationIndex: route.relationIndex, start: route.points[index], end,
19 })));
20 const inside = rect => rect.x >= 0 && rect.y >= 0
21 && rect.x + rect.width <= viewBox[0] && rect.y + rect.height <= placementBottom;
22 const masks = (segment, rect) => segmentRectClearanceWithin(segment, rect, 4) + 0.0001 < 4;
23 const obstacles = [...components, ...titles];
24 let wordSegmenter;
25 for (const [index, label] of placed.entries()) {
26 if (['labelAt', 'labelDx', 'labelDy', 'labelSegment'].some(key => label.relation[key] !== undefined)) continue;
27 if (inside(label) && !components.some(rect => rectsOverlap(label, rect, -2))
28 && !titles.some(rect => rectsOverlap(label, rect))
29 && !placed.some((rect, i) => i !== index && rectsOverlap(label, rect))
30 && !segments.some(segment => segment.relationIndex !== label.relationIndex && masks(segment, label))) continue;
31 const text = label.label;
32 wordSegmenter ||= new Intl.Segmenter(undefined, { granularity: 'word' });
33 const shapes = [...wordSegmenter.segment(text)].slice(1).map(({ index: split }) => {
34 const lines = [text.slice(0, split), text.slice(split)];
35 return { lines, ...flowLabelSize(label.relation, lines) };
36 }).filter(shape => shape.lines.every(line => line.trim()) && shape.width < label.width)
37 .sort((a, b) => a.width - b.width).slice(0, 8);
38 const blockers = [
39 ...obstacles.map(rect => ({ rect, gap: 2 })),
40 ...placed.filter((_, i) => i !== index).map(rect => ({ rect, gap: 2 })),
41 ...segments.map(segment => ({ rect: {
42 x: Math.min(segment.start[0], segment.end[0]), y: Math.min(segment.start[1], segment.end[1]),
43 width: Math.abs(segment.end[0] - segment.start[0]), height: Math.abs(segment.end[1] - segment.start[1]),
44 }, gap: 4 })),
45 ];
46 let probes = 0;
47 const seen = new Set();
48 search: for (const shape of shapes) {
49 for (const segment of segments.filter(segment => segment.relationIndex === label.relationIndex)) {
50 const [a, b] = [segment.start, segment.end];
51 if (Math.abs(a[1] - b[1]) > 0.0001) continue;
52 const low = Math.min(a[0], b[0]) + 8, high = Math.max(a[0], b[0]) - shape.width - 8;
53 if (high < low) continue;
54 const ys = [a[1] - shape.height - 4, a[1] + 4];
55 if (label.y > a[1]) ys.reverse();
56 for (const y of ys) {
57 const positions = [Math.max(low, Math.min(high, label.lx - shape.width / 2)), low, high];
58 for (const { rect, gap } of blockers) {
59 if (y + shape.height + gap <= rect.y || rect.y + rect.height + gap <= y) continue;
60 positions.push(rect.x - gap - shape.width, rect.x + rect.width + gap);
61 }
62 const xs = [...new Set(positions)].filter(x => x >= low && x <= high)
63 .sort((a, b) => Math.abs(a + shape.width / 2 - label.lx) - Math.abs(b + shape.width / 2 - label.lx));
64 for (const x of xs) {
65 const key = `${x},${y},${shape.width},${shape.height}`;
66 if (seen.has(key)) continue;
67 seen.add(key);
68 if (probes++ >= 64) break search;
69 const rect = { ...label, ...shape, x, y, lx: x + shape.width / 2, ly: y + 11 };
70 if (!inside(rect) || obstacles.some(other => rectsOverlap(rect, other, 2))
71 || placed.some((other, i) => i !== index && rectsOverlap(rect, other, 2))
72 || segments.some(segment => masks(segment, rect))) continue;
73 placed[index] = rect;
74 break search;
75 }
76 }
77 }
78 }
79 }
80 return placed;