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:
co-authored by
Claude Fable 5
parent
1debb7d9da
commit
52232f2c20
+42
-4
@@ -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);
|
||||
|
||||
+50
-8
@@ -55,6 +55,40 @@ function attr(s){ return esc(String(s)).replace(/"/g,'"'); }
|
||||
Beschreibung als data-Attribut wäre der einzige andere Weg gewesen. */
|
||||
export const TIP_RULE = '─'.repeat(24);
|
||||
|
||||
/* Lange Labels umbrechen (SPEC §9/D64): höchstens ~40 Zeichen je Zeile, und
|
||||
die Zeichen GLEICHMÄSSIG auf die Zeilen verteilt — der gierige CSS-Umbruch
|
||||
machte aus 44 Zeichen eine volle Zeile plus ein einsames Wort. Bewusst im
|
||||
Renderer statt per `text-wrap:balance`: So schrumpft der Knotenkasten auf
|
||||
die längste BALANCIERTE Zeile (CSS balanciert nur innerhalb der einmal
|
||||
bestimmten Kastenbreite und ließe ihn auf `max-width` stehen), und die
|
||||
Regel ist headless testbar. Gebrochen wird nur an Leerzeichen; ein
|
||||
einzelnes Wort über der Grenze bleibt stehen (das CSS fängt es mit
|
||||
`max-width` + `overflow-wrap` ab). */
|
||||
export function wrapLabel(label, max = 40){
|
||||
const text = String(label);
|
||||
if(text.length <= max) return [text];
|
||||
const words = text.split(' ');
|
||||
const n = Math.ceil(text.length / max); /* so viele Zeilen braucht es */
|
||||
const target = Math.ceil(text.length / n); /* … und so voll wird jede */
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
for(const w of words){
|
||||
if(!cur){ cur = w; continue; }
|
||||
const withW = cur.length + 1 + w.length;
|
||||
/* Gebrochen wird an der Stelle, die dem Ziel am nächsten kommt: wenn das
|
||||
Wort die Zeile weiter über das Ziel höbe, als sie jetzt darunter liegt —
|
||||
oder wenn es die harte Grenze risse. */
|
||||
if(withW > max || (withW > target && withW - target >= target - cur.length)){
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur + ' ' + w;
|
||||
}
|
||||
}
|
||||
lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
/* Barrierefreier Name eines Knotens: Label + Status + Aufwand + Zuständige +
|
||||
Link. Die visuellen Badges (Größe, Tags, ↗) sind aria-hidden — ihre
|
||||
Information steckt hier, sonst würde der Screenreader Kryptisches („M",
|
||||
@@ -184,19 +218,27 @@ function nodeHtml(n, extra, opts, fold){
|
||||
const ownChip = effKey
|
||||
? `<span class="chip ownst st-${n.status.key}" aria-hidden="true">[${n.status.code}]</span>`
|
||||
: '';
|
||||
/* ID vor dem Titel, mit `: ` abgetrennt — dieselbe Schreibweise wie im Text
|
||||
(SPEC §1/D36), damit man beides nebeneinander lesen kann. Umschaltbar im
|
||||
Diagramm-Kopf; als Renderer-Option und nicht per CSS versteckt, damit der
|
||||
Grafikexport (er liest den Knotentext) von selbst folgt. `aria-hidden`:
|
||||
Der Screenreader bekommt die ID schon über `a11yId` (D56). */
|
||||
/* ID in einer eigenen Zeile ÜBER dem Titel (D56, geändert mit D64): Das
|
||||
`\n` trennt sie, `white-space:pre-line` macht es sichtbar. Ohne den
|
||||
Trenn-Doppelpunkt — der trennte ID und Titel in DERSELBEN Zeile, hier
|
||||
trennt der Umbruch; mit Doppelpunkt läse sich die Zeile wie ein
|
||||
Block-Kopf (`#auth:`). Umschaltbar im Diagramm-Kopf; als Renderer-Option
|
||||
und nicht per CSS versteckt, damit der Grafikexport (er misst die
|
||||
gerenderten Zeilen) von selbst folgt. `aria-hidden`: Der Screenreader
|
||||
bekommt die ID schon über `a11yId` (D56). */
|
||||
/* Nicht bei einem Knoten, dessen Label die ID selbst IST (SPEC §1) — sonst
|
||||
stünde dort `#US-123: #US-123`. */
|
||||
stünde dort `#US-123` zweimal untereinander. */
|
||||
const idHtml = showIds && n.id && !n.labelFromId
|
||||
? `<span class="nid" aria-hidden="true">#${esc(n.id)}:</span> `
|
||||
? `<span class="nid" aria-hidden="true">#${esc(n.id)}</span>\n`
|
||||
: '';
|
||||
const inner = foldHtml +
|
||||
idHtml +
|
||||
esc(n.label) +
|
||||
/* Die Umbrüche kommen als echte `\n` ins Markup, das CSS macht
|
||||
sie mit `white-space:pre-line` sichtbar (D64). Kein `<br>`:
|
||||
Der `textContent` behielte sonst keine Wortgrenze, und alles,
|
||||
was den Knotentext liest (Export, Fokusmarken-Schlüssel),
|
||||
bekäme zusammengeklebte Wörter. */
|
||||
esc(wrapLabel(n.label).join('\n')) +
|
||||
/* ”-Marke (D40): macht die sonst unsichtbare Beschreibung
|
||||
auffindbar (Lehre aus D25) — spiegelt das "-Zeichen der
|
||||
Notation. Nicht im Export: Der Text selbst kann dort nicht
|
||||
|
||||
+34
-7
@@ -684,7 +684,18 @@
|
||||
border-radius:var(--r);
|
||||
padding:7px 14px;
|
||||
font-size:.88rem;font-weight:500;line-height:1.3;
|
||||
white-space:nowrap;
|
||||
/* Lange Labels brechen um, statt den Baum in die Breite zu treiben (D64).
|
||||
Die Umbrüche setzt der RENDERER (wrapLabel: ~40 Zeichen je Zeile,
|
||||
gleichmäßig verteilt) als `\n` — pre-line macht sie sichtbar, und der
|
||||
Kasten schrumpft auf die längste balancierte Zeile. `max-width` +
|
||||
`overflow-wrap` sind nur der Rückhalt für ein einzelnes Wort über der
|
||||
Grenze. Zentriert (Organigramm-Konvention). Die feste Zeilenhöhe
|
||||
bleibt — der 23-px-Abzweig (§9) trifft bei mehrzeiligen Knoten
|
||||
deterministisch die Mitte der ERSTEN Zeile. */
|
||||
white-space:pre-line;
|
||||
max-width:40ch;
|
||||
text-align:center;
|
||||
overflow-wrap:break-word;
|
||||
box-shadow:0 1px 2px rgba(36,52,71,.08);
|
||||
}
|
||||
a.node{text-decoration:none;cursor:pointer}
|
||||
@@ -692,15 +703,23 @@
|
||||
/* Falt-Zeichen (SPEC §9, D38): ▾ offen / „▸ n" eingeklappt, Klickziel fürs
|
||||
Umklappen. Gedeckt in Grau — es ist Bedienelement, keine Statusaussage;
|
||||
beim Zeigen färbt es sich zur Tinte. */
|
||||
/* Als gerahmter Chip statt bloßem Glyph (D64): das nackte ▾ war ~10px klein
|
||||
und kaum zu treffen. Die Höhe ist bewusst gedeckelt (feste line-height +
|
||||
kein vertikales Padding): Der Chip darf die Zeilenbox des Knotens NICHT
|
||||
über die festen 18,3px heben, sonst wandert die 23-px-Abzweig-Geometrie
|
||||
(§9) um Halbpixel. */
|
||||
.node .fold{
|
||||
cursor:pointer;color:var(--muted);
|
||||
font-size:.72em;margin-right:6px;
|
||||
font-size:.75em;margin-right:6px;
|
||||
display:inline-block;padding:0 6px;line-height:14px;
|
||||
border:1px solid rgba(36,52,71,.28);border-radius:5px;
|
||||
background:rgba(36,52,71,.04);
|
||||
user-select:none;-webkit-user-select:none;
|
||||
}
|
||||
.node .fold:hover{color:var(--line)}
|
||||
.node .fold:hover{color:var(--line);border-color:var(--line);background:rgba(36,52,71,.09)}
|
||||
/* Der dunkle Wurzelknoten braucht ein helleres Zeichen als das Grau. */
|
||||
.root-node .fold{color:rgba(255,255,255,.75)}
|
||||
.root-node .fold:hover{color:#fff}
|
||||
.root-node .fold{color:rgba(255,255,255,.85);border-color:rgba(255,255,255,.45);background:rgba(255,255,255,.08)}
|
||||
.root-node .fold:hover{color:#fff;border-color:#fff}
|
||||
/* ”-Marke (SPEC §9, D40): der Knoten trägt eine Beschreibung — im Tooltip
|
||||
nachlesbar. Spiegelt das "-Zeichen der Notation; gedeckt, kein Klickziel. */
|
||||
.node .desc-mark{color:var(--muted);margin-left:4px;font-weight:600}
|
||||
@@ -1165,7 +1184,12 @@
|
||||
Abzweig kommt von links auf halber Knotenhöhe. */
|
||||
.node.opt::before{
|
||||
content:'';position:absolute;
|
||||
left:-5px;top:50%;transform:translateY(-50%);
|
||||
/* 18px statt 50%: Der gestapelte Abzweig sitzt fest bei 23px im <li>
|
||||
(= 5px Listenabstand + halbe EINZEILIGE Knotenhöhe). Seit Knoten
|
||||
umbrechen (D64), ist 50% bei mehrzeiligen nicht mehr diese Höhe —
|
||||
der Kreis muss auf dem Abzweig sitzen, nicht auf der Knotenmitte.
|
||||
Für einzeilige Knoten sind 18px und 50% dasselbe. */
|
||||
left:-5px;top:18px;transform:translateY(-50%);
|
||||
width:10px;height:10px;border-radius:50%;
|
||||
background:var(--card);border:2px solid var(--line);
|
||||
box-sizing:border-box;
|
||||
@@ -1177,8 +1201,11 @@
|
||||
/* …ausgenommen der gestapelte all-of-Teilbaum unter einer any-of-Gruppe
|
||||
(siehe oben): dort kommt der Abzweig wieder von links. */
|
||||
.tree:not(.vertical):not(.kompakt) ul.or>li.has-and>ul.and>li>.node.opt::before{
|
||||
left:-5px;top:50%;transform:translateY(-50%);
|
||||
left:-5px;top:18px;transform:translateY(-50%);
|
||||
}
|
||||
/* Vertikal zentrierte all-of-Zwischenknoten (unten): dort trifft der Abzweig
|
||||
wirklich die Knotenmitte (50%) — der Kreis geht mit. */
|
||||
.tree.vertical li.has-and>ul.and>li.has-and>.node.opt::before{top:50%}
|
||||
|
||||
/* --- TREPPE: aufeinanderfolgende optionale Endknoten (D29, Nachtrag 3) ---
|
||||
Nur im horizontalen Fächer, wo jedes Geschwister sonst eine eigene Spalte
|
||||
|
||||
Reference in New Issue
Block a user