feat: Labels brechen balanciert um, ID-Zeile über dem Titel, Falt-Chip — D64

Lange Knotentitel brechen bei ~40 Zeichen um, gleichmäßig auf die Zeilen
verteilt — vom Renderer (wrapLabel, headless getestet) statt text-wrap:balance,
damit der Kasten auf die längste balancierte Zeile schrumpft. Der
23-px-Abzweig ankert an der ersten Zeile; der Optional-Kreis sitzt fest auf
der Abzweighöhe. Der Grafikexport misst die gerenderten Zeilen (labelLines)
und gibt je Zeile ein <text> aus. Dazu: die eingeblendete ID (D56) steht in
einer eigenen Zeile über dem Titel, und das Falt-Zeichen ist ein gerahmter
19×16-px-Chip mit gedeckelter Höhe. Plan: #ed.render.wrap, #ed.render M→L
(D62-Prüfung schlug korrekt an).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 17:45:11 +02:00
co-authored by Claude Fable 5
parent 1debb7d9da
commit 52232f2c20
10 changed files with 318 additions and 32 deletions
+42 -4
View File
@@ -470,6 +470,42 @@ out.addEventListener('focusout', () => setTimeout(drawDepLinks, 0));
der Geister-Knoten werden übernommen; Verbindungslinien werden je Gate
(und = durchgezogen Tinte, oder = gestrichelt Grau) neu gezogen und treffen
so garantiert die Knoten unabhängig vom Darstellungsmodus. */
/* Die gerenderten Textzeilen eines Knotens, gemessen am LIVE-Element: je
Zeile der (whitespace-normalisierte) Text und die Box in Viewport-Pixeln.
Zeichenweise per Range eine neue Zeilen-Oberkante beginnt eine neue Zeile;
das deckt auch den Umbruch INNERHALB eines langen Worts (overflow-wrap) ab,
den eine Nachbildung der Wort-Umbruchlogik verfehlte. Kollabierter Leerraum
(etwa das Leerzeichen, an dem umbrochen wurde) hat eine Null-Box und fällt
heraus. `excludeSel` sind die Elemente, die der Export nicht ins Label
nimmt (Badges, , -Marke, je nach Faltzustand das Falt-Zeichen). */
function labelLines(node, excludeSel){
const range = document.createRange();
const lines = [];
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
acceptNode: tn => tn.parentElement.closest(excludeSel)
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
});
for(let tn = walker.nextNode(); tn; tn = walker.nextNode()){
const s = tn.nodeValue;
for(let i = 0; i < s.length; i++){
range.setStart(tn, i); range.setEnd(tn, i + 1);
const r = range.getBoundingClientRect();
if(!r.width && !r.height) continue;
const last = lines[lines.length - 1];
if(last && Math.abs(r.top - last.top) < 2){
last.text += s[i];
last.left = Math.min(last.left, r.left);
last.right = Math.max(last.right, r.right);
last.bottom = Math.max(last.bottom, r.bottom);
} else {
lines.push({text: s[i], top: r.top, bottom: r.bottom, left: r.left, right: r.right});
}
}
}
for(const l of lines) l.text = l.text.replace(/\s+/g, ' ').trim();
return lines.filter(l => l.text);
}
function diagramToSvg(){
/* Der Export misst die **Live-Geometrie**. Den Ring der Cursor-Zeile liest er
nie aus (`box-shadow` steht nicht in der Liste), ihre Erhebung
@@ -608,15 +644,17 @@ function diagramToSvg(){
const b = R(node), cs = getComputedStyle(node);
const dashed = cs.borderTopStyle === 'dashed';
parts.push(`<rect x="${b.x.toFixed(1)}" y="${b.y.toFixed(1)}" width="${b.w.toFixed(1)}" height="${b.h.toFixed(1)}" rx="8" fill="${cs.backgroundColor}" stroke="${cs.borderTopColor}" stroke-width="${parseFloat(cs.borderTopWidth)||1.5}"${dashed?' stroke-dasharray="4 3"':''}/>`);
const clone = node.cloneNode(true);
/* Das n"-Kennzeichen eingeklappter Knoten gehört in den Export (SPEC
§9/D38 das Bild darf keine Vollständigkeit behaupten); das offener
Knoten ist Bedienelement und fällt weg. */
const stripFold = node.classList.contains('folded') ? '' : ',.fold';
clone.querySelectorAll('.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold).forEach(e => e.remove());
const label = clone.textContent.replace(/\s+/g,' ').trim();
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
parts.push(`<text x="${b.cx.toFixed(1)}" y="${(b.cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(label)}</text>`);
/* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen ein
SVG-<text> bricht nicht von selbst; je gemessene Zeile ein Element. */
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){
const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy;
parts.push(`<text x="${cx.toFixed(1)}" y="${(cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(ln.text)}</text>`);
}
const riskEl = node.querySelector('.risk');
if(riskEl){
const rb = R(riskEl);