slidemind 0.3.20 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +591 -15
- package/README.md +202 -7
- package/bin/slidemind.mjs +50 -2
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// Fit a transcribed text inside the card the reader put it in. The card's
|
|
2
|
+
// height is the image's, the type is the design's ramp (transcribe.mjs), so a
|
|
3
|
+
// text often wraps into more lines than the image drew and runs out of its
|
|
4
|
+
// card (09-27 agentic loop: four ring cards, 10–26px each). The trigger is
|
|
5
|
+
// measured containment the reader declared (`parent`), never an image line
|
|
6
|
+
// count. One lever per card per pass — the settle driver re-renders between
|
|
7
|
+
// (after a pass that only moved things it applies the moves to the last
|
|
8
|
+
// render instead), and cards apart from each other advance side by side (the
|
|
9
|
+
// driver has four passes; one slide-wide lever per pass starved the last cards
|
|
10
|
+
// of theirs):
|
|
11
|
+
// 1. widen — the text into its card's free inner width;
|
|
12
|
+
// 1b. rows — a divider a wrapped text runs through moves down with what is
|
|
13
|
+
// under it, only where the card then still holds its content
|
|
14
|
+
// (with its new height, placed in the same pass: both are rigid);
|
|
15
|
+
// 2. disc — the disc beside it one step smaller, the text takes the width;
|
|
16
|
+
// 3. height — one new height, the content re-anchored in it (a centred text
|
|
17
|
+
// re-centres, a top-set one lifts to the inset; card-height.mjs).
|
|
18
|
+
// Then (S) a text past the slot's bottom, or past a card none of those could
|
|
19
|
+
// fit, lifts into the free room above it with its stack and its row; a text
|
|
20
|
+
// on no card first widens into the room beside it.
|
|
21
|
+
// Symmetry first: a card the reader declared part of a repeated set
|
|
22
|
+
// (repeat-groups.mjs) never changes alone — every lever acts on every member
|
|
23
|
+
// alike, and a new height is placed by the set's arrangement (a row keeps its
|
|
24
|
+
// line, a column its gaps, a ring grows about its centres and moves or closes
|
|
25
|
+
// in as one). A lone card with undeclared lookalikes keeps its size and disc:
|
|
26
|
+
// resizing it alone would break a symmetry nobody declared, so that fit is
|
|
27
|
+
// the author's. The type size never changes; a circle keeps its shape.
|
|
28
|
+
import { translateElement } from "./concept-geometry.mjs";
|
|
29
|
+
import { fixedAspect } from "./concept-layout.mjs";
|
|
30
|
+
import { linePoints, lineSpans, linesWithin, moveLine } from "./repeat-groups.mjs";
|
|
31
|
+
import { hardLineCount } from "./settle.mjs";
|
|
32
|
+
import { cardHeight } from "./card-height.mjs";
|
|
33
|
+
import { columnOf, declaredSets, inkOf, isCard, isMarker, isRound, looksLike as alike, markerRoom, stackUnder } from "./text-stack.mjs";
|
|
34
|
+
import { seatFrame, seatOf, seatRect } from "./seat.mjs";
|
|
35
|
+
import { applyStacks } from "./stack.mjs";
|
|
36
|
+
import { placeBlockDividers } from "./block-dividers.mjs";
|
|
37
|
+
|
|
38
|
+
const INSET = 8; // px kept between a text and its card's edge (settle.mjs freeSpan)
|
|
39
|
+
const GAP = 4; // px kept from a neighbour
|
|
40
|
+
const MIN_GAIN = 2; // px of width worth a render pass
|
|
41
|
+
const ESCAPE_PX = 3; // a descender past the edge is no escape (overlap-check.ts)
|
|
42
|
+
const DISC_SCALE = 0.8; // one step, once: a disc is the card's emblem, not padding
|
|
43
|
+
const SLOT_PX = 1; // past the slot's bottom (slot-fit.ts EPSILON_PX)
|
|
44
|
+
const STACK_GAP = 12; // px between a text and the one stacked on it (a title over its body)
|
|
45
|
+
|
|
46
|
+
const filled = (e) => Boolean(e?.fill && e.fill !== "none");
|
|
47
|
+
// A text box; words ON a painted shape (a number badge) belong to that shape.
|
|
48
|
+
const isText = (e) => (e.kind === "text" || e.kind === "number") && e.text != null && !e.textRotation && !filled(e);
|
|
49
|
+
const span = (a0, a1, b0, b1) => a0 < b1 && b0 < a1;
|
|
50
|
+
const hit = (a, b, gap = 0) => span(a.x, a.x + a.w, b.x, b.x + b.w) && span(a.y - gap, a.y + a.h + gap, b.y, b.y + b.h);
|
|
51
|
+
|
|
52
|
+
export function fitTextToCard(spec, measured, { slot = spec.slot, stale = new Set() } = {}) {
|
|
53
|
+
const s = JSON.parse(JSON.stringify(spec));
|
|
54
|
+
const byId = new Map(s.elements.map((e) => [e.id, e]));
|
|
55
|
+
const changes = [];
|
|
56
|
+
const kids = (el) => s.elements.filter((o) => o.parent === el.id);
|
|
57
|
+
const subtree = (el) => [el, ...kids(el).flatMap(subtree)];
|
|
58
|
+
const inTree = (el, root) => {
|
|
59
|
+
for (let p = el, guard = 0; p && guard < 32; p = p.parent ? byId.get(p.parent) : null, guard++) if (p === root) return true;
|
|
60
|
+
return false;
|
|
61
|
+
};
|
|
62
|
+
// Where a text really is: its measured rect (the image's height is a plan),
|
|
63
|
+
// plus what this call moved it by — a rigid move needs no new render.
|
|
64
|
+
const shift = new Map();
|
|
65
|
+
const at = (t) => {
|
|
66
|
+
const m = measured.get(t.id);
|
|
67
|
+
return m?.h > 0 ? { y: m.y != null ? m.y + (shift.get(t.id) ?? 0) : t.y, h: m.h } : { y: t.y, h: t.h };
|
|
68
|
+
};
|
|
69
|
+
const move = (o, dy) => {
|
|
70
|
+
translateElement(o, 0, dy);
|
|
71
|
+
shift.set(o.id, (shift.get(o.id) ?? 0) + dy);
|
|
72
|
+
};
|
|
73
|
+
const heightOf = (o) => (isText(o) ? at(o).h : o.h);
|
|
74
|
+
// What the ink fit re-wrapped in this pass (`stale`) is measured wrong until
|
|
75
|
+
// the next render: nothing here acts on it or on the card it sits on.
|
|
76
|
+
const isStale = (el) => subtree(el).some((o) => stale.has(o.id));
|
|
77
|
+
const img0 = new Map(s.elements.map((e) => [e.id, { x: e.x, y: e.y, w: e.w, h: e.h }]));
|
|
78
|
+
const env = { s, byId, measured, slot, at, img: (o) => img0.get(o.id) ?? o, isText, filled, subtree, inTree };
|
|
79
|
+
const textsOf = (card) => kids(card).filter(isText).sort((a, b) => a.y - b.y || a.x - b.x);
|
|
80
|
+
const block = (card) => {
|
|
81
|
+
const ts = textsOf(card);
|
|
82
|
+
if (!ts.length) return null;
|
|
83
|
+
return { texts: ts, top: Math.min(...ts.map((t) => at(t).y)), bottom: Math.max(...ts.map((t) => at(t).y + at(t).h)) };
|
|
84
|
+
};
|
|
85
|
+
const pastOf = (card) => (block(card)?.bottom ?? -Infinity) - (card.y + card.h);
|
|
86
|
+
|
|
87
|
+
const declared = declaredSets(s);
|
|
88
|
+
const unitOf = (card) => declared.find((g) => g.members.includes(card)) ?? { id: null, members: [card], arrangement: null, hub: null };
|
|
89
|
+
// Symmetry nobody declared is still symmetry: a lone card that looks like
|
|
90
|
+
// its neighbours is never resized on its own (text-stack.mjs looksLike).
|
|
91
|
+
const looksLike = (o, card) => alike(s, o, card);
|
|
92
|
+
const lookalike = (card) => s.elements.some((o) => looksLike(o, card));
|
|
93
|
+
// A divider on the card that a text above it runs through (a title that
|
|
94
|
+
// wrapped into a second line at the design's type size): px to push it down.
|
|
95
|
+
// A divider between two paragraphs of one block is placed by its block (0c),
|
|
96
|
+
// never pushed under the whole block as "a line the text runs through".
|
|
97
|
+
const blockDividers = new Set(s.elements.flatMap((e) => (e.dividers ?? []).map((d) => d.id)));
|
|
98
|
+
const crossingOf = (card) => {
|
|
99
|
+
let best = null;
|
|
100
|
+
for (const c of linesWithin(s, card)) {
|
|
101
|
+
if (c.id && blockDividers.has(c.id)) continue;
|
|
102
|
+
const ys = linePoints(c).map((p) => p.y);
|
|
103
|
+
if (Math.max(...ys) - Math.min(...ys) > 2) continue;
|
|
104
|
+
const ly = ys.reduce((a, b) => a + b, 0) / ys.length;
|
|
105
|
+
const xs = linePoints(c).map((p) => p.x);
|
|
106
|
+
for (const t of textsOf(card)) {
|
|
107
|
+
const r = at(t);
|
|
108
|
+
const need = Math.ceil(r.y + r.h + GAP - ly);
|
|
109
|
+
if (!span(t.x, t.x + t.w, Math.min(...xs), Math.max(...xs))) continue;
|
|
110
|
+
// Measured lines decide: a rule runs through a text only where it
|
|
111
|
+
// crosses one of its lines' ink. A rule on a line's row but past its
|
|
112
|
+
// words is beside them — "Early Signal Review ———" (x113 PM 3): the
|
|
113
|
+
// block's frame is as wide as its body, and the average line height
|
|
114
|
+
// of a tall title over small body lines put the rule "in" the text,
|
|
115
|
+
// so it was pushed under the whole block.
|
|
116
|
+
const m = measured.get(t.id);
|
|
117
|
+
if (m?.lineRects?.length) {
|
|
118
|
+
const dy = r.y - (m.y ?? r.y);
|
|
119
|
+
const dx = t.x - (m.x ?? t.x);
|
|
120
|
+
const hit = m.lineRects.some((l) => ly > l.y + dy + 1 && ly < l.y + dy + l.h - 1 && span(l.x + dx, l.x + dx + l.w, Math.min(...xs), Math.max(...xs)));
|
|
121
|
+
if (hit && (!best || need > best.need)) best = { need, y: ly };
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// A rule through the middle of the first line is beside the words
|
|
125
|
+
// ("4. Handoff rule ——"), not under them: pushing the card's content
|
|
126
|
+
// down for it shoved four bodies past the slot (gtm-03). Lower in the
|
|
127
|
+
// line, or under it, the text has run onto its divider.
|
|
128
|
+
const n = measured.get(t.id)?.lines;
|
|
129
|
+
if (n >= 1 && ly > r.y + r.h / n / 4 && ly < r.y + (r.h / n) * 0.75) continue;
|
|
130
|
+
if (r.y < ly - 1 && r.y + r.h > ly + 1 && (!best || need > best.need)) best = { need, y: ly };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return best;
|
|
134
|
+
};
|
|
135
|
+
// 0a. seat — a text the reader seated in the middle or at the bottom of its
|
|
136
|
+
// carrier sits there by its MEASURED height (seat.mjs): the painting's
|
|
137
|
+
// height was a plan, the ramp's type is taller or shorter. A text its
|
|
138
|
+
// carrier cannot hold within the insets is the levers' below (they grow the
|
|
139
|
+
// card and re-seat its content), not a seat's.
|
|
140
|
+
// Every member of a block follows: an icon's centre stays on its text's
|
|
141
|
+
// line, the lines under a grown text keep their gaps.
|
|
142
|
+
const atRect = (o) => (isText(o) ? { x: o.x, w: o.w, ...at(o) } : o);
|
|
143
|
+
// Only a child whose structure the normaliser recorded (seatBand, seatInset)
|
|
144
|
+
// is re-seated: an inset read off the current position would drift by the
|
|
145
|
+
// measured height on every pass.
|
|
146
|
+
for (const t of s.elements.filter((t) => t.parent && ["middle", "bottom", "span"].includes(seatOf(t)?.y) && !isStale(t) && t.seatBand && t.seatInset && (isText(t) || t.seatBand.y?.block?.length))) {
|
|
147
|
+
const p = byId.get(t.parent);
|
|
148
|
+
if (!p || !filled(p) || !(p.h > 0) || t.w <= 0 || t.h <= 0) continue;
|
|
149
|
+
const r = atRect(t);
|
|
150
|
+
// Its frame: the row between its dividers or neighbours, measured now.
|
|
151
|
+
const f = seatFrame(s, t, p, atRect);
|
|
152
|
+
if (r.h > f.h - 1 || (f.h === p.h && isText(t) && r.h + 2 * INSET > p.h + 0.5)) continue;
|
|
153
|
+
const top = seatRect(t, f, { w: t.w, h: r.h }, { spec: s, at: atRect })?.y;
|
|
154
|
+
const dy = top == null ? 0 : Math.round((top - r.y) * 2) / 2;
|
|
155
|
+
if (!dy) continue;
|
|
156
|
+
for (const o of subtree(t)) move(o, dy);
|
|
157
|
+
changes.push(`${t.id}: ${dy > 0 ? "+" : ""}${dy}px to sit ${seatOf(t).y === "bottom" ? "at the bottom of" : "in the middle of"} ${f.h === p.h ? p.id : `its row in ${p.id}`}`);
|
|
158
|
+
}
|
|
159
|
+
// 0b. stack — a declared stack is laid out again on its rows' MEASURED
|
|
160
|
+
// heights (stack.mjs): even gaps stay even, a divider rides with its row.
|
|
161
|
+
// A stack holding a text the ink fit just re-wrapped waits for the render.
|
|
162
|
+
if (s.stacks?.length) {
|
|
163
|
+
const live = { ...s, stacks: s.stacks.filter((g) => !g.rows.flat().some((id) => stale.has(id))) };
|
|
164
|
+
changes.push(...applyStacks(live, { at: atRect, move: (o, dy) => move(o, dy) }));
|
|
165
|
+
}
|
|
166
|
+
// 0c. block dividers — a rule between two paragraphs of one block sits at
|
|
167
|
+
// that boundary on the measured paragraphs (block-dividers.mjs).
|
|
168
|
+
changes.push(...placeBlockDividers(s, measured, { moveLine }));
|
|
169
|
+
// 0. round — a disc keeps its shape, so a text past its edge fits by the
|
|
170
|
+
// disc's own room: the content (icon and words together) centred in it,
|
|
171
|
+
// else the text as wide as the disc's chord at the rows it would take there.
|
|
172
|
+
// A disc of a declared set, or one with undeclared lookalikes, never changes
|
|
173
|
+
// alone: every member takes the same lift (the one its fullest member needs,
|
|
174
|
+
// their tops kept alike) and its corresponding texts the same chord width,
|
|
175
|
+
// or none does (x47-03: three lookalike step discs, each left spilling).
|
|
176
|
+
const round = new Set();
|
|
177
|
+
for (const d of s.elements.filter((d) => isRound(d) && pastOf(d) > ESCAPE_PX)) {
|
|
178
|
+
if (round.has(d)) continue;
|
|
179
|
+
const u = unitOf(d);
|
|
180
|
+
const group = u.members.length > 1 ? u.members : [d, ...s.elements.filter((o) => looksLike(o, d))];
|
|
181
|
+
for (const g of group) round.add(g);
|
|
182
|
+
if (group.some((g) => !isRound(g) || isStale(g))) continue;
|
|
183
|
+
const content = group.map((g) => {
|
|
184
|
+
const items = kids(g).map((k) => ({ k, top: isText(k) ? at(k).y : k.y, bottom: (isText(k) ? at(k).y : k.y) + heightOf(k) }));
|
|
185
|
+
const top = Math.min(...items.map((it) => it.top));
|
|
186
|
+
const bottom = Math.max(...items.map((it) => it.bottom));
|
|
187
|
+
return { g, items, top, bottom, dy: g.y + g.h / 2 - (top + bottom) / 2 };
|
|
188
|
+
});
|
|
189
|
+
const dy = Math.round(Math.min(...content.map((c) => c.dy)) * 10) / 10;
|
|
190
|
+
if (dy < 0 && content.every((c) => c.top + dy >= c.g.y + INSET - 0.5 && c.bottom + dy <= c.g.y + c.g.h - INSET + 0.5)) {
|
|
191
|
+
for (const c of content) {
|
|
192
|
+
for (const it of c.items) for (const o of subtree(it.k)) move(o, dy);
|
|
193
|
+
changes.push(`${c.g.id}: content ${dy}px to sit inside it${group.length > 1 ? " (its set alike)" : ""}`);
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const lists = group.map(textsOf);
|
|
198
|
+
if (lists.some((l) => l.length !== lists[0].length)) continue;
|
|
199
|
+
for (const k of lists[0].keys()) {
|
|
200
|
+
const col = lists.map((l) => l[k]);
|
|
201
|
+
if (col.every((t) => measured.get(t.id)?.lines <= 1)) continue;
|
|
202
|
+
const w = Math.min(...col.map((t, i) => Math.floor(group[i].w * Math.sqrt(Math.max(0, 1 - (at(t).h / group[i].h) ** 2)) - 2 * INSET)));
|
|
203
|
+
// Never narrower than a member already is, and only for a real gain.
|
|
204
|
+
if (col.some((t) => t.w > w) || col.every((t) => w - t.w < MIN_GAIN)) continue;
|
|
205
|
+
col.forEach((t, i) => {
|
|
206
|
+
const g = group[i];
|
|
207
|
+
if (t.w === w) return;
|
|
208
|
+
changes.push(`${t.id}: width ${Math.round(t.w)}→${w}px to fit ${g.id}`);
|
|
209
|
+
t.x = Math.round((g.x + g.w / 2 - w / 2) * 10) / 10;
|
|
210
|
+
t.w = w;
|
|
211
|
+
round.add(t);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const units = [];
|
|
217
|
+
const spills = new Set();
|
|
218
|
+
for (const c of s.elements.filter((c) => isCard(c) && !isStale(c))) {
|
|
219
|
+
const spill = pastOf(c) > ESCAPE_PX || crossingOf(c);
|
|
220
|
+
if (!spill && !textsOf(c).some((t) => stackUnder(env, t))) continue;
|
|
221
|
+
const u = unitOf(c);
|
|
222
|
+
if (spill) spills.add(u);
|
|
223
|
+
if (!units.includes(u)) units.push(u);
|
|
224
|
+
}
|
|
225
|
+
const alone = (u) => u.members.length === 1 && lookalike(u.members[0]);
|
|
226
|
+
// Free width beside a text at the rows it takes now, within [left, right]:
|
|
227
|
+
// other elements, and the lines crossing those rows (a card's rules either
|
|
228
|
+
// side of an "OR", a loop around the card).
|
|
229
|
+
const roomBeside = (e, left, right) => {
|
|
230
|
+
const r0 = at(e);
|
|
231
|
+
const others = [
|
|
232
|
+
...s.elements.filter((o) => !inTree(o, e) && !inTree(e, o) && o.w > 0 && o.h > 0)
|
|
233
|
+
.map((o) => ({ x: o.x, w: o.w, y: isText(o) ? at(o).y : o.y, h: heightOf(o) })),
|
|
234
|
+
...lineSpans(s, r0.y, r0.y + r0.h, new Set(s.elements.filter((o) => inTree(o, e) || inTree(e, o)).map((o) => o.id)))
|
|
235
|
+
.map((l) => ({ ...l, y: r0.y, h: r0.h })),
|
|
236
|
+
];
|
|
237
|
+
for (const o of others) {
|
|
238
|
+
if (!span(o.y, o.y + o.h, r0.y, r0.y + r0.h)) continue;
|
|
239
|
+
if (o.x >= e.x + e.w - 1) right = Math.min(right, o.x - GAP);
|
|
240
|
+
else if (o.x + o.w <= e.x + 1) left = Math.max(left, o.x + o.w + GAP);
|
|
241
|
+
}
|
|
242
|
+
return e.align === "center"
|
|
243
|
+
? e.w + 2 * Math.max(0, Math.min(e.x - left, right - e.x - e.w))
|
|
244
|
+
: e.align === "right" ? e.x + e.w - left : right - e.x;
|
|
245
|
+
};
|
|
246
|
+
const widenTo = (e, w, why) => {
|
|
247
|
+
if (e.align === "center") e.x = Math.round((e.x - (w - e.w) / 2) * 10) / 10;
|
|
248
|
+
else if (e.align === "right") e.x = Math.round((e.x - (w - e.w)) * 10) / 10;
|
|
249
|
+
changes.push(`${e.id}: width ${Math.round(e.w)}→${w}px ${why}`);
|
|
250
|
+
e.w = w;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// 1. widen — one width per corresponding text across the set.
|
|
254
|
+
const widenUnit = (u) => {
|
|
255
|
+
const mark = changes.length;
|
|
256
|
+
const lists = u.members.map(textsOf);
|
|
257
|
+
// Corresponding texts across the set share one width; unmatched ones fit alone.
|
|
258
|
+
const sets = lists.every((l) => l.length === lists[0].length)
|
|
259
|
+
? lists[0].map((_, k) => lists.map((l, i) => [u.members[i], l[k]]))
|
|
260
|
+
: lists.flatMap((l, i) => l.map((t) => [[u.members[i], t]]));
|
|
261
|
+
for (const set of sets) {
|
|
262
|
+
// Only a text that wraps gets shorter by widening (a one-line label does not).
|
|
263
|
+
if (!set.some(([, t]) => !(measured.get(t.id)?.lines <= 1))) continue;
|
|
264
|
+
const w = Math.floor(Math.min(...set.map(([c, t]) => roomBeside(t, c.x + INSET, c.x + c.w - INSET))));
|
|
265
|
+
for (const [card, e] of set) if (w - e.w >= MIN_GAIN) widenTo(e, w, `to fit ${card.id}`);
|
|
266
|
+
}
|
|
267
|
+
return changes.length > mark;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// 1b. rows — a divider a text runs through moves down, with everything under
|
|
271
|
+
// it, by the same px on every member (the set keeps one inner layout). The
|
|
272
|
+
// move is rigid, so the new height it may need is placed in the same pass;
|
|
273
|
+
// where the card cannot take that height the divider stays (pushing the
|
|
274
|
+
// content out of the card trades one defect for a worse one, pm-03).
|
|
275
|
+
const dividerUnit = (u) => {
|
|
276
|
+
if (alone(u)) return false;
|
|
277
|
+
const hits = u.members.map(crossingOf);
|
|
278
|
+
const lead = hits.findIndex(Boolean);
|
|
279
|
+
if (lead < 0) return false;
|
|
280
|
+
const d = Math.max(...hits.filter(Boolean).map((h) => h.need));
|
|
281
|
+
const rel = hits[lead].y - u.members[lead].y;
|
|
282
|
+
const plans = u.members.map((card) => {
|
|
283
|
+
const line = linesWithin(s, card).find((c) => linePoints(c).every((p) => Math.abs(p.y - card.y - rel) <= 2));
|
|
284
|
+
if (!line) return null;
|
|
285
|
+
const y = card.y + rel;
|
|
286
|
+
return {
|
|
287
|
+
card,
|
|
288
|
+
items: kids(card).filter((k) => (isText(k) ? at(k).y : k.y) >= y - 1).flatMap(subtree),
|
|
289
|
+
lines: linesWithin(s, card).filter((c) => Math.min(...linePoints(c).map((p) => p.y)) >= y - 1),
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
if (plans.some((p) => !p)) return false;
|
|
293
|
+
// Nothing it carries crosses a line or lands on what stays (a full-width
|
|
294
|
+
// line, a text over the card).
|
|
295
|
+
const items = plans.flatMap((p) => p.items);
|
|
296
|
+
if (!clearOf(items, plans.flatMap((p) => p.lines), d) || landsOn(items, d)) return false;
|
|
297
|
+
const push = (dy) => {
|
|
298
|
+
for (const p of plans) {
|
|
299
|
+
for (const o of p.items) move(o, dy);
|
|
300
|
+
for (const c of p.lines) moveLine(c, 0, dy);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
push(d);
|
|
304
|
+
const mark = changes.length;
|
|
305
|
+
if (u.members.some((c) => pastOf(c) > ESCAPE_PX) && !heightUnit(u)) {
|
|
306
|
+
push(-d);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
changes.splice(mark, 0, ...plans.map(({ card }) => `${card.id}: content from its divider down +${d}px`));
|
|
310
|
+
return true;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// 2. the disc beside the text one step smaller on EVERY member; each text
|
|
314
|
+
// takes the width its disc gave up.
|
|
315
|
+
const discBeside = (card) => {
|
|
316
|
+
const b = block(card);
|
|
317
|
+
if (!b) return null;
|
|
318
|
+
const left = Math.min(...b.texts.map((t) => t.x));
|
|
319
|
+
const right = Math.max(...b.texts.map((t) => t.x + t.w));
|
|
320
|
+
// One disc beside the words: a list of rows with a disc each (cloud-08's
|
|
321
|
+
// four decisions) would lose its symmetry to one smaller disc.
|
|
322
|
+
const beside = kids(card).filter((d) => fixedAspect(d) && filled(d)
|
|
323
|
+
&& Math.min(d.y + d.h, b.bottom) - Math.max(d.y, b.top) >= d.h / 2
|
|
324
|
+
&& (d.x + d.w <= left + 1 || d.x >= right - 1));
|
|
325
|
+
return beside.length === 1 && !beside[0].fitScale ? beside[0] : null;
|
|
326
|
+
};
|
|
327
|
+
const discUnit = (u) => {
|
|
328
|
+
if (alone(u)) return false;
|
|
329
|
+
const discs = u.members.map(discBeside);
|
|
330
|
+
if (discs.some((d) => !d)) return false;
|
|
331
|
+
u.members.forEach((card, i) => {
|
|
332
|
+
const d = discs[i];
|
|
333
|
+
const onLeft = d.x + d.w <= Math.min(...textsOf(card).map((t) => t.x)) + 1;
|
|
334
|
+
const cx = d.x + d.w / 2;
|
|
335
|
+
const cy = d.y + d.h / 2;
|
|
336
|
+
const w = Math.round(d.w * DISC_SCALE);
|
|
337
|
+
const h = Math.round(d.h * DISC_SCALE);
|
|
338
|
+
const freed = d.w - w;
|
|
339
|
+
const ncx = onLeft ? d.x + w / 2 : d.x + d.w - w / 2;
|
|
340
|
+
for (const o of subtree(d).slice(1)) {
|
|
341
|
+
const ow = o.w * DISC_SCALE;
|
|
342
|
+
const oh = o.h * DISC_SCALE;
|
|
343
|
+
translateElement(o, ncx + (o.x + o.w / 2 - cx) * DISC_SCALE - ow / 2 - o.x, cy + (o.y + o.h / 2 - cy) * DISC_SCALE - oh / 2 - o.y);
|
|
344
|
+
o.w = Math.round(ow);
|
|
345
|
+
o.h = Math.round(oh);
|
|
346
|
+
}
|
|
347
|
+
const was = d.w;
|
|
348
|
+
d.x = Math.round(ncx - w / 2);
|
|
349
|
+
d.y = Math.round(cy - h / 2);
|
|
350
|
+
d.w = w;
|
|
351
|
+
d.h = h;
|
|
352
|
+
d.fitScale = DISC_SCALE;
|
|
353
|
+
for (const t of textsOf(card)) {
|
|
354
|
+
if (onLeft) t.x -= freed;
|
|
355
|
+
t.w += freed;
|
|
356
|
+
}
|
|
357
|
+
changes.push(`${d.id}: ${was}→${w}px, ${textsOf(card).map((t) => t.id).join(", ")} +${freed}px wide`);
|
|
358
|
+
});
|
|
359
|
+
return true;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// 3. one height, placed by the set's arrangement, all or nothing (card-height.mjs).
|
|
363
|
+
const { heightUnit, crossesLine } = cardHeight({ s, byId, slot, changes, at, ink: (t) => inkOf(env, t), heightOf, isText, filled, kids, subtree, inTree, move, block, pastOf, alone });
|
|
364
|
+
// 1a. stack — a text grown into what is stacked beneath it (text-stack.mjs):
|
|
365
|
+
// a marker lifts clear of its label where it has the room; else the stack
|
|
366
|
+
// moves down, every member of a set by the same px, into its free room, or
|
|
367
|
+
// with the one new height (3) its card can take; else the text — or the
|
|
368
|
+
// first text of its stack — widens where that saves one of its lines.
|
|
369
|
+
const shiftStacks = (hits, d) => {
|
|
370
|
+
for (const h of hits) {
|
|
371
|
+
for (const o of h.members) move(o, d);
|
|
372
|
+
for (const c of h.lines) moveLine(c, 0, d);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
// Where a moving element is drawn: a text's glyphs (a line through its
|
|
376
|
+
// box's empty margin crosses nothing), anything else its rect.
|
|
377
|
+
const drawn = (o, dy = 0) => {
|
|
378
|
+
if (!isText(o)) return { x: o.x, y: o.y + dy, w: o.w, h: o.h };
|
|
379
|
+
const [x0, x1] = columnOf(env, o);
|
|
380
|
+
const g = inkOf(env, o);
|
|
381
|
+
return { x: x0, y: g.top + dy, w: x1 - x0, h: g.bottom - g.top };
|
|
382
|
+
};
|
|
383
|
+
// Moved by dy, no element crosses a line it did not cross before — member
|
|
384
|
+
// by member: a line between two rows of a stack lies inside its outline.
|
|
385
|
+
const clearOf = (members, lines, dy) => members.every((o) => !(o.w > 0 && o.h > 0) || !crossesLine(drawn(o, dy), [drawn(o)], members, lines));
|
|
386
|
+
// …nor lands on an element that stays and that it did not touch (its own card never counts).
|
|
387
|
+
const landsOn = (members, dy) => members.some((o) => o.w > 0 && o.h > 0 && s.elements.some((f) => !members.includes(f) && !inTree(o, f)
|
|
388
|
+
&& f.w > 0 && f.h > 0 && !hit(drawn(f), drawn(o)) && hit(drawn(f), drawn(o, dy))));
|
|
389
|
+
const pushLever = (col, hits, grow) => {
|
|
390
|
+
// The image's gap where the room holds it, else just clear of each other.
|
|
391
|
+
const need = Math.max(...hits.map((h) => h.need));
|
|
392
|
+
const least = Math.max(...hits.map((h) => h.least));
|
|
393
|
+
const up = col.every(isMarker) ? Math.min(...col.map((t) => markerRoom(env, t).up)) : -1;
|
|
394
|
+
const lift = up >= need ? need : least;
|
|
395
|
+
if (up >= least && col.every((t) => clearOf(subtree(t), [], -lift))) {
|
|
396
|
+
for (const [i, t] of col.entries()) {
|
|
397
|
+
for (const o of subtree(t)) move(o, -lift);
|
|
398
|
+
changes.push(`${t.id}: lifted ${lift}px clear of ${hits[i].first.id}`);
|
|
399
|
+
}
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
const d = hits.every((h) => h.room >= need) || least > Math.min(...hits.map((h) => h.room)) ? need : least;
|
|
403
|
+
const clear = (h) => clearOf(h.members, h.lines, d);
|
|
404
|
+
const notes = hits.map((h) => `${h.first.id}${h.members.length > 1 ? ` and ${h.members.length - 1} under it` : ""}: +${d}px down, clear of ${h.t.id}`);
|
|
405
|
+
if (!hits.every(clear)) return false;
|
|
406
|
+
if (hits.every((h) => h.room >= d)) {
|
|
407
|
+
shiftStacks(hits, d);
|
|
408
|
+
changes.push(...notes);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
// Only the card's own bottom is worth a new height: growing it never
|
|
412
|
+
// clears what lies under the stack inside it. The height lever judges
|
|
413
|
+
// what the content lands on from where it was before this push.
|
|
414
|
+
if (!grow || hits.some((h) => h.blocker)) return false;
|
|
415
|
+
shiftStacks(hits, d);
|
|
416
|
+
const mark = changes.length;
|
|
417
|
+
if (grow(new Map(hits.flatMap((h) => h.members.map((o) => [o, d]))))) {
|
|
418
|
+
changes.splice(mark, 0, ...notes);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
shiftStacks(hits, -d);
|
|
422
|
+
return false;
|
|
423
|
+
};
|
|
424
|
+
// Only a width that saves a line is worth it: its lines, run full, one fewer.
|
|
425
|
+
const widenLever = (col, bounds, why) => {
|
|
426
|
+
const lines = col.map((t) => measured.get(t.id)?.lines ?? 0);
|
|
427
|
+
if (col.some((t, i) => !(lines[i] > hardLineCount(t)))) return false;
|
|
428
|
+
const w = Math.floor(Math.min(...col.map((t) => roomBeside(t, ...bounds(t)))));
|
|
429
|
+
if (!col.every((t, i) => w - t.w >= MIN_GAIN && w >= (t.w * lines[i]) / (lines[i] - 1))) return false;
|
|
430
|
+
for (const t of col) widenTo(t, w, why);
|
|
431
|
+
return true;
|
|
432
|
+
};
|
|
433
|
+
const inner = (card) => () => [card.x + INSET, card.x + card.w - INSET];
|
|
434
|
+
// Pushes are rigid, so every stack of a card moves in the same pass, top
|
|
435
|
+
// down; a widen changes a text's lines and waits for the next render.
|
|
436
|
+
const stackUnit = (u) => {
|
|
437
|
+
const lists = u.members.map(textsOf);
|
|
438
|
+
if (lists.some((l) => l.length !== lists[0].length)) return false;
|
|
439
|
+
let acted = false;
|
|
440
|
+
for (const k of lists[0].keys()) {
|
|
441
|
+
const col = lists.map((l) => l[k]);
|
|
442
|
+
const found = col.map((t) => stackUnder(env, t));
|
|
443
|
+
if (!found.some(Boolean)) continue;
|
|
444
|
+
// A set moves alike: the members t does not reach move by the same px.
|
|
445
|
+
const hits = col.map((t, i) => found[i] ?? stackUnder(env, t, { force: true }));
|
|
446
|
+
if (hits.some((h) => !h || h.members.some(isStale))) continue;
|
|
447
|
+
if (pushLever(col, hits, alone(u) ? null : (pushed) => heightUnit(u, pushed))) {
|
|
448
|
+
acted = true;
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const firsts = hits.map((h) => h.first);
|
|
452
|
+
if (widenLever(col, (t) => inner(byId.get(t.parent))(), `to clear ${firsts[0].id}`)) return true;
|
|
453
|
+
if (firsts.every(isText) && widenLever(firsts, (t) => inner(byId.get(t.parent))(), `to make room under ${col[0].id}`)) return true;
|
|
454
|
+
}
|
|
455
|
+
return acted;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const touched = new Set();
|
|
459
|
+
for (const u of units) {
|
|
460
|
+
const levers = spills.has(u) ? [widenUnit, dividerUnit, stackUnit, discUnit, heightUnit] : [stackUnit];
|
|
461
|
+
if (levers.some((lever) => lever(u))) for (const c of u.members) touched.add(c);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// S. slot — a text that ends past the slot's bottom, or past a card no lever
|
|
465
|
+
// above could fit, lifts into the free room above it: with the texts stacked
|
|
466
|
+
// on it (a title over its body) and the texts beside it on the same top (a
|
|
467
|
+
// row of labels stays one row), never onto a neighbour or across a line. A
|
|
468
|
+
// text on no card first widens into the room beside it where that saves a
|
|
469
|
+
// line (the slot's edges, its neighbours, a panel it lies on).
|
|
470
|
+
const cardOf = (t) => {
|
|
471
|
+
for (let p = t.parent ? byId.get(t.parent) : null, g = 0; p && g < 32; p = p.parent ? byId.get(p.parent) : null, g++) if (filled(p)) return p;
|
|
472
|
+
return null;
|
|
473
|
+
};
|
|
474
|
+
const bottomOf = (t) => at(t).y + at(t).h;
|
|
475
|
+
const pastBy = (t) => Math.max(bottomOf(t) - slot.h, cardOf(t) ? bottomOf(t) - cardOf(t).y - cardOf(t).h : -Infinity);
|
|
476
|
+
const flagged = (t) => bottomOf(t) - slot.h > SLOT_PX || (cardOf(t) && bottomOf(t) - cardOf(t).y - cardOf(t).h > ESCAPE_PX);
|
|
477
|
+
const stackOf = (t) => {
|
|
478
|
+
const stack = [t];
|
|
479
|
+
for (let top = t; top;) {
|
|
480
|
+
top = s.elements.filter((o) => isText(o) && !stack.includes(o) && o.parent === t.parent && span(o.x, o.x + o.w, top.x, top.x + top.w)
|
|
481
|
+
&& at(o).y < at(top).y && at(top).y - (at(o).y + at(o).h) <= STACK_GAP).sort((a, b) => at(b).y - at(a).y)[0];
|
|
482
|
+
if (top) stack.push(top);
|
|
483
|
+
}
|
|
484
|
+
const x0 = Math.min(...stack.map((o) => o.x));
|
|
485
|
+
const x1 = Math.max(...stack.map((o) => o.x + o.w));
|
|
486
|
+
const top = Math.min(...stack.map((o) => at(o).y));
|
|
487
|
+
const card = cardOf(t);
|
|
488
|
+
let ceil = card ? card.y + INSET : 0;
|
|
489
|
+
for (const o of s.elements) {
|
|
490
|
+
if (stack.some((k) => inTree(o, k) || inTree(k, o)) || !(o.w > 0 && o.h > 0) || !span(o.x, o.x + o.w, x0, x1)) continue;
|
|
491
|
+
const oy = isText(o) ? at(o).y : o.y;
|
|
492
|
+
if (oy + heightOf(o) <= top + 1) ceil = Math.max(ceil, oy + heightOf(o) + GAP);
|
|
493
|
+
}
|
|
494
|
+
return { stack, room: Math.floor(top - ceil) };
|
|
495
|
+
};
|
|
496
|
+
// The width a text on no card may take: the slot, or the panel it lies on.
|
|
497
|
+
const panelBounds = (t) => {
|
|
498
|
+
const r = at(t);
|
|
499
|
+
let left = 0;
|
|
500
|
+
let right = slot.w;
|
|
501
|
+
for (const p of s.elements) {
|
|
502
|
+
if (!filled(p) || inTree(t, p) || !hit(p, { x: t.x, y: r.y, w: t.w, h: r.h }) || p.x > t.x || p.x + p.w < t.x + t.w) continue;
|
|
503
|
+
left = Math.max(left, p.x + INSET);
|
|
504
|
+
right = Math.min(right, p.x + p.w - INSET);
|
|
505
|
+
}
|
|
506
|
+
return [left, right];
|
|
507
|
+
};
|
|
508
|
+
// 1a on no card: the same for texts on the slide itself (a step's number
|
|
509
|
+
// over its label, a title over its body), top-down; a push is rigid, so a
|
|
510
|
+
// text it moved is measured where it now is, a widened one waits.
|
|
511
|
+
const widened = new Set();
|
|
512
|
+
for (const t of s.elements.filter((t) => isText(t) && !cardOf(t) && !isStale(t)).sort((a, b) => at(a).y - at(b).y)) {
|
|
513
|
+
if (widened.has(t)) continue;
|
|
514
|
+
const h = stackUnder(env, t);
|
|
515
|
+
if (!h || h.members.some((o) => isStale(o) || widened.has(o)) || pushLever([t], [h], null)) continue;
|
|
516
|
+
if (widenLever([t], panelBounds, `to clear ${h.first.id}`)) widened.add(t);
|
|
517
|
+
else if (isText(h.first) && widenLever([h.first], panelBounds, `to make room under ${t.id}`)) widened.add(h.first);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const done = new Set();
|
|
521
|
+
for (const t of s.elements.filter((t) => isText(t) && flagged(t) && !isStale(t) && !widened.has(t))) {
|
|
522
|
+
// A disc is the round lever's (0): lifted in it, a line leaves its outline.
|
|
523
|
+
// A card of a declared set, or one with undeclared lookalikes, changes
|
|
524
|
+
// with the others or not at all: a lift in one member alone breaks its
|
|
525
|
+
// inner layout (alone() guards the card levers the same way).
|
|
526
|
+
const card = cardOf(t);
|
|
527
|
+
if (done.has(t) || touched.has(card) || fixedAspect(card ?? {}) || (card && isStale(card))
|
|
528
|
+
|| (card && (unitOf(card).members.length > 1 || (isCard(card) && lookalike(card))))) continue;
|
|
529
|
+
const lines = measured.get(t.id)?.lines;
|
|
530
|
+
if (!cardOf(t) && lines > hardLineCount(t)) {
|
|
531
|
+
// Only a width that saves a line is worth it: its lines, run full, one fewer.
|
|
532
|
+
const w = Math.floor(roomBeside(t, ...panelBounds(t)));
|
|
533
|
+
if (w - t.w >= MIN_GAIN && w >= (t.w * lines) / (lines - 1)) {
|
|
534
|
+
widenTo(t, w, "to end inside the slot");
|
|
535
|
+
done.add(t);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const row = s.elements.filter((o) => isText(o) && !done.has(o) && o.parent === t.parent && Math.abs(at(o).y - at(t).y) <= 1);
|
|
540
|
+
for (const o of row) done.add(o);
|
|
541
|
+
const stacks = row.map(stackOf);
|
|
542
|
+
// As far as the whole row can go, and only when that brings at least one
|
|
543
|
+
// of its texts inside: a lift that fixes nothing is only a move.
|
|
544
|
+
const d = Math.min(Math.ceil(Math.max(...row.map(pastBy))), ...stacks.map((k) => k.room));
|
|
545
|
+
if (!(d >= 1) || !row.some((o) => flagged(o) && pastBy(o) <= d)) continue;
|
|
546
|
+
const lifted = [...new Set(stacks.flatMap((k) => k.stack))];
|
|
547
|
+
if (lifted.some((o) => crossesLine({ x: o.x, y: at(o).y - d, w: o.w, h: at(o).h }, [{ x: o.x, y: at(o).y, w: o.w, h: at(o).h }], []))) continue;
|
|
548
|
+
for (const o of lifted) for (const k of subtree(o)) move(k, -d);
|
|
549
|
+
changes.push(`${lifted.map((o) => o.id).join(", ")}: lifted ${d}px to end inside ${cardOf(t)?.id ?? "the slot"}`);
|
|
550
|
+
}
|
|
551
|
+
return { spec: s, changes };
|
|
552
|
+
}
|