/* ========================================================================== ANALYSE: Auswertungen. ReportView ist der gemeinsame Renderer aller vier Analysen. Split, Stack, Meal und Blutbild liefern serverseitig dasselbe Objekt (docs/ANALYSE.md Abschnitt 2), deshalb gibt es hier genau eine Anzeige und nicht vier. Eine Sache fehlt bewusst: die Findings kommen nur auf Deutsch. Sie stehen nicht in BB.UI_STRINGS, weil sie am Server aus Zahlen entstehen. Sobald sie uebersetzt werden sollen, kommt der Pfad in LOCALISED in core.js. ========================================================================== */ var WDH_VORGABE = 8; var RIR_VORGABE = 0; /* Woran eine gesenkte Konfidenz liegt. Ohne diesen Satz stuende dort eine kleinere Zahl, ohne dass jemand sagen koennte warum, und das waere schlimmer als gar keine. */ function belegText(report) { const beleg = report && report.beleg; if (!beleg || !beleg.je_stufe) return ""; const setzung = beleg.je_stufe.setzung || 0; const gesamt = Object.values(beleg.je_stufe).reduce((a, b) => a + b, 0); if (!gesamt) return ""; if (!setzung) { return BB.t("Alle Befunde stehen auf gemessenen Werten, der Wissensbasis oder der Eingabe selbst."); } return BB.t("{n} von {m} Befunden hängen an einer Schwelle, die gesetzt und nicht gemessen ist.") .replace("{n}", BB.num(setzung)).replace("{m}", BB.num(gesamt)); } function scoreTon(score) { if (score === null || score === undefined) return ""; if (score >= 85) return ""; if (score >= 60) return "warn"; return "hot"; } /* Kleine semantische Zeichen fuer die Scorekarte. Sie sind absichtlich inline und einfarbig: Das haelt die vier Analysen in einem System, bleibt im Dark-Mode scharf und gibt uns spaeter je Dimension eine einzelne Stelle zum Austausch, falls ein Icon nicht passt. */ var DIMENSION_ICONS = { split: { saettigung: "saturation", erholung: "sleep", wochenvolumen: "volume", abdeckung: "target", balance: "balance", sitzung: "dumbbell", }, stack: { komplexitaet: "checklist", schwankung: "wave", dosis: "capsule", interaktion: "cycle-arrows", evidenz: "book", side_control: "shield", risikominderung: "shield", ziel: "crosshair", wirkung: "crosshair", last: "organ-heart", redundanz: "handshake", synergie: "handshake", gegenlaeufigkeit: "bolt", abdeckung: "clock", zeitliche_abdeckung: "clock", }, meal: { anlass: "utensils", naehrstoffe: "leaf", verdauung: "intestine", dichte: "gauge", }, blood: { blutbild: "drop", leber: "liver", muskel: "flex", niere: "kidney", lipide: "lipid", hormone: "molecule", schilddruese: "thyroid", stoffwechsel: "flame", entzuendung: "thermometer", mineralien: "balance", vitamine: "leaf", sonstiges: "flask", }, }; /* Was eine Dimension fachlich liest. Die zweite Haelfte des Tooltips wird aus Rolle und Gewicht des konkreten Reports gebaut; dadurch bleibt auch bei zielabhaengigen Gewichten sichtbar, was wirklich in genau diesen Score eingegangen ist. */ var DIMENSION_INFO = { split: { saettigung: "Bewertet den nutzbaren Trainingsreiz innerhalb einer Einheit. Ein hoher Wert bedeutet, dass nur wenige Sätze jenseits der produktiven Sättigung vor allem zusätzliche Ermüdung erzeugen.", erholung: "Vergleicht den Abstand zwischen zwei Reizen desselben Muskels mit seiner geschätzten Regenerationsdauer. Zu frühes erneutes Training senkt diesen Wert.", wochenvolumen: "Ordnet die effektiven Sätze je Muskel in das hinterlegte Wochenvolumen-Band ein. Zu wenig und deutlich zu viel Volumen senken den Wert.", abdeckung: "Prüft ausschließlich, welche Kernmuskeln die Mindestschwelle erreichen. Wie gleichmäßig diese Muskeln trainiert werden, wird hier bewusst nicht bewertet.", balance: "Vergleicht die bereits abgedeckten Muskeln untereinander und berücksichtigt ihre Reihenfolge sowie Vorermüdung. Fehlende Muskeln werden hier nicht ein zweites Mal bestraft.", sitzung: "Bewertet ausschließlich die gesamte Satzlast je Trainingseinheit. Die Übungsreihenfolge gehört nicht in diese Dimension.", }, stack: { komplexitaet: "Schätzt ein, wie aufwendig der Plan durch Stoffzahl und Anwendungshäufigkeit wird. Ein hoher Wert steht für einen vergleichsweise überschaubaren, leichter einzuhaltenden Ablauf.", schwankung: "Schätzt aus Halbwertszeit und Einnahmeintervall, wie stark Spiegel zwischen Spitze und Tal schwanken. Gleichmäßigere Verläufe ergeben einen höheren Wert.", dosis: "Vergleicht die eingetragenen Dosierungen mit den gepflegten plausiblen Bereichen für Zweck und Phase. Der Wert ist eine Plausibilitätsprüfung und keine Sicherheitsfreigabe.", interaktion: "Bündelt die kuratierten Warnungen zu einzelnen Stoffpaaren — benannte Kombinationen mit hinterlegter Begründung. Die sich addierende Organbelastung zählt hier nicht mit, die wird gerechnet und steht als Organlast daneben.", side_control: "Prüft nur eines: zu welcher erwartbaren Belastung dieses Protokolls steht ein Mittel in der Liste, das daran ansetzt — und wo fehlt eines, obwohl es dafür eines gäbe. Wie hoch die Belastung ist, steht in der Organlast und zählt hier bewusst nicht mit. Eine Gegenmaßnahme macht die Belastung darunter nicht ungeschehen.", ziel: "Prüft, wie vollständig die Wirkwege des Stacks zum gewählten Ziel passen. Ein höherer Wert bedeutet eine breitere Zielabdeckung.", evidenz: "Fasst die Qualität der hinterlegten Belege für die verwendeten Stoffe zusammen. Ein höherer Wert steht für eine belastbarere Datenbasis.", /* Die gerechneten Faktoren der Interaktionsmatrix. Sie entstehen nicht aus gepflegten Regeln, sondern aus gemessenen Kanten zwischen den Stoffen und ihren Endpunkten — jede aus einer anderen Konstellation zweier Stoffe an einem gemeinsamen Punkt. Ohne Eintrag hier bekamen alle sieben denselben generischen Satz, und der sagte nichts darüber, was sie unterscheidet. */ wirkung: "Summiert über gemessene Wege, wie stark die Stoffe die Endpunkte bewegen, die zum gewählten Zweck gehören. Anders als die gepflegte Ziel-Abdeckung fragt sie nicht, ob ein passender Wirkweg vorhanden ist, sondern wie viel an den Zielpunkten tatsächlich ankommt.", last: "Summiert dieselbe Rechnung für die Endpunkte, die vom Ziel wegführen: Leber, Blutbild, Herz-Kreislauf, Hormonachse, Niere. Ein niedriger Wert heißt viel Belastung. Was dagegenhält, steht in der Risikominderungs-Abdeckung.", /* Signierter Faktor: 50 ist die Mitte, nicht die halbe Note. Ohne diesen Satz liest sich eine 39 wie ein schlechter Wert, obwohl sie „etwas mehr Doppelung als Ergänzung" heißt. */ synergie: "Wie die Stoffe an gemeinsamen Zielen zusammenwirken. Über 50 überwiegt die Ergänzung — dasselbe Ziel über verschiedene Treiber. Unter 50 überwiegen Doppelung am selben Treiber, bei der sich die Nebenwirkungen addieren und die Wirkung nicht, und Paare, die zeitgleich gegeneinander ziehen. Genau 50 heißt ausgeglichen; ist gar nichts gefunden, zählt der Faktor nicht mit. Hält bei einem gegenläufigen Paar einer erkennbar dagegen, steht das nicht hier, sondern in der Risikominderungs-Abdeckung.", risikominderung: "Sucht zu jeder Belastung, die der Stack treibt, einen Stoff, der dagegenhält: NAC gegen Entzündung, ein Aromatasehemmer gegen Estradiol, Cardarine gegen sinkende Insulinempfindlichkeit. Gerechnet wird, welcher Anteil der Belastung abgefangen ist; die Dosis des Gegenspielers geht dabei ein, und liegt sie unter der Hälfte der untersuchten, ist sie als knapp markiert.", abdeckung: "Ob die Einnahmezeiten zueinander passen. Zwei Dinge gehen ein: Mineralien, die im Darm um die Aufnahme konkurrieren (Zink und Magnesium, Calcium und Eisen), zählen getrennt eingenommen für den Wert und zeitgleich gegen ihn. Und Stoffe, die gleichläufig wirken, aber in getrennten Zeitfenstern liegen; das setzt eine Halbwertszeit unter einem Tag voraus und schlägt bei Depots kaum an.", }, meal: { anlass: "Vergleicht Energie- und Makronährstoffprofil mit dem gewählten Essensanlass. Ein hoher Wert bedeutet, dass die Mahlzeit für diesen Zeitpunkt gut passt.", naehrstoffe: "Bewertet, wie viele relevante Mikro- und Makronährstoffe die Mahlzeit bezogen auf ihre Energie liefert. Eine höhere Dichte ergibt einen höheren Wert.", verdauung: "Schätzt anhand der Zusammensetzung ein, wie gut die Mahlzeit voraussichtlich verträglich und für den gewählten Zeitpunkt verdaulich ist.", dichte: "Ordnet die Kalorienmenge pro Portion zum gewählten Ziel ein. Ob eine hohe oder niedrige Energiedichte günstig ist, hängt vom Anlass und Ziel ab.", }, blood: { blutbild: "Fasst die eingegebenen Werte des Blutbilds zusammen und zeigt, welcher Anteil im hinterlegten Bewertungsbereich liegt.", leber: "Fasst die eingegebenen Leberwerte zusammen und zeigt, welcher Anteil im hinterlegten Bewertungsbereich liegt.", muskel: "Ordnet die eingegebenen muskelbezogenen Marker in ihre hinterlegten Bewertungsbereiche ein.", niere: "Fasst die eingegebenen Nierenwerte zusammen und zeigt, welcher Anteil im hinterlegten Bewertungsbereich liegt.", lipide: "Fasst die eingegebenen Blutfette zusammen und bewertet ihre Lage in den hinterlegten Bereichen.", hormone: "Fasst die eingegebenen Hormonwerte zusammen und zeigt, welcher Anteil im hinterlegten Bewertungsbereich liegt.", schilddruese: "Ordnet die eingegebenen Schilddrüsenwerte in ihre hinterlegten Bewertungsbereiche ein.", stoffwechsel: "Ordnet die eingegebenen Stoffwechselmarker in ihre hinterlegten Bewertungsbereiche ein.", entzuendung: "Fasst Entzündungs- und Eisenmarker zusammen und bewertet ihre Lage in den hinterlegten Bereichen. Ferritin und Transferrinsättigung sind nur zusammen mit dem CRP lesbar, weil Ferritin bei Entzündung mitsteigt.", mineralien: "Ordnet Elektrolyte und Mineralstoffe in ihre hinterlegten Bereiche ein. Sie bewegen sich häufig gemeinsam, etwa wenn vor der Abnahme wenig getrunken oder entwässert wurde.", vitamine: "Fasst Vitamin D, B12, Folsäure und Homocystein zusammen. Homocystein steht hier, weil es steigt, wenn B12 oder Folsäure knapp werden, und das früher anzeigt als die beiden Werte selbst.", sonstiges: "Bündelt weitere eingegebene Laborwerte, die keiner der größeren Gruppen zugeordnet sind.", }, }; var ZSP_FACTOR_ICONS = { wirkung: "crosshair", last: "organ-heart", redundanz: "nodes", aufhebung: "bolt", gegenlaeufigkeit: "bolt", synergie: "handshake", risikominderung: "shield", abdeckung: "clock", }; function DimensionIconGlyph({ name }) { switch (name) { case "saturation": return <>; case "sleep": return <>; case "volume": return <>; case "target": return <>; case "crosshair": return <>; case "cycle-arrows": return <>; case "organ-heart": return <>; case "balance": return <>; case "dumbbell": return <>; case "checklist": return <>; case "wave": return <>; case "capsule": return <>; case "nodes": return <>; case "book": return <>; case "shield": return <>; case "handshake": return <>; case "bolt": return ; case "clock": return <>; case "plate": return <>; case "utensils": return <>; case "leaf": return <>; case "stomach": return <>; case "intestine": return <>; case "gauge": return <>; case "drop": return <>; case "liver": return <>; case "flex": return <>; case "kidney": return <>; case "lipid": return <>; case "molecule": return <>; case "thyroid": return <>; case "flame": return <>; case "thermometer": return <>; case "flask": return <>; default: return <>; } } function AnalysisIcon({ name, className }) { return ( ); } function DimensionIcon({ kind, dimension }) { const name = (DIMENSION_ICONS[kind] || {})[dimension.key] || "spark"; return ; } function dimensionAussage(kind, dimension) { const gepflegt = DIMENSION_INFO[kind] && DIMENSION_INFO[kind][dimension.key]; if (gepflegt) return BB.t(gepflegt); return BB.t("Ordnet diese Dimension auf einer Skala von 0 bis 100 ein. Ein höherer Wert bedeutet, dass die hinterlegten Kriterien besser erfüllt sind."); } function dimensionScoreRelevanz(dimension) { const gewicht = Number(dimension.weight) || 0; if (dimension.rolle === "deckel") { return BB.t("Sie geht nicht in den Mittelwert ein, kann den Gesamtscore aber nach oben begrenzen."); } if (dimension.rolle === "daneben" || gewicht <= 0) { return BB.t("Sie wird separat gezeigt und verändert den Gesamtscore nicht."); } return BB.t("Sie fließt mit {n} % in den gewichteten Gesamtscore ein.") .replace("{n}", Math.round(gewicht * 100)); } /* Der Tooltip liegt in einem Portal. Die Scorekarte muss eigenständig scrollen; ein absolut darin liegender Hinweis wuerde deshalb gerade bei den unteren Blutwert-Dimensionen abgeschnitten. */ function DimensionTooltip({ id, anchor, aussage, relevanz, footerLabel = "Score" }) { const tooltip = React.useRef(null); const [position, setPosition] = React.useState(null); React.useLayoutEffect(() => { function platzieren() { const ausloeser = anchor.current; const hinweis = tooltip.current; if (!ausloeser || !hinweis) return; const rand = 12; const abstand = 9; const breite = Math.min(320, Math.max(0, window.innerWidth - rand * 2)); hinweis.style.width = `${breite}px`; const rechteck = ausloeser.getBoundingClientRect(); const hoehe = hinweis.getBoundingClientRect().height; const links = Math.min( Math.max(rechteck.left + 42, rand), Math.max(rand, window.innerWidth - breite - rand), ); const unten = rechteck.bottom + abstand; const oben = rechteck.top - hoehe - abstand; const nachOben = unten + hoehe > window.innerHeight - rand && oben >= rand; const top = nachOben ? oben : Math.max(rand, Math.min(unten, window.innerHeight - hoehe - rand)); setPosition({ left: links, top, width: breite, placement: nachOben ? "top" : "bottom" }); } platzieren(); window.addEventListener("resize", platzieren); window.addEventListener("scroll", platzieren, true); return () => { window.removeEventListener("resize", platzieren); window.removeEventListener("scroll", platzieren, true); }; }, [anchor, aussage, relevanz]); return ReactDOM.createPortal( {aussage} {BB.t(footerLabel)}: {relevanz} , document.body, ); } function matrixKonfidenzDetails(matrix) { const faktoren = (matrix && matrix.konfidenzFaktoren) || {}; const teile = []; const anzahl = Number(faktoren.anzahlStoffe); const stoffzahlWert = faktoren.stoffzahl; const stoffzahl = Number(stoffzahlWert); const nichtAbgedeckt = Number(faktoren.nichtAbgedeckt); if (Number.isFinite(anzahl)) { teile.push(`${BB.t("Stoffe in der Matrix")}: ${anzahl}`); } if (stoffzahlWert != null && Number.isFinite(stoffzahl)) { teile.push(`${BB.t("Stoffzahlfaktor")}: ${stoffzahl.toFixed(2)}`); } if (Number.isFinite(nichtAbgedeckt) && nichtAbgedeckt > 0) { teile.push(`${BB.t("nicht aufgelöste Stoffe")}: ${nichtAbgedeckt}`); } if (matrix && matrix.matrixStatus === "leer") { teile.push(BB.t(matrix.matrixHinweis || "Keine bewertbaren Wege in der Interaktionsmatrix.")); } return teile.join(" · ") || BB.t("Keine Detailwerte zur Matrix-Konfidenz vorhanden."); } function ConfidenceInfo({ report, matrix }) { const daten = matrix || (report && report.stack_zusammenspiel); const [infoHover, setInfoHover] = useState(false); const [infoFokus, setInfoFokus] = useState(false); const anchor = useRef(null); const infoId = React.useId(); if (!daten) return null; const infoOffen = infoHover || infoFokus; const aussage = [ BB.t("Die Konfidenz wird bei einem Stack ausschließlich aus der Interaktionsmatrix abgeleitet."), BB.t("Für jeden berechneten Pfad werden Kantenbeleg, Stoffevidenz, Pharmakokinetik, Stoffzahl, Wirkrichtung, Transit und Weglänge berücksichtigt; die Gesamtzahl ist das geometrische Mittel der Pfadkonfidenzen."), BB.t("Sie beschreibt die Belastbarkeit der Herleitung und ist keine medizinische Wahrscheinlichkeit."), ].join(" "); const wert = Number(daten.konfidenz); const relevanz = `${BB.t("Matrix-Konfidenz")}: ${Number.isFinite(wert) ? Math.round(wert * 100) : 0} % · ${matrixKonfidenzDetails(daten)}`; return ( setInfoHover(true)} onMouseLeave={() => setInfoHover(false)} onFocusCapture={() => setInfoFokus(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setInfoFokus(false); }}> {infoOffen && ( )} ); } function levelZeichen(level, guenstig) { if (guenstig) return "✓"; return level === "achtung" ? "▲" : level === "hinweis" ? "●" : "○"; } /* --- Bausteine ----------------------------------------------------------- */ /** Der Score als Ring: die Linie läuft anteilig um den Kreis, 75 sind drei Viertel. Die Farbe folgt derselben Abstufung wie die Balken. Der Umfang wird aus dem Radius gerechnet und die Linie über stroke-dashoffset gesetzt, damit sich derselbe Wert animieren lässt, ohne die Geometrie anzufassen. */ function ScoreRing({ score, animieren }) { const kreis = useRef(null); const zahl = useRef(null); const [gezeigt, setGezeigt] = useState(animieren ? 0 : score); const r = 52; const umfang = 2 * Math.PI * r; const anteil = score === null ? 0 : Math.max(0, Math.min(100, score)) / 100; useEffect(() => { const linie = kreis.current; if (!linie) return; if (!animieren || BB.reducedMotion() || typeof anime === "undefined") { linie.style.strokeDashoffset = String(umfang * (1 - anteil)); setGezeigt(score); return; } // Der Ring läuft auf, die Zahl zählt mit. Beides dieselbe Dauer, sonst // steht die Zahl still, während die Linie noch wandert. linie.style.strokeDashoffset = String(umfang); anime({ targets: linie, strokeDashoffset: umfang * (1 - anteil), duration: 1100, easing: "easeOutCubic", }); const lauf = { v: 0 }; anime({ targets: lauf, v: score === null ? 0 : score, duration: 1100, easing: "easeOutCubic", update: () => setGezeigt(lauf.v), }); }, [score, animieren, anteil, umfang]); return (
{score === null ? "?" : Math.round(gezeigt)} / 100
); } function ScoreHead({ report, animieren }) { return (
{BB.t("Konfidenz")} {report.kind === "stack" && } {Math.round((report.confidence || 0) * 100)} %
{report.engine}
); } /* Die Zahlen hinter einem Satz. Sie stehen im Report, seit es ihn gibt, und waren bisher nur im Blutmodul sichtbar. docs/ANALYSE.md verlangt in Abschnitt 1, dass jede Zahl rueckverfolgbar ist; unsichtbar im JSON ist sie das nicht. Zugeklappt, weil der Satz die Aussage traegt und die Zahlen der Beleg sind. Die Schluessel kommen aus den Scorern und sind schon lesbar, `evidenzLabel` macht aus `obergrenze_je_einheit` nur noch "Obergrenze je Einheit". Was sich nicht in eine Zeile bringen laesst, also Listen und verschachtelte Objekte, bleibt draussen: eine aufgeklappte JSON-Struktur ist kein Beleg, sondern ein Rohdatenblick. */ var EVIDENZ_LABEL = { warn_ab: "Warnschwelle", kritisch_ab: "kritisch ab", abzug_faktor: "Abzugsfaktor", obergrenze_je_einheit: "Obergrenze je Einheit", mps_stunden: "MPS-Fenster (h)", von: "Bereich von", bis: "Bereich bis", anteil: "Anteil am Bereich (%)", }; function evidenzLabel(schluessel) { if (EVIDENZ_LABEL[schluessel]) return BB.t(EVIDENZ_LABEL[schluessel]); const text = schluessel.replace(/_/g, " "); return text.charAt(0).toUpperCase() + text.slice(1); } function evidenzWert(wert) { if (typeof wert === "number") return BB.num(Math.round(wert * 100) / 100); if (typeof wert === "boolean") return wert ? BB.t("ja") : BB.t("nein"); return String(wert); } function FindingEvidenz({ evidence }) { if (!evidence) return null; const zeilen = Object.entries(evidence).filter( ([, wert]) => wert !== null && wert !== undefined && (typeof wert === "number" || typeof wert === "string" || typeof wert === "boolean")); if (!zeilen.length) return null; return (
{BB.t("Zahlen dahinter")}
{zeilen.map(([schluessel, wert]) => (
{evidenzLabel(schluessel)}
{evidenzWert(wert)}
))}
); } /* `zahlen` blendet die Evidenzzeilen aus. In der Stackanalyse sagt der Satz alles, und die Zeilen darunter wiederholten nur Schlüssel wie "Stoffe 13". */ /* Die Artikel stehen am Namen im Satz, nicht noch einmal als Slug darunter. "HGH und BPC-157 brauchen Kühlung", darunter "hgh bpc157": dieselbe Auskunft zweimal, einmal davon in Kleinbuchstaben. Gesucht wird der Titel und jeder Alias des Artikels, der längste zuerst; was im Satz nicht vorkommt (ein Laborartikel hinter "Prolaktinspiegel"), steht als "Mehr dazu" mit Titel. */ function befundTeile(text, refs, index) { const eintraege = (refs || []).map((slug) => { const e = (index || []).find((x) => x.slug === slug); return e ? { slug, titel: e.title, namen: [e.title].concat(e.aliases || []) } : null; }).filter(Boolean); const treffer = []; const frei = (von, bis) => treffer.every((t) => bis <= t.von || von >= t.bis); const klein = text.toLowerCase(); eintraege.forEach((e) => { const namen = e.namen.filter((n) => n && n.length >= 2) .sort((a, b) => b.length - a.length); for (const name of namen) { let ab = 0; while (ab < klein.length) { const i = klein.indexOf(name.toLowerCase(), ab); if (i < 0) break; const davor = i === 0 ? "" : klein[i - 1]; if (!/[a-zäöüß0-9]/.test(davor) && frei(i, i + name.length)) { treffer.push({ von: i, bis: i + name.length, slug: e.slug }); return; } ab = i + 1; } } }); treffer.sort((a, b) => a.von - b.von); const teile = []; let pos = 0; treffer.forEach((t) => { if (t.von > pos) teile.push(text.slice(pos, t.von)); teile.push( {text.slice(t.von, t.bis)}); pos = t.bis; }); if (pos < text.length) teile.push(text.slice(pos)); const genutzt = new Set(treffer.map((t) => t.slug)); return { teile, rest: eintraege.filter((e) => !genutzt.has(e.slug)) }; } function useWissensIndex() { const [index, setIndex] = useState(ZSP_INDEX.wert || []); useEffect(() => { let aktiv = true; BB.loadIndex().then((i) => { if (aktiv && Array.isArray(i)) setIndex(i); }); return () => { aktiv = false; }; }, []); return index; } function FindingRow({ finding, zahlen = true }) { const index = useWissensIndex(); const ev = finding.evidence || {}; const { teile, rest } = befundTeile(finding.text || "", finding.refs, index); return (
  • {teile}

    {(rest.length > 0 || (ev.quelle && ev.pmid)) && (

    {rest.length > 0 && {BB.t("Mehr dazu")}:} {rest.map((e) => ( {e.titel} ))} {ev.quelle && ev.pmid && ( {ev.quelle} )}

    )} {/* Der Ausschnitt behauptet nichts über den Satz hinaus: er zeigt das Netz unter einer Aussage, die schon aus der Wissensbasis kommt. */} {finding.karte && ( )} {zahlen && }
  • ); } function DimensionRow({ dim, animieren, rang, kompakt, kind }) { // Der Balken läuft über den CSS-Übergang auf, den .bar-fill schon mitbringt. // anime.js parallel darauf loszulassen hieße, dieselbe Eigenschaft zweimal // zu schreiben, und der Übergang würde jeden Frame verschleifen. const [gefuellt, setGefuellt] = useState(!animieren); const [infoHover, setInfoHover] = useState(false); const [infoFokus, setInfoFokus] = useState(false); const zeile = useRef(null); const infoId = React.useId(); useEffect(() => { if (gefuellt) return; if (BB.reducedMotion()) { setGefuellt(true); return; } const id = setTimeout(() => setGefuellt(true), 220 + rang * 70); return () => clearTimeout(id); }, [gefuellt, rang]); const s = dim.score; const breite = !gefuellt || s === null ? 0 : s / 100; const rollenHinweis = { deckel: BB.t("begrenzt die Gesamtwertung"), daneben: BB.t("zählt nicht in die Wertung"), }[dim.rolle]; const infoOffen = infoHover || infoFokus; const aussage = dimensionAussage(kind, dim); const relevanz = dimensionScoreRelevanz(dim); return (
    setInfoHover(true)} onMouseLeave={() => setInfoHover(false)} onFocusCapture={() => setInfoFokus(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setInfoFokus(false); }}>
    {dim.label} {rollenHinweis && {rollenHinweis}} {/* Das Gewicht stand bisher nur im JSON. Der Deckel haengt daran: tragend ist eine Dimension erst ab `deckel_ab_gewicht`, und ohne die Zahl ist nicht nachvollziehbar, warum der Gesamtscore unter dem Mittel liegt. Nur bei `mittel`, denn nur dort bedeutet sie etwas; `deckel` und `daneben` tragen Gewicht 0 und sagen das schon im Rollenhinweis daneben. */} {/* Die Gewichtung bleibt sichtbar, nur nicht in der schmalen Karte rechts: dort steht der Überblick, und jede Zusatzzahl drängt ihn weiter nach unten. */} {!kompakt && dim.rolle === "mittel" && dim.weight > 0 && ( {Math.round(dim.weight * 100)} % )} {s === null ? (kompakt ? "-" : BB.t("entfällt")) : Math.round(s)}
    {/* Der gerechnete Gegenwert direkt darunter. Nicht verrechnet: die obere Zahl ist gepflegtes Fachurteil, die untere eine Rechnung aus der Molekülkarte. Stimmen sie überein, ist die Aussage stärker; weichen sie ab, ist genau das die interessante Stelle. */} {dim.gerechnet && (
    {BB.t("gerechnet")} {Math.round(dim.gerechnet.score)} {!kompakt && <> {" · "}{BB.t("Konfidenz")} {Math.round(dim.gerechnet.konfidenz * 100)} % {/* Die Faktornamen nur, wenn sie mehr sagen als die Überschrift darüber. Bei „Organlast" stand sonst „Organlast" in Klammern darunter. */} {dim.gerechnet.faktoren.length > 1 && ( {" "}({dim.gerechnet.faktoren.map((f) => BB.t(f.label)).join(", ")}) )} }
    )}
    {infoOffen && ( )}
    ); } /* Nur `setzung` wird angeschrieben. Die uebrigen drei Stufen unterscheiden sich in der Art des Belegs, nicht in seiner Festigkeit, und ein Etikett an jedem Satz waere Rauschen. */ function BelegMarke({ finding }) { if (!finding || finding.beleg !== "setzung") return null; return {BB.t("gesetzte Schwelle")} ; } function DimensionFindings({ dimensions, zahlen = true, titel }) { const mitBefund = dimensions.filter((dimension) => dimension.findings && dimension.findings.length); if (!mitBefund.length) return null; const liste = (
    {mitBefund.map((dimension) => (
    {dimension.label}
      {dimension.findings.map((finding, i) => ( ))}
    ))}
    ); /* Mit Titel ist das ein Abschnitt, und die Dimensionen sind seine Unterpunkte. Ohne standen fünf gleich laute Köpfe untereinander, jeder mit derselben Linie wie „Interaktionsmatrix". */ if (!titel) return liste; return (
    {BB.t(titel)}
    {liste}
    ); } var MEAL_NRV_FALLBACK_LABELS = { a: "Vitamin A", d: "Vitamin D", e: "Vitamin E", k: "Vitamin K", c: "Vitamin C", b1: "Vitamin B1", b2: "Vitamin B2", b3: "Niacin", b5: "Vitamin B5", b6: "Vitamin B6", folat: "Folat", b12: "Vitamin B12", calcium: "Calcium", eisen: "Eisen", magnesium: "Magnesium", zink: "Zink", kupfer: "Kupfer", mangan: "Mangan", selen: "Selen", phosphor: "Phosphor", kalium: "Kalium", }; var MEAL_NRV_ARTICLE_SLUGS = { a: "vitamin-a", d: "vitamin-d", e: "vitamin-e", k: "vitaminK2", c: "vitaminC", b1: "vitamin-b1", b2: "vitamin-b2", b3: "niacin", b5: "vitamin-b5", b6: "vitamin-b6", folat: "folsaeure", b12: "vitaminB12", calcium: "kalzium", eisen: "eisen", magnesium: "magnesium", zink: "zink", kupfer: "kupfer", selen: "selen", kalium: "kalium", }; function mealFormat(wert, stellen) { const zahl = Number(wert); if (!Number.isFinite(zahl)) return "?"; return zahl.toLocaleString("de-DE", { maximumFractionDigits: stellen === undefined ? 1 : stellen }); } function mealRangeText(ref, einheit) { if (!ref || !Number.isFinite(Number(ref.von))) return ""; const suffix = einheit ? " " + einheit : ""; const von = mealFormat(ref.von); if (ref.bis === null || ref.bis === undefined) return "ab " + von + suffix; return von + suffix + " bis " + mealFormat(ref.bis) + suffix; } function MealReferenceRow({ label, display, scaleValue, referenz, einheit, note, scaleMax }) { const wert = Number(scaleValue); const von = Number(referenz && referenz.von); const bis = referenz && referenz.bis === null ? null : Number(referenz && referenz.bis); if (!Number.isFinite(wert) || !Number.isFinite(von)) return null; const bereichsEnde = bis === null || !Number.isFinite(bis) ? von * 2 : bis; const maximum = Math.max(1, Number(scaleMax) || 0, bereichsEnde * 1.25, wert * 1.1); const links = Math.max(0, Math.min(100, von / maximum * 100)); const rechts = bis === null || !Number.isFinite(bis) ? 100 : Math.max(links, Math.min(100, bis / maximum * 100)); const marker = Math.max(0, Math.min(100, wert / maximum * 100)); const ausserhalb = wert < von || (bis !== null && Number.isFinite(bis) && wert > bis); const bereich = mealRangeText(referenz, einheit); return (
    {label} {display} {note && · {note}}
    {BB.t("Referenz")}: {bereich}
    ); } function MealCoverageRow({ label, artikel, wert }) { const prozent = Number(wert); if (!Number.isFinite(prozent)) return null; const fuellung = Math.max(0, Math.min(100, prozent)); return (
    {artikel ? {label} : label} {mealFormat(prozent, 0)} %
    0 %100 % {BB.t("Tagesbezug")}
    ); } function MealReferenceRanges({ report }) { const summe = report && report.inputs && report.inputs.summe; const anlass = (report && report.dimensions || []).find((dimension) => dimension.key === "anlass"); const naeherstoffe = (report && report.dimensions || []).find((dimension) => dimension.key === "naehrstoffe"); const anlassDetail = anlass && anlass.detail; const nahrDetail = naeherstoffe && naeherstoffe.detail; const referenzen = anlassDetail && anlassDetail.referenzen; if (!summe || !referenzen) return null; const prozentRef = (ref) => ref && { von: Number(ref.von) * 100, bis: ref.bis === null || ref.bis === undefined ? null : Number(ref.bis) * 100, }; const makros = [ { key: "protein", label: BB.t("Eiweiß"), display: mealFormat(summe.protein) + " g", scaleValue: summe.protein, referenz: referenzen.protein, einheit: "g", }, { key: "kh", label: BB.t("Kohlenhydrate"), display: mealFormat(summe.kh) + " g", scaleValue: Number(anlassDetail.kh_anteil) * 100, referenz: prozentRef(referenzen.kh), einheit: "%", note: mealFormat(Number(anlassDetail.kh_anteil) * 100, 0) + " % " + BB.t("der Energie"), scaleMax: 100, }, { key: "fett", label: BB.t("Fett"), display: mealFormat(summe.fett) + " g", scaleValue: Number(anlassDetail.fett_anteil) * 100, referenz: prozentRef(referenzen.fett), einheit: "%", note: mealFormat(Number(anlassDetail.fett_anteil) * 100, 0) + " % " + BB.t("der Energie"), scaleMax: 100, }, { key: "kcal", label: BB.t("Energie"), display: mealFormat(anlassDetail.kcal, 0) + " kcal", scaleValue: anlassDetail.kcal, referenz: referenzen.kcal, einheit: "kcal", }, ]; const anteile = nahrDetail && nahrDetail.nrv_anteile || {}; const labels = nahrDetail && nahrDetail.nrv_labels || {}; const mikro = Object.entries(anteile) .map(([key, anteil]) => ({ key, label: labels[key] || MEAL_NRV_FALLBACK_LABELS[key] || key, wert: Number(anteil) * 100, })) .filter((eintrag) => Number.isFinite(eintrag.wert)); return (
    {BB.t("Makros")}
    {BB.t("Passend zum Anlass")}
    {makros.map((makro) => ( ))}
    {BB.t("Mikronährstoffe")}
    {BB.t("Anteil am Tagesbezug")}
    {mikro.length > 0 ? (
    {mikro.map((eintrag) => ( ))}
    ) : (

    {BB.t("Für diese Lebensmittel liegen keine quantitativen Mikronährstoffwerte vor.")}

    )}

    {BB.t("Referenz sind die EU-Nährstoffbezugswerte für Erwachsene, nicht ein individueller Tagesbedarf.")}

    ); } function blutWertText(wert) { const zahl = Number(wert); if (!Number.isFinite(zahl)) return String(wert || "?"); return Number.isInteger(zahl) ? String(zahl) : zahl.toLocaleString("de-DE", { maximumFractionDigits: 2 }); } function blutWertName(wert, index) { // Der Analytname ist ein Datum aus dem Katalog, kein Text im Quelltext. // Ohne BB.t() bleibt "Gesamteiweiß" in jeder Sprache stehen. Ein Titel aus // der Wissensbasis ist dagegen schon uebersetzt und bleibt unberuehrt. if (wert.label) return BB.t(wert.label); const artikel = (index || []).find((eintrag) => eintrag.slug === wert.analyt); if (artikel && artikel.title) return artikel.title; return String(wert.analyt || "Wert") .replace(/-/g, " ") .replace(/^./, (zeichen) => zeichen.toUpperCase()); } function blutHinweise(report) { const nachWert = {}; const reihenfolge = { achtung: 0, hinweis: 1, info: 2 }; const istUnauffaellig = (finding) => { const evidence = finding && finding.evidence; if (!evidence || evidence.lage !== "innen" || finding.level !== "info") return false; if (evidence.randnah !== undefined) return evidence.randnah === false; const anteil = Number(evidence.anteil); return Number.isFinite(anteil) && anteil >= 15 && anteil <= 85; }; const sammeln = (finding) => { const key = finding && finding.evidence && finding.evidence.analyt; if (!key || istUnauffaellig(finding)) return; if (!nachWert[key]) nachWert[key] = []; if (!nachWert[key].some((vorhanden) => vorhanden.id === finding.id && vorhanden.text === finding.text)) { nachWert[key].push(finding); } }; (report.flags || []).forEach(sammeln); (report.dimensions || []).forEach((dimension) => { (dimension.findings || []).forEach(sammeln); }); Object.values(nachWert).forEach((findings) => findings.sort( (a, b) => (reihenfolge[a.level] === undefined ? 9 : reihenfolge[a.level]) - (reihenfolge[b.level] === undefined ? 9 : reihenfolge[b.level]) )); return nachWert; } function BloodValuesList({ report }) { const werte = report && report.inputs && Array.isArray(report.inputs.werte) ? report.inputs.werte : []; const { go } = useRouter(); const [offen, setOffen] = useState(null); const [wissenIndex, setWissenIndex] = useState([]); const nachWert = blutHinweise(report || {}); useEffect(() => { let aktiv = true; BB.loadIndex().then((index) => { if (aktiv) setWissenIndex(Array.isArray(index) ? index : []); }); return () => { aktiv = false; }; }, []); if (!werte.length) return null; const nameHtml = (wert) => BB.autoLink(blutWertName(wert, wissenIndex), wissenIndex, ""); const onNameClick = (event) => { const link = event.target.closest && event.target.closest(".auto-link"); if (!link) return; event.preventDefault(); event.stopPropagation(); go("/wissen/" + link.dataset.slug); }; const toggleHinweis = (hinweisId) => { setOffen((aktuell) => aktuell === hinweisId ? null : hinweisId); }; const onValueClick = (event, hinweisId) => { // Der auto-verlinkte Wertname bleibt ein eigener Navigationspunkt. if (event.target.closest && event.target.closest(".auto-link")) return; toggleHinweis(hinweisId); }; const onValueKeyDown = (event, hinweisId) => { if (event.target.closest && event.target.closest(".auto-link")) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); toggleHinweis(hinweisId); }; return (
    {BB.t("Eingetragene Werte")}
    {werte.map((wert, i) => { const anteil = wert.anteil === null || wert.anteil === undefined || wert.anteil === "" ? NaN : Number(wert.anteil); const fallback = Number(wert.bis) > Number(wert.von) ? (Number(wert.wert) - Number(wert.von)) / (Number(wert.bis) - Number(wert.von)) * 100 : 50; const bereichsanteil = Number.isFinite(anteil) ? anteil : fallback; // Die Referenzspanne liegt in der Mitte der Skala. So bleiben Werte // ausserhalb sichtbar, ohne den Balken fuer den Rest zu sprengen. const position = Math.max(0, Math.min(100, (bereichsanteil + 50) / 2)); const refVon = blutWertText(wert.von); const refBis = blutWertText(wert.bis); const hinweise = nachWert[wert.analyt] || []; const kritisch = hinweise.some((finding) => finding.level === "achtung"); const hinweisId = "blut-hinweis-" + (wert.analyt || i); const istInteraktiv = hinweise.length > 0; const istOffen = offen === hinweisId; return (
    onValueClick(event, hinweisId) : undefined} onKeyDown={istInteraktiv ? (event) => onValueKeyDown(event, hinweisId) : undefined} >
    {istInteraktiv && ( )}
    {blutWertText(wert.wert)} {wert.einheit || ""}
    {BB.t("Referenzbereich")}: {refVon} bis {refBis} {wert.einheit || ""} {Math.round(bereichsanteil)} %
    {istOffen && (
      {hinweise.map((finding, hinweisIndex) => ( ))}
    )}
    ); })}
    ); } /* Runde Teilstriche für eine absolute Achse: 1, 2, 2,5 oder 5 mal einer Zehnerpotenz, so dass vier bis sechs Striche entstehen. */ function kurvenTicks(maximum) { if (!(maximum > 0)) return [0, 1]; const roh = maximum / 5; const basis = Math.pow(10, Math.floor(Math.log10(roh))); const schritt = [1, 2, 2.5, 5, 10].map((f) => f * basis).find((s) => s >= roh); const ticks = []; for (let wert = 0; wert <= maximum + schritt * 0.999; wert += schritt) { ticks.push(Math.round(wert * 1000) / 1000); } return ticks; } /* Der Depotverlauf in freigesetztem Wirkstoff je Tag. Frühere Auswertungen tragen noch Kurven, die je Stoff auf ihr eigenes Maximum normiert sind; sie haben kein `einheit` und werden weiter in Prozent gezeichnet. */ function StackCurveGraph({ curve }) { const tage = Array.isArray(curve && curve.tage) ? curve.tage : []; const kurven = Array.isArray(curve && curve.kurven) ? curve.kurven : []; if (tage.length < 2 || !kurven.length) return null; const absolut = !!curve.einheit && kurven.every((k) => k.absolut !== false); const breite = 760; const hoehe = 292; const rand = { oben: 22, rechts: 20, unten: 48, links: absolut ? 58 : 44 }; const innenBreite = breite - rand.links - rand.rechts; const innenHoehe = hoehe - rand.oben - rand.unten; const dauer = Number(curve.zeitraum_tage) || Number(tage[tage.length - 1]) || 1; const hoechster = absolut ? Math.max(...kurven.map((k) => Math.max(...(k.punkte || [0])))) : 1; const yTicks = absolut ? kurvenTicks(hoechster) : [0, 0.25, 0.5, 0.75, 1]; const yMax = yTicks[yTicks.length - 1] || 1; const x = (tag) => rand.links + (Number(tag) / dauer) * innenBreite; const y = (wert) => rand.oben + (1 - Math.max(0, Math.min(1, (Number(wert) || 0) / yMax))) * innenHoehe; const pfad = (werte) => (werte || []).map((wert, i) => `${i ? "L" : "M"} ${x(tage[i] === undefined ? 0 : tage[i]).toFixed(2)} ${y(wert).toFixed(2)}` ).join(" "); const xTicks = [0, 0.25, 0.5, 0.75, 1]; const mg = (wert) => BB.num(wert >= 10 ? Math.round(wert) : Math.round(wert * 10) / 10); return (
    {BB.t("Depotverlauf")}

    {absolut ? BB.t("Freigesetzter Wirkstoff je Tag, aus Dosis, Esteranteil und Halbwertszeit modelliert. Der Blutspiegel verläuft proportional dazu; seine Höhe in ng/dl hängt von der eigenen Clearance ab und wird deshalb nicht behauptet.") : BB.t("Die Kurve ist eine modellierte Orientierung je Wirkstoff und kein gemessener Blutspiegel.")}

    {yTicks.map((tick) => ( ))} {yTicks.map((tick) => ( {absolut ? mg(tick) + " mg" : Math.round(tick * 100) + "%"} ))} {xTicks.map((tick) => ( {Math.round(tick * dauer)} ))} {kurven.map((k, i) => ( ))} {BB.t("Zeit (Tage)")} {absolut ? " · " + BB.t("mg Wirkstoff pro Tag") : ""}
    {kurven.map((k, i) => (
    ))}
    ); } /* --- Wirkketten aus der Molekülkarte ------------------------------------- Die alte Detailansicht steht neben der Bewertung. Der zusammengefuehrte Matrixblock weiter unten liefert dagegen eigene Faktoren fuer den Hauptscore; beide Ebenen bleiben als Graphhypothese klar gekennzeichnet. -------------------------------------------------------------------------- */ /* Der Belegungsanteil ist eine Größenordnung, keine Messung. Er wird deshalb grob gerundet: „19 %" behauptet eine Genauigkeit, die in der Rechnung nicht steckt, „rund ein Fünftel" ist ehrlicher und genauso brauchbar. */ function pfadAnteil(anteil) { const p = Math.max(0, Math.min(1, Number(anteil) || 0)) * 100; if (p >= 50) return "über die Hälfte"; if (p >= 30) return "rund ein Drittel"; if (p >= 20) return "rund ein Fünftel"; if (p >= 12) return "gut ein Zehntel"; return "etwa ein Zehntel"; } function pfadZahl(wert, stellen = 1) { const n = Number(wert); if (!isFinite(n)) return ""; return (n >= 100 ? Math.round(n).toString() : n.toFixed(stellen)).replace(".", ","); } /* Eine kleine, unbewegliche Karte zu genau einer Aussage. Bewusst nicht der interaktive Renderer der Molekülkarte: hier soll nichts aufgeklappt werden, sondern ein Ausschnitt stillstehen. Farben und Kantenformen kommen trotzdem von dort (mkPalette, mkStylesheet, mkNodeEl, mkEdgeEl), damit dieselbe Kante an beiden Stellen gleich aussieht. */ /* Der Weg als Kartenausschnitt. Er stand in einem schmalen hohen Kasten mit drei Bubbles in der Mitte und viel Leerraum drumherum: das Layout rechnet in der Fläche, die es bekommt, und die war hoch statt breit. Der Kasten ist jetzt so breit wie die Zeile und flach, und `fit` holt den Ausschnitt heran statt ihn auf einen Briefmarken-Zoom zu setzen. Ein Klick öffnet Vollbild, wie in der Molekülkarte selbst. Der Knopf „groß ansehen" daneben ist damit überflüssig: das Bild ist der Knopf. */ function PfadKarte({ karte, titel }) { const halter = React.useRef(null); const [vollbild, setVollbild] = useState(false); const knoten = (karte && karte.nodes) || []; const kanten = (karte && karte.edges) || []; // Escape schließt, und solange offen ist, scrollt die Seite darunter nicht. React.useEffect(() => { if (!vollbild) return undefined; const zu = (event) => { if (event.key === "Escape") setVollbild(false); }; document.addEventListener("keydown", zu); const vorher = document.body.style.overflow; document.body.style.overflow = "hidden"; /* **Ohne das öffnet sich das Vollbild dort, wo der Weg steht, und nicht vor den Augen.** `PageTransition` animiert `
    ` mit `translateY` und lässt die Transformation als Inline-Stil stehen. Ein transformierter Vorfahr wird für `position: fixed` zum Bezugsrahmen: aus „über dem Bildschirm" wird „über dem Hauptbereich", und wer ein Stück gescrollt hat, sieht nur den unscharfen Hintergrund. Die Molekülkarte hatte denselben Fehler und dieselbe Lösung; seitdem heißt die Klasse für beide gleich. */ document.documentElement.classList.add("bb-vollbild-aktiv"); return () => { document.removeEventListener("keydown", zu); document.body.style.overflow = vorher; document.documentElement.classList.remove("bb-vollbild-aktiv"); }; }, [vollbild]); React.useEffect(() => { const el = halter.current; if (!el || !knoten.length || typeof cytoscape === "undefined") return; const pal = mkPalette(); const cy = cytoscape({ container: el, elements: [...knoten.map(mkNodeEl), ...kanten.map(mkEdgeEl)], style: mkStylesheet(pal), // Ein Weg ist eine Kette und keine Wolke. `breadthfirst` legt ihn von // links nach rechts, statt die Knoten im Kreis zu verteilen. layout: { name: "breadthfirst", directed: true, animate: false, padding: 16, spacingFactor: 1.1, horizontal: true, grid: false, }, userZoomingEnabled: vollbild, userPanningEnabled: vollbild, boxSelectionEnabled: false, autoungrabify: !vollbild, autounselectify: true, }); // Nah heran: der Ausschnitt hat drei bis fünf Knoten, und die sollen den // Kasten füllen. Ohne Deckel zieht `fit` bei zwei Knoten ins Absurde. // // **Erst messen, dann einpassen.** `cy.ready()` läuft, während der Kasten // noch aufklappt; Cytoscape hat dann die Breite gespeichert, die der // Container in diesem Moment hatte, und rechnet `fit` und `center` gegen // sie. Bei einem Zweiknotenweg landete der Graph dadurch am rechten Rand // statt in der Mitte. `resize()` verwirft die alte Messung, und der // Beobachter wiederholt das, sooft sich die Fläche später noch ändert -- // beim Aufklappen, beim Vollbild, beim Drehen des Geräts. const einpassen = () => { cy.resize(); cy.fit(undefined, 14); if (cy.zoom() > 1.6) cy.zoom(1.6); cy.center(); }; cy.ready(einpassen); const beobachter = typeof ResizeObserver !== "undefined" ? new ResizeObserver(einpassen) : null; if (beobachter) beobachter.observe(el); return () => { if (beobachter) beobachter.disconnect(); cy.destroy(); }; }, [karte, vollbild]); if (!knoten.length) return null; const flaeche = (
    ); if (vollbild) { return (
    setVollbild(false)}>
    e.stopPropagation()}> {flaeche}
    {titel}
    ); } return (
    {titel}
    ); } /* Ein Nebenziel als Zeile. Die Zahlen stehen mit dabei und nicht in einem Tooltip: die Aussage trägt nur, solange man sie nachrechnen kann. */ function PfadZiel({ ziel, kette }) { const [tief, hoch] = ziel.spanne || []; return (
  • {ziel.label} {pfadAnteil(ziel.belegung)}

    {kette ? "Der Metabolit bindet" : "Bindet"} hier bei {pfadZahl(ziel.affinitaet)} nM, das ist {pfadZahl(ziel.selektivitaet)} mal schwächer als am eigentlichen Ziel.

    {ziel.messungen} {ziel.messungen === 1 ? "Messung" : "Messungen"} {tief != null && hoch != null && tief !== hoch ? `, Spanne ${pfadZahl(tief)} bis ${pfadZahl(hoch)} nM` : ""} {ziel.affinitaetsart ? `, ${ziel.affinitaetsart}` : ""} {ziel.quelle ? `, ${ziel.quelle}` : ""} {ziel.vergleichbar === false ? ", Messarten gemischt" : ""}

  • ); } function PfadHypothese({ hypothese }) { const h = hypothese; const ueber = h.dosisfaktor >= 1; const bezugText = { studien_bis: "der höchsten untersuchten Dosis", klinisch: "der zugelassenen Dosis", bis: "dem oberen Ende des verbreiteten Bereichs", }[h.bezug] || "der Referenzdosis"; return (
    {h.label} {h.dosis ? `${pfadZahl(h.dosis, 0)} ${h.einheit}` : "ohne Dosisangabe"} {" · "} {ueber ? `${pfadZahl(h.dosisfaktor, 1)} mal` : `${pfadZahl(h.dosisfaktor, 2)} mal`} {" "}{bezugText} ({pfadZahl(h.referenzdosis, 0)} {h.einheit})

    Wirkt über {h.hauptziel.label} ({pfadZahl(h.hauptziel.affinitaet)} nM aus{" "} {h.hauptziel.messungen} Messungen). Darauf sind die folgenden Verhältnisse bezogen.

    {h.ziele.length > 0 && (
      {h.ziele.map((z) => )}
    )} {(h.ketten || []).map((k) => (

    Wird über {k.enzyme.join(" oder ")} zu {k.produkt.label}, und dieser Stoff wirkt seinerseits über {k.hauptziel.label}.

      {k.ziele.map((z) => )}
    ))}
    ); } /* Zwei Spalten je System: die Last und was sie adressiert. Sie werden nie miteinander verrechnet. Ein Leberschutz aendert nichts an der Leberlast eines Orals; er sagt nur, dass ueberhaupt etwas dasteht. */ var SYSTEM_LAST_TON = { gering: "", erhoeht: "warn", hoch: "hot" }; /* **Keine Plakette neben dem Systemnamen mehr.** Dort standen „immerhin etwas", „keine Last", „nur über Messwerte" und „zum Teil aufgefangen". Der ganze Block gibt Vermutungen aus Rechnungen aus, also gilt „nur über Messwerte" für jede Zeile, und die übrigen sagen nichts, was der Balken und die Zeile „Dagegen: …" nicht schon sagen. Was bleibt, sind die Zahl, der Balken und die Namen der Stoffe, die hier ansetzen. */ function StackSystemZeile({ eintrag }) { const last = eintrag.last || {}; const abdeckung = eintrag.abdeckung || {}; const anteil = Math.max(0, Math.min(1, (last.wert || 0) / (last.kritisch_ab || 1))); return (
  • {eintrag.label}
    {BB.t("Last")} {/* Der Wert gegen seine eigene Grenze statt eines Wortes daneben. „9,0 gering" stand da, weil die Skala bis 14 geht und die Warnschwelle bei 10 liegt: richtig gerechnet, aber ohne die Skala unlesbar. Die Farbe des Balkens trägt die Einordnung weiter, die Zahl daneben sagt jetzt, worauf sie sich bezieht. */} {BB.num(Math.round((last.wert || 0) * 10) / 10)} {last.kritisch_ab ? ` / ${BB.num(last.kritisch_ab)}` : ""}
    {/* „Dagegen: TUDCA, NAC" stand hier und war eine Empfehlung: es sagte, was gegen diese Last hilft. Dieses Projekt beschreibt und ordnet ein, es empfiehlt nicht. Wo eine Gegenmaßnahme ansetzt, steht in der Interaktionsmatrix als Risikominderung, mit Weg und Treiberprotein statt als Liste von Namen. */}
  • ); } function StackSysteme({ systeme }) { const liste = (systeme && systeme.systeme) || []; if (!liste.length) return null; return (
    {BB.t("Belastung je System")}

    {BB.t("Was an Grundlast bleibt, auch wenn die Werte gut aussehen. Darunter steht, was im Stack an dieser Stelle ansetzt.")}

      {liste.map((eintrag) => ( ))}
    ); } function StackPfade({ pfade }) { const hypothesen = (pfade && pfade.hypothesen) || []; const [offen, setOffen] = React.useState(false); if (!hypothesen.length) return null; return (
    {BB.t("Mögliche Nebenziele bei dieser Dosis")}

    {BB.t("Das ist eine Hypothese und geht in keine Bewertung ein.")}{" "} Sie ist aus gemessenen Bindungsstärken gerechnet, nicht am Menschen geprüft. Ein Stoff bindet neben seinem Ziel noch anderes, nur schwächer. Bei der untersuchten Dosis spielt das keine Rolle, bei einem Vielfachen davon möglicherweise schon. Was hier steht, ist der Hinweis, wo man hinsehen könnte, und kein Befund.

    {hypothesen.map((h) => )} {offen && (

    Gerechnet wird nur mit Verhältnissen, weil sich aus einer Angabe in Milligramm keine Konzentration am Wirkort ableiten lässt. Zwei Zahlen gehen ein: wie viel schwächer ein Nebenziel gebunden wird als das eigentliche Ziel desselben Stoffes, und wie weit die eingetragene Dosis über der liegt, zu der es Daten gibt. Beides sind Verhältnisse desselben Moleküls, alles Pharmakokinetische steht in beiden gleich und fällt beim Teilen heraus.

    Die Annahme dahinter: bei der Referenzdosis liegt die Konzentration in der Größenordnung der Bindungsstärke am eigentlichen Ziel. Genau deshalb wirkt der Stoff dort. Mehr steckt in der Schätzung nicht, und sie wird darum auch nur als Größenordnung genannt.

    Nicht berücksichtigt: Messungen ab {pfadZahl(pfade.obergrenze_nm, 0)} nM, weil ein Test ohne Fund dort als Zahl statt als „kein Effekt" in den Quellen steht. Ebenso Ziele mit weniger als {pfade.min_messungen}{" "} Messungen oder mit Werten, die um mehr als das Tausendfache auseinanderliegen. Genannt wird ein Nebenziel erst ab geschätzt{" "} {Math.round((pfade.schwelle || 0) * 100)} Prozent Belegung.

    )}
    ); } /* Wo sich zwei Stoffe am selben Knoten treffen. Steht wie `stack_pfade` neben der Bewertung, mit gestricheltem Rahmen und ohne Score: das hier ist aus gemessenen Bindungen und einer gepflegten Tabelle gerechnet, nicht am Menschen geprueft. Gegenlaeufig heisst ausdruecklich nicht falsch. Wer im Cut Metformin neben Wachstumshormon nimmt, will genau diese Gegenlaeufigkeit. Die Anzeige sagt deshalb, was da steht, und nicht, was zu tun waere. */ var WEGE_ART_TON = { gegen: "hot", wettbewerb: "warn", gleich: "", offen: "" }; var WEGE_ART_TEXT = { gegen: "gegenläufig", wettbewerb: "Konkurrenz", gleich: "gleichläufig", offen: "Richtung offen", }; var WEGE_RICHTUNG_TEXT = { hoch: "treibt an", runter: "hemmt", substrat: "wird dort umgesetzt", }; function WegeStoff({ stoff }) { return (
  • {stoff.label} {stoff.rolle === "kontrolle" && ( {BB.t("Absicherung")} )} {BB.t(WEGE_RICHTUNG_TEXT[stoff.richtung] || "Richtung nicht belegt")} {stoff.wert != null && ( · {pfadZahl(stoff.wert, 1)} nM aus {stoff.messungen} {BB.t("Messungen")} )} {stoff.ebene === "kuratiert" && !stoff.wert && ( · {BB.t("aus der Wissensbasis")} )}
  • ); } function WegeTreffer({ treffer }) { return (
    {treffer.name}{treffer.name !== treffer.gen ? ` (${treffer.gen})` : ""} {BB.t(WEGE_ART_TEXT[treffer.art] || treffer.art)}

    {treffer.text}.

      {treffer.stoffe.map((stoff) => )}
    {treffer.timing && (

    {BB.t("Zeitlich")}: {treffer.timing}.

    )}
    ); } function StackWege({ wege }) { const gemeinsam = (wege && wege.gemeinsam) || []; const gegenspieler = (wege && wege.gegenspieler) || []; const ohneKarte = (wege && wege.ohneKarte) || []; const [offen, setOffen] = React.useState(false); if (!gemeinsam.length && !gegenspieler.length) return null; return (
    {BB.t("Wo die Stoffe sich treffen")}

    {BB.t("Auch das geht in keine Bewertung ein.")}{" "} {BB.t("Gezeigt wird, an welchen Proteinen mehrere Stoffe aus dieser Liste ankommen und ob sie dort in dieselbe Richtung ziehen. Gegenläufig heißt nicht falsch: in der Diät kann genau das gewollt sein.")}

    {gegenspieler.map((paar) => (
    {paar.label} {BB.t("gegenläufig")}

    {paar.text}

    • {paar.a.map((e) => e.label).join(", ")} {paar.a.map((e) => e.gene.join("+")).join(", ")}
    • {paar.b.map((e) => e.label).join(", ")} {paar.b.map((e) => e.gene.join("+")).join(", ")}
    {paar.timing && (

    {BB.t("Zeitlich")}: {paar.timing}.

    )}
    ))} {gemeinsam.map((treffer) => ( ))} {wege.weitere > 0 && (

    {BB.t("Nicht gezeigt")}: {BB.num(wege.weitere)} {BB.t("weitere gemeinsame Ziele.")}

    )} {ohneKarte.length > 0 && (

    {BB.t("Ohne Eintrag in der Karte")}: {ohneKarte.map((z) => z.label).join(", ")}.{" "} {BB.t("Zu diesen Stoffen gibt es weder eine gemessene Bindung noch einen gepflegten Wirkweg. Sie fehlen hier, sie sind nicht unauffällig.")}

    )} {offen && (

    {BB.t("Zwei Quellen, getrennt gehalten. Gemessen: Bindungen aus ChEMBL, GtoPdb und BindingDB, mit mindestens drei Messungen je Ziel und ohne Werte über der Nachweisgrenze. Gepflegt: eine Tabelle, die je Wirkweg sagt, an welchem Protein er ansetzt und in welche Richtung.")}

    {BB.t("Die gepflegte Ebene ist nötig, weil eine Bindungsmessung manche Aussage gar nicht enthalten kann. Metformin aktiviert AMPK, bindet es aber nicht: es hemmt Komplex I der Atmungskette. Für Peptide und Pflanzenauszüge fehlen Messungen oft ganz.")}

    {BB.t("Die Zeitangabe kommt aus der Verlaufskurve oben. Verglichen wird die Form zweier Depotkurven, nicht die Menge: Milligramm verschiedener Moleküle sind nicht vergleichbar. Genannt wird der kleinste Wert unter den Beteiligten, damit „gleichzeitig“ nur dasteht, wenn es für alle gilt.")}

    )}
    ); } var CONVERGENZ_ART_TEXT = { reinforcing: "gleichgerichtet", antagonistic: "gegenläufig", additive_burden: "additive Belastung", potential_synergy: "mögliche Synergie", uncertain: "unsicher", }; var CONVERGENZ_ART_TON = { antagonistic: "hot", additive_burden: "hot", potential_synergy: "warn", }; var CONVERGENZ_RICHTUNG = { up: "↑", down: "↓", unknown: "?" }; function ConvergenceEffect({ effect }) { return (
  • {effect.compound} {effect.role === "kontrolle" && ( {BB.t("Absicherung")} )} {CONVERGENZ_RICHTUNG[effect.direction] || "?"}{" "} {(effect.path || []).join(" → ")} {effect.score != null && ( · {Math.round(effect.score * 100)} % {BB.t("Pfadscore")} )}
  • ); } function StackConvergence({ data }) { const points = (data && data.points) || []; const pk = (data && data.pharmacokinetic) || []; const [offen, setOffen] = React.useState(false); if (!points.length && !pk.length) return null; return (
    {BB.t("Biologische Convergence-Punkte")}

    {BB.t("Mehrstufige Graphhypothese: kein klinischer Befund.")}{" "} {BB.t("Die Pfade verbinden gemessene oder vorhergesagte Bindungen mit gerichteten Signalbeziehungen. Sie verändern den Score nicht.")}

    {points.map((point) => (
    {point.label || point.endpoint} {BB.t(CONVERGENZ_ART_TEXT[point.interaction] || point.interaction)}

    {BB.t("Gemeinsame Pfadkonfidenz")}: {Math.round((point.confidence || 0) * 100)} %.

      {(point.effects || []).map((effect) => ( ))}
    ))} {pk.map((item) => (
    {BB.t("Pharmakokinetik")}: {item.target} {item.interaction === "inhibition" ? BB.t("Hemmung") : BB.t("Induktion")}

    {item.regulator.compound} {item.interaction === "inhibition" ? BB.t("hemmt den Weg, über den folgende Stoffe umgesetzt oder transportiert werden:") : BB.t("verstärkt den Weg, über den folgende Stoffe umgesetzt oder transportiert werden:")}{" "} {(item.affected || []).map((e) => e.compound).join(", ")}.

    ))} {data.additionalPoints > 0 && (

    {BB.num(data.additionalPoints)} {BB.t("weitere Convergence-Punkte werden nicht gezeigt.")}

    )} {offen && (

    {BB.t("Maximale Pfadlänge")}: {(data.config && data.config.maxHops) || 4}.{" "} {BB.t("Gemessene und sichere Beziehungen werden bevorzugt; vorhergesagte, schwache, richtungslose und längere Wege erhalten Abschläge.")}

    {BB.t("Aktivierung zählt als +1, Hemmung als -1. Die Werte werden entlang des Pfades multipliziert. Eine unbekannte Richtung bleibt unbekannt und erzeugt keine sichere Aussage.")}

    )}
    ); } /* Der zusammengefuehrte Durchgang. Er ersetzt StackPfade, StackWege und StackConvergence: dieselbe Frage, dreimal in verschiedenen Sprachen beantwortet, ergab drei Bloecke unter dem Score, die einander ueberlappten. Die Matrixfaktoren stehen inzwischen auch im Hauptscore. Der Block erklärt sie weiterhin separat; seine Konfidenz bleibt eine Datenqualitaetsangabe und wird nicht in den Score eingerechnet. */ /* Der Wissensindex wird einmal geladen und von allen Teilen des Blocks benutzt. Ein State je Unterkomponente hiesse, ihn mehrfach zu holen. */ var ZSP_INDEX = { wert: [] }; var ZSP_PFEIL = { hoch: "\u25b2", runter: "\u25bc", offen: "\u2013" }; var ZSP_ART_TEXT = { redundanz: "Doppelung", aufhebung: "heben sich auf", last: "zusätzliche Last", synergie: "ergänzen sich", risikominderung: "Risikominderung", abdeckung: "zeitlich verteilt", neutral: "ohne Beitrag", }; /* Eine Seite der Waage: eine Zeile, ein Stoff, sein Balken, sein Name. Vorher standen beide Namen fest links und rechts, egal wohin ihr Balken zeigte. Wer die Zeile las, ordnete den linken Namen dem linken Balken zu, und das war bei jedem gegenlaeufigen Paar falsch. Zwei gleichlaeufige Balken lagen ausserdem in derselben Spur uebereinander und sahen aus wie einer. Der Name steht deshalb an dem Ende, an dem sein Balken liegt, der Pfeil klebt daneben, und jeder Stoff bekommt seine eigene Spurzeile. Die Mittellinie steht in beiden und haelt sie zusammen. */ function ZspWaageSeite({ name, wert, max, treiber, schritte }) { const runter = wert < 0; const anteil = (Math.abs(wert) / Math.max(max, 0.0001)) * 50; /* Das Treiberprotein steht an seinem Stoff und nicht mehr als gemeinsame Kopfzeile darueber. Solange die Namen in der Reihenfolge a, b standen, passte `AR / CYP19A1` dazu; seit die Seite die Richtung anzeigt, waere nicht mehr abzulesen, welches Protein zu welchem Stoff gehoert. */ const marke = ( {runter ? "\u25bc " + name : name + " \u25b2"} {treiber && ( {treiber}{schritte ? ` (${schritte})` : ""} )} ); return (
    {runter ? marke : } {runter ? : marke}
    ); } function ZspWaage({ eintrag, guenstig, ohneLabel, ohneGrund }) { const a = eintrag.aWert || 0; const b = eintrag.bWert || 0; const max = Math.max(Math.abs(a), Math.abs(b), 0.0001); return (
    {/* In einer Gruppe traegt der Kopf oft gar nichts mehr: der Endpunkt steht in der Ueberschrift, die Treiber stehen an ihren Stoffen. Ein leerer Kopf ist dann nur Abstand. */} {(!ohneLabel || guenstig !== undefined || eintrag.gezaehlt === false || (eintrag.timingBewertbar !== false && !eintrag.zeitgleich)) && (
    {/* Der Endpunktname steht in der Gruppenüberschrift, sobald mehrere Paare denselben Endpunkt treffen. Ihn je Zeile zu wiederholen war genau die Doppelung, die den Block unlesbar machte: sechsmal "Muscle protein breakdown" fett untereinander. */} {!ohneLabel && {eintrag.label}} {/* "Zeitlich versetzt" ist eine Timing-Aussage. Ohne hinterlegte Halbwertszeit gibt es sie nicht, und dann darf hier auch nichts stehen: Exemestan galt so einmal als versetzt, obwohl bei einem irreversiblen Aromatasehemmer die Uhrzeit keine Rolle spielt. */} {eintrag.timingBewertbar !== false && !eintrag.zeitgleich && ( {BB.t("zeitlich versetzt")} )} {guenstig !== undefined && ( {guenstig ? BB.t("günstig") : BB.t("ungünstig")} )} {eintrag.gezaehlt === false && ( {BB.t("zählt nicht doppelt")} )}
    )}
    {/* Der Begruendungssatz stand zwischen den beiden Namen und drueckte sie auseinander, bis der Pfeil unter den Stoff umbrach. In einer Gruppe steht er einmal ueber allen Zeilen, die ihn teilen (`zspGrundbloecke`), sonst hier. */} {!ohneGrund && eintrag.grund && (

    {BB.t(eintrag.grund)}

    )}
    ); } /* Paare nach Endpunkt buendeln, Reihenfolge sonst unveraendert. Drei Androgene am Muskelabbau ergeben drei Stoffpaare, und mit vertauschten Treiberrollen sechs Zeilen. Fachlich ist das eine Aussage ueber einen Endpunkt, und so steht sie jetzt auch da: eine Ueberschrift, darunter die Paare. Die Wiederholungen mit vertauschten Rollen liegen hinter einem Falz, weggelassen wird nichts. */ /* Gruppiert wird nach Endpunkt **und Paarart**. Seit `synergie` Ergänzung, Doppelung und Gegenläufigkeit in einem Faktor bündelt, können an einem Endpunkt Zeilen mit entgegengesetzter Bedeutung stehen. Eine gemeinsame Überschrift "günstig" oder "ungünstig" wäre für die Hälfte davon falsch. Getrennt gelesen ist es die eigentliche Auskunft: an der Muskelproteinsynthese ergänzen sich zwei Stoffe, und zwei andere doppeln sich dort. */ function zspPaargruppen(befunde) { const nach = new Map(); (befunde || []).forEach((eintrag) => { const endpoint = eintrag.endpoint || eintrag.label || ""; const schluessel = endpoint + "|" + (eintrag.art || ""); if (!nach.has(schluessel)) { nach.set(schluessel, { endpoint: schluessel, label: eintrag.label, art: eintrag.art, guenstig: eintrag.guenstig, wertung: eintrag.wertung, gezaehlt: [], doppelt: [] }); } const gruppe = nach.get(schluessel); (eintrag.gezaehlt === false ? gruppe.doppelt : gruppe.gezaehlt).push(eintrag); }); return Array.from(nach.values()); } /* Zeilen einer Gruppe nach ihrer Begruendung buendeln. Acht Paare an der HPTA-Unterdrueckung tragen zwei verschiedene Saetze, und jede Zeile trug ihren eigenen: derselbe Satz stand fuenfmal untereinander und las sich wie fuenf Befunde. Er steht jetzt einmal ueber den Zeilen, die ihn teilen. Sortiert wird nach dem ersten Auftreten, die Reihenfolge der Gruenden bleibt also die der Rechnung. */ function zspGrundbloecke(eintraege) { const bloecke = []; const nach = new Map(); (eintraege || []).forEach((eintrag) => { const grund = eintrag.grund || ""; if (!nach.has(grund)) { nach.set(grund, { grund: grund, zeilen: [] }); bloecke.push(nach.get(grund)); } nach.get(grund).zeilen.push(eintrag); }); return bloecke; } function ZspPaargruppe({ gruppe, guenstig }) { const [mehr, setMehr] = useState(false); const zeilen = gruppe.gezaehlt.length ? gruppe.gezaehlt : gruppe.doppelt; const rest = gruppe.gezaehlt.length ? gruppe.doppelt : []; // **Die Gruppe schlaegt den Faktor, nicht umgekehrt.** // // Vorher gab `faktor.guenstig` die Bewertung vor, sobald er gesetzt war. Beim // gebuendelten Faktor `synergie` ist das aber das Gesamturteil ueber alle // Paare: stand der Balken bei 26 Prozent, bekam auch die Ergaenzung zweier // Stoffe an der Entzuendung das Wort "unguenstig" daneben, obwohl sie der // Grund ist, warum der Balken nicht noch tiefer steht. Das Urteil der Gruppe // ist das genauere, der Faktor greift nur, wenn die Gruppe keines hat. const wertung = gruppe.guenstig !== undefined && gruppe.guenstig !== null ? gruppe.guenstig : guenstig; const w = gruppe.wertung || {}; return (
    {wertung !== undefined && wertung !== null && ( {w.wort ? BB.t(w.wort) : (wertung ? BB.t("günstig") : BB.t("ungünstig"))} )} {zeilen.length + rest.length}
    {/* Erst das Ziel, dann die Richtung, dann die Beziehung. In dieser Reihenfolge, weil "das doppelt sich" ohne "mehr ist hier erwuenscht" nicht einzuordnen ist. */} {(w.ziel || w.schub || w.beziehung) && (

    {[w.ziel, w.schub, w.beziehung].filter(Boolean) .map((teil) => BB.t(teil)).join(" ")}

    )} {zspGrundbloecke(zeilen).map((block, bi) => (
    {block.grund && (

    {BB.t(block.grund)}

    )} {block.zeilen.map((eintrag, i) => ( ))}
    ))} {rest.length > 0 && !mehr && ( )} {mehr && zspGrundbloecke(rest).map((block, bi) => (
    {block.grund && (

    {BB.t(block.grund)}

    )} {block.zeilen.map((eintrag, i) => ( ))}
    ))}
    ); } /* Ein Stoff mit seiner Dosis im Verhältnis zur untersuchten. "knapp" heißt: unter der Hälfte davon, der Stoff ist da, aber womöglich nicht in der Menge, in der er untersucht wurde. */ function ZspRisikoStoff({ stoff }) { const anteil = stoff.verhaeltnis != null ? Math.round(stoff.verhaeltnis * 100) : null; return ( {anteil != null && ( {" "}({BB.t("Dosis")} {BB.num(anteil)} % {BB.t("der Studiendosis")}{stoff.knapp ? ", " + BB.t("knapp") : ""}) )} ); } /* Die Risikominderungs-Abdeckung: je Kategorie, wie viel der Belastung abgefangen ist, und je Endpunkt, wer belastet und wer dagegenhält. */ function ZspRisiken({ kategorien }) { return (kategorien || []).map((k) => (
    {zspKategorie(k.kategorie)} {BB.num(Math.round(k.deckung * 100))} % {BB.t("gedeckt")}
      {k.punkte.map((p) => (
    • {BB.t(p.label)} {/* Ein Endpunkt, der weniger wiegt, sagt hier warum. Ohne den Satz liest sich „Herzfrequenz 0 % gedeckt" wie ein Mangel am Stack, obwohl der Ruhepuls an Cardio und Schlaf hängt. */} {p.risikogewicht < 1 && ( {" "}{BB.t("zählt nur zu")} {Math.round(p.risikogewicht * 100)} {BB.t("Prozent")} )} {BB.num(Math.round(p.deckung * 100))} %
      = 0.7 ? "" : p.deckung >= 0.3 ? "warn" : "hot")} style={{ transform: `scaleX(${p.deckung})` }} />
      {BB.t("belastet von")}{" "} {p.treiber.map((s, i) => ( {i ? ", " : ""} ))} {p.gegen.length ? {BB.t("abgefangen von")}{" "} {p.gegen.map((s, i) => ( {i ? ", " : ""} ))} : {BB.t("Nichts im Stack hält hier dagegen.")}}
    • ))}
    )); } function ZspFaktor({ faktor }) { const [offen, setOffen] = useState(false); const ton = faktor.score >= 70 ? "" : faktor.score >= 40 ? "warn" : "hot"; const iconName = ZSP_FACTOR_ICONS[faktor.key] || "spark"; return (
    {/* Nichts gefunden ist keine Null, und es gibt zwei Gründe dafür. Ein Stack aus lauter Depots hat wirklich keine getrennten Zeitfenster. Fehlt dagegen zu allen Stoffen die Kinetik, sagt der Faktor nichts über den Plan, sondern über unsere Daten, und dann schweigt er. */} {faktor.leer && faktor.gewicht === 0 && (

    {faktor.leerGrund === "datenlage" ? BB.t("Zu den Stoffen dieses Plans fehlen die Zeitangaben, deshalb wird das Timing nicht bewertet. Das ist eine Lücke in unseren Daten und kein Befund über den Plan.") : BB.t("Kein Befund. Der Faktor geht deshalb nicht in den Score ein, statt ihn mit null zu belasten.")}

    )} {offen && faktor.befundart === "kategorien" && faktor.befunde.map((eintrag) => (
    {zspKategorie(eintrag.kategorie)} {eintrag.betrag.toFixed(2)}
      {eintrag.punkte.map((punkt) => (
    • {ZSP_PFEIL[punkt.richtung] || ""} {BB.t(punkt.label)} {punkt.betrag.toFixed(2)}
      {punkt.durch.length ? punkt.durch.map((d) => ( {ZSP_PFEIL[d.richtung] || ""} {BB.t("über")} {d.ueber} {d.staerkeordnung === false && ( {" "}{BB.t("ohne Stärkeordnung")} )} )) : {BB.t("Wirkrichtung nicht belegt")}}
      {punkt.durch.map((d) => ( ))}
    • ))}
    ))} {offen && faktor.befundart === "risiken" && ( <> {faktor.bedeutung && (

    {BB.t(faktor.bedeutung)}

    )} )} {offen && faktor.befundart === "paare" && ( <> {faktor.bedeutung && (

    {BB.t(faktor.bedeutung)}

    )} {zspPaargruppen(faktor.befunde).map((gruppe) => ( ))} )}
    ); } /* Ein Begriff, der auf seinen Wissensartikel zeigt, wenn es einen gibt. `BB.autoLink` gab es schon für Artikeltext und Blutwertnamen; hier bekommt die Auswertung dieselbe Verknüpfung. Ohne Treffer bleibt der Begriff als reiner Text stehen. Ein Verweis ins Leere wäre schlechter als keiner. */ function Begriff({ text, index }) { if (!text) return null; if (!index || !index.length) return <>{text}; const html = BB.autoLink(String(text), index, ""); if (html === String(text)) return <>{text}; return ; } /* Der Weg hinter einer Aussage, auf Knopfdruck aus der Karte gerendert. Es wird nichts neu gerechnet. Gezeigt wird, worüber die Aussage entstanden ist. Der Ausschnitt klappt sich unter dem Befund auf, damit man den Satz dabei im Blick behält. */ function ZspPfadKnopf({ pfad, titel, startId }) { const [karte, setKarte] = useState(null); const [busy, setBusy] = useState(false); // Der Stoffknoten gehört dazu, sonst bleibt bei einem Ein-Schritt-Weg genau // eine Bubble übrig und Cytoscape zeichnet keine Kante. const ids = [startId].concat((pfad || []).map((k) => k.id)) .filter((i) => typeof i === "number"); if (ids.length < 2) return null; async function zeigen() { if (karte) { setKarte(null); return; } setBusy(true); try { /* `kette`: die Kennungen stehen in der Reihenfolge des Weges, und der Server schliesst die Luecken zwischen zwei Schritten. Ohne das kamen bei einem gepflegten Weg zwei Bubbles ohne Linie heraus -- Omega-3 steht in der Karte und hat dort keine einzige Kante, sein Weg kommt aus `wirkstoffe.json`. Eine Karte, die dazu nichts zeichnet, sieht aus, als haette die Rechnung nichts gefunden. */ setKarte(await BB.post("/molekuelkarte/ausschnitt", { ids, kette: true })); } catch (err) { setKarte({ fehler: err.message }); } finally { setBusy(false); } } return ( <> {karte && (karte.fehler ?

    {karte.fehler}

    : )} ); } /* Eine Zeile der System- oder Zieltabelle: zwei Balken nebeneinander. Sie werden nie miteinander verrechnet: ein schwacher Treffer mit guter Datenlage und ein starker mit dünner sind zwei sehr verschiedene Aussagen, und genau das soll ablesbar bleiben. */ function ZspTabellenzeile({ zeile, index, namen }) { const [offen, setOffen] = useState(false); const ton = zeile.wert >= 66 ? "hot" : zeile.wert >= 33 ? "warn" : ""; return (
    {offen && (
    {zeile.hinweis &&

    {BB.t(zeile.hinweis)}

    }
      {zeile.effekte.map((e, i) => (
    • {BB.t("an")} {e.gen} {e.staerkeordnung === false && ( {BB.t("ohne Stärkeordnung")} )} {e.gewicht.toFixed(2)} k {e.konfidenz.toFixed(2)}
      {(e.pfad || []).map((k) => k.zu).join(" → ")}
    • ))}
    )}
    ); } function ZspTabelle({ titel, erklaerung, zeilen, index, namen }) { if (!zeilen || !zeilen.length) return null; return (
    {/* Die zwei Farbkästchen standen hier neben der Überschrift und erklärten nichts: wer die Tabelle zum ersten Mal sieht, weiß damit noch nicht, dass jede Zeile zwei übereinanderliegende Balken hat. Das steht jetzt einmal als beschriftetes Beispiel darüber. */}
    {BB.t(titel)}

    {BB.t(erklaerung)}

    {zeilen.map((zeile) => ( ))}
    ); } /* Die Endpunktkategorien kommen als Schlüssel aus `endpunkte.json` und standen bisher roh in der Anzeige: "herz_kreislauf" statt "Herz und Kreislauf". Die Liste ist geschlossen, deshalb steht sie hier und nicht auf dem Server. */ const ZSP_KATEGORIE = { blut: "Blut", entzuendung: "Entzündung", haut: "Haut", herz_kreislauf: "Herz und Kreislauf", hormone: "Hormonachse", leber: "Leber", muskel: "Muskel", niere: "Niere", prostata: "Prostata", stoffwechsel: "Stoffwechsel", zellstress: "Zellstress", zns: "Nervensystem", sonstige: "Sonstige", }; function zspKategorie(name) { return BB.t(ZSP_KATEGORIE[name] || name || "Sonstige"); } /* Wenn ein Effekt in ein Zeitfenster fällt, in dem die Grundlinie des Endpunkts ohnehin wandert. HGH senkt die Insulinempfindlichkeit, das ist der bekannte Preis der Gabe. Genommen wird es aber pre-workout, und dort ist die Insulinempfindlichkeit ohnehin erhöht; mit vier Stunden Wirkdauer trifft es genau dieses Fenster. Es verändert keine Zahl. Wie stark eine erhöhte Grundlinie einen Abfall auffängt, ist nicht gemessen, und eine Rechnung dazu wäre eine Erfindung. */ function ZspFenster({ hinweis }) { if (!hinweis) return null; return (

    {BB.t("Zeitfenster")}: {BB.t(hinweis.text)}{" "} {BB.t("Wirkdauer")} {BB.num(hinweis.wirkdauerStunden)} h {hinweis.wirkdauerErsatz ? ` (${BB.t("aus einem anderen Applikationsweg")})` : ""} {hinweis.quelle ? ` · ${hinweis.quelle}` : ""} {hinweis.pmid ? ` · PMID ${hinweis.pmid}` : ""}

    ); } /* Zwei Beispielbalken statt einer Farbtabelle. Die Legende bestand aus zwei farbigen Kästchen mit den Wörtern „Stärke" und „Konfidenz" daneben. Wer die Tabelle zum ersten Mal sieht, weiß damit noch nicht, dass jede Zeile zwei übereinanderliegende Balken hat und welcher welcher ist. Hier steht die Form selbst, einmal und beschriftet. */ function ZspLegende() { return (
    {BB.t("Oberer Balken: Stärke")} {BB.t("Wie stark der Stack das anrührt.")}
    {BB.t("Unterer Balken: Konfidenz")} {BB.t("Wie gut die Datenlage das hergibt.")}

    {BB.t("Die beiden werden nie miteinander verrechnet. Ein schwacher Treffer mit guter Datenlage und ein starker mit dünner sind zwei sehr verschiedene Aussagen.")}

    ); } /* Was von der Eingabe nicht in dieser Matrix steht. Drei von acht Stoffen ohne Profil sind keine Kleinigkeit: die Matrix zeigt dann eine Aussage über fünf Stoffe und sieht aus wie eine über acht. Der Konfidenzabschlag zählt sie mit, denn ein Stoff, den die Wissensbasis nicht führt, verschwindet nicht aus dem Körper. */ function ZspStoffabdeckung({ daten }) { const fehlend = daten.nichtAbgedeckt || []; const ohneProfil = daten.ohneProfil || []; const zahl = daten.stoffzahl; if (!fehlend.length && !ohneProfil.length) return null; return ( <> {fehlend.length > 0 && (

    {zahl ? `${BB.num(zahl.gerechnet)} ${BB.t("von")} ${BB.num(zahl.eingegeben)} ${BB.t("Stoffen sind hier gerechnet.")}` : BB.t("Nicht alle Stoffe sind hier gerechnet.")} {" "} {BB.t("Zu diesen kennt die Molekülkarte keinen Knoten:")}{" "} {fehlend.join(", ")}.{" "} {BB.t("Sie zählen für die Konfidenz mit, nicht für die Faktoren.")}

    )} {/* Ein Stoff braucht keine gepflegte Zeile, um in der Karte zu stehen. Melatonin hat dort 391 gemessene Kanten, Koffein 169, ohne dass zu einem von beiden ein Profil existiert. Sie werden gerechnet wie jeder andere Stoff; was fehlt, ist die Einordnung daneben, und das steht hier statt in einem Ausschluss. */} {ohneProfil.length > 0 && (

    {ohneProfil.join(", ")}{" "} {ohneProfil.length === 1 ? BB.t("läuft nur über gemessene Kanten der Molekülkarte, ohne gepflegte Einordnung daneben.") : BB.t("laufen nur über gemessene Kanten der Molekülkarte, ohne gepflegte Einordnung daneben.")}{" "} {BB.t("Das senkt ihre Konfidenz, nicht ihr Gewicht.")}

    )} ); } /* Belastungen, die unter diesem Stack erwartbar sind und ersetzt werden. Die Unterdrückung der Hormonachse steht bei jedem Steroidstack ganz oben in der Organlast, und das stimmt: die Achse geht herunter. Nur juckt es nicht, solange ein aromatisierbares Androgen zugeführt wird, denn dann liegt an, was die Achse liefern würde. Ohne ein solches Androgen zählt sie voll, und dann steht sie auch wieder in der Organlast. */ function ZspErwartet({ zeilen }) { if (!zeilen || !zeilen.length) return null; return (
    {BB.t("Erwartbar und ersetzt")}
      {zeilen.map((z) => (
    • {BB.t(z.label)} {" "}{BB.t(z.text)}
    • ))}
    ); } /* **„Wo die Rechnung nichts findet" ist am 2026-09-22 entfallen.** Der Block stellte je Kategorie die gepflegte Organlast neben den gerechneten Anteil: „Leber: gepflegt 91 %, gerechnet 19 %". Gedacht war er als Warnung vor einer Lücke, gelesen wurde er als Vergleich -- und als Vergleich taugt er nicht. Die gepflegte Last ist ein Fachurteil über einen Stoff, die Interaktionsmatrix eine Rechnung über Wege im Graphen; sie beantworten zwei verschiedene Fragen und haben keine gemeinsame Skala, auf der 91 gegen 19 etwas hieße. Zwei Zahlen nebeneinander behaupten aber immer, vergleichbar zu sein. Die Daten bleiben: `daten.datenlage` steht weiter im Report, und `werkzeuge/_last_vergleich.py` misst den Zusammenhang dort, wo er hingehört -- als Rangkorrelation über viele Stacks und nicht als zwei Prozentzahlen unter einem einzelnen. -------------------------------------------------------------------------- */ function StackZusammenspiel({ daten }) { const [index, setIndex] = useState([]); useEffect(() => { let aktiv = true; BB.loadIndex().then((i) => { const liste = Array.isArray(i) ? i : []; ZSP_INDEX.wert = liste; if (aktiv) setIndex(liste); }); return () => { aktiv = false; }; }, []); if (!daten || !Array.isArray(daten.faktoren)) return null; const faktoren = daten.faktoren; // Beschriftung je Stoff steht einmal in `stoffe`; die Tabellen tragen nur // den Slug, damit der gespeicherte Report nicht dreimal dasselbe Wort führt. const namen = {}; (daten.stoffe || []).forEach((s) => { namen[s.slug] = s.label; }); return (
    {BB.t("Interaktionsmatrix")}

    {BB.t("Gerechnete Graphhypothese: kein klinischer Befund.")}{" "} {BB.t("Die Faktoren entstehen aus gemessenen Bindungen, gerichteten Signalbeziehungen und der gepflegten Wegtabelle. Sie fließen in den Hauptscore ein; ihre Konfidenz wird separat angezeigt.")}

    {/* Die Konfidenz steht neben der Zahl und nie darin. Sonst lässt sich nicht mehr unterscheiden, ob der Stack ungünstig ist oder die Datenlage dünn, zwei sehr verschiedene Aussagen. */}
    {BB.t("Konfidenz")} {Math.round((daten.konfidenz || 0) * 100)} %
    {!faktoren.length ? (

    {BB.t(daten.matrixHinweis || "Keine bewertbaren Wege in der Interaktionsmatrix.")}

    ) : ( <> {faktoren.map((faktor) => ( ))} {/* Was ausgelassen wurde, gehört genauso hierher wie was gefunden wurde. Eine Wechselwirkung, die niemand geprüft hat, ist keine Wechselwirkung, die es nicht gibt. */} {daten.paareOhneRichtung > 0 && (

    {BB.num(daten.paareOhneRichtung)}{" "} {BB.t("Stoffpaare treffen sich an mindestens einer Stelle, ohne dass die Wirkrichtung dort belegt ist. Sie sind nicht bewertet.")} {daten.stellenOhneRichtung > daten.paareOhneRichtung && ( <> {" "}({BB.num(daten.stellenOhneRichtung)} {BB.t("Stellen insgesamt")}) )}

    )} {daten.paareUngleicheWeglaenge > 0 && (

    {BB.num(daten.paareUngleicheWeglaenge)}{" "} {BB.t("Stoffpaare erreichen dasselbe Ziel über verschieden lange Wege. Eine gemessene Bindung und eine mehrstufige Signalkette sind keine zwei Seiten derselben Frage, deshalb steht daraus keine Paaraussage.")}

    )} )}
    ); } /** Der gemeinsame Renderer. Nimmt einen Report nach docs/ANALYSE.md Abschnitt 2. */ function ReportView({ report, premium, animieren, analyseId }) { const wurzel = useRef(null); const flags = report ? report.flags || [] : []; const wertbezogeneAnsicht = report && report.kind === "blood"; const sichtbareFlags = wertbezogeneAnsicht ? flags.filter((finding) => !(finding.evidence && finding.evidence.analyt)) : flags; const sichtbareDimensionen = report && report.dimensions ? report.dimensions.map((dimension) => wertbezogeneAnsicht ? { ...dimension, findings: (dimension.findings || []).filter( (finding) => !(finding.evidence && finding.evidence.analyt) ) } : dimension) : []; const splitBasis = report && report.kind === "split" && report.inputs && report.inputs.trainingseinheiten_pro_woche != null ? report.inputs : null; // Das Ergebnis läuft von unten ein, die Balken versetzt hinterher. Der Ring // bringt seine eigene Animation mit, deshalb ist er hier ausgenommen. useEffect(() => { const el = wurzel.current; if (!el || !animieren || BB.reducedMotion() || typeof anime === "undefined") return; anime.remove(el); anime({ targets: el, opacity: [0, 1], translateY: [14, 0], duration: 460, easing: "easeOutQuad" }); anime({ targets: el.querySelectorAll(".an-dim, .an-block, .an-none"), opacity: [0, 1], translateY: [10, 0], delay: anime.stagger(70, { start: 220 }), duration: 420, easing: "easeOutQuad", }); }, [report, animieren]); if (!report) return null; return (
    {/* Fruehere Fassungen kannten hier einen gesperrten Teaser fuer Nutzer ohne Zugang. Den setzt niemand mehr: eine Auswertung entsteht erst, wenn bezahlt oder ein Token abgebucht wurde, und dann steht sie vollstaendig da. Der Zweig ist entfallen, statt als Bedingung stehen zu bleiben, die nie wahr wird. */} <> {splitBasis && (

    {BB.t("Bewertungsgrundlage")}: Ø {BB.num(splitBasis.trainingseinheiten_pro_woche)} {BB.t("Trainingseinheiten pro Woche")} {splitBasis.zyklus_tage ? ` · ${BB.num(splitBasis.zyklus_tage)} ${BB.t("Tage Zyklus")}` : ""} {splitBasis.bewertungs_tage ? ` · ${BB.num(splitBasis.bewertungs_tage)} ${BB.t("Tage Bewertungszeitraum")}` : ""}

    )} {report.kind === "blood" && } {report.kind === "meal" && } {report.stack_curve && } {report.stack_systeme && } {report.kind === "split" && report.split_visualisierung && ( )} {/* Ein Block statt drei. `stack_pfade`, `stack_wege` und `stack_convergence` bleiben im Report stehen und werden nur nicht mehr gezeichnet: solange die Gewichte hier gesetzt und nicht kalibriert sind, soll sich eine gespeicherte Auswertung noch gegen die alte Lesart halten lassen. Die Matrix steht seit dem 2026-09-28 vor den Einzelbefunden: sie ist die Rechnung, die Befunde darunter sind ihre Sätze. */} {report.stack_zusammenspiel && } {sichtbareFlags.length > 0 && (
    {BB.t("Übergreifend")}
      {sichtbareFlags.map((f, i) => ( ))}
    )} {sichtbareFlags.length === 0 && sichtbareDimensionen.every((d) => !d.findings.length) && (

    {BB.t("Keine Auffälligkeiten. Alle Dimensionen liegen im Rahmen.")}

    )} {/* Der Stack als Karte: Stoffe, die Wege der Matrix und die Analyse-Endpunkte. Nur mit gespeicherter Auswertung, denn der Server rechnet die Ansicht aus deren Eingabe. */} {report.kind === "stack" && analyseId && report.stack_zusammenspiel && (
    {BB.t("In der Molekülkarte ansehen")} {BB.t("Zeigt den Stack mit den Wegen der Interaktionsmatrix bis zu den Analyse-Endpunkten.")}
    )} {/* **Die Liste stand hier ein zweites Mal.** Jede der vier Analysearten legt zu nicht zugeordneten Angaben schon einen Befund an, und der nennt sie beim Namen und sagt dazu, was sie für die Konfidenz bedeuten. Darunter dieselben vier Wörter noch einmal, ohne den Satz -- das las sich wie ein zweiter, ärmerer Befund. `report.unknown` bleibt im Report stehen, es trägt die Liste für alles, was sie auswertet. */}
    ); } /* --- Eingabe: Split ------------------------------------------------------ */ /* --- Eingabe: Split ------------------------------------------------------ */ function leereZeile() { return { muskel: "", saetze: "", indirekt: "", wdh: "", rir: "" }; } const MAX_SPLIT_TAGE = 7; const DEFAULT_SPLIT_TAGE = 1; function leererTag(n, ruhe) { return { tag: n, name: "", ruhetag: !!ruhe, muskeln: [leereZeile()] }; } /** Ein Zyklus aus `n` Tagen. Die Eingabe bleibt auf sieben Tage begrenzt, weil fast jeder Plan in Wochen denkt. Die Ruhetage gehören mit in den Plan: aus ihnen ergeben sich die Abstände zwischen den Einheiten, sie müssen nicht geraten werden. */ function leererZyklus(n) { const tage = []; const anzahl = Math.min(MAX_SPLIT_TAGE, Math.max(0, Number(n) || 0)); for (let i = 1; i <= anzahl; i++) tage.push(leererTag(i, i > 3)); return tage; } /** Aus einer gespeicherten Eingabe wieder ein Formularzustand. Damit lässt sich ein Split erneut aufrufen und ändern, statt ihn abzutippen. */ function ausEingabe(eingabe) { const tage = Array.isArray(eingabe.tage) ? eingabe.tage.slice(0, MAX_SPLIT_TAGE) : []; return { ziel: eingabe.ziel || "hypertrophie", status: eingabe.status || "fortgeschritten", wiederholen: eingabe.wiederholen !== false, tage: tage.map((t, i) => ({ tag: t.tag || i + 1, name: t.name || "", ruhetag: !!t.ruhetag, muskeln: (Array.isArray(t.muskeln) && t.muskeln.length ? t.muskeln : [leereZeile()]) .map((m) => ({ muskel: m.muskel || "", saetze: m.saetze === undefined || m.saetze === null ? "" : String(m.saetze), indirekt: m.indirekt ? String(m.indirekt) : "", wdh: m.wdh === undefined || m.wdh === null ? "" : String(m.wdh), rir: m.rir === undefined || m.rir === null ? "" : String(m.rir), })), })), }; } /** Die Eingabe und die Live-Vorschau muessen exakt denselben Plan sehen. Ein wiederholtes Muster bleibt deshalb ein echter kurzer Zyklus. Ohne Wiederholung werden die noch nicht angelegten Wochentage als Ruhetage an den Server geschickt und der Zyklus fest auf sieben Tage gesetzt. */ function splitTagFuerApi(tag, index) { if (!tag || tag.ruhetag) return { tag: index + 1, ruhetag: true }; return { tag: index + 1, name: tag.name || "", muskeln: (tag.muskeln || []) .filter((m) => m.muskel && (Number(m.saetze) || Number(m.indirekt))) .map((m) => ({ muskel: m.muskel, saetze: Number(m.saetze) || 0, indirekt: Number(m.indirekt) || 0, wdh: m.wdh || String(WDH_VORGABE), rir: m.rir === "" || m.rir === undefined || m.rir === null ? String(RIR_VORGABE) : m.rir, })), }; } function splitDatenFuerApi(ziel, status, tage, wiederholen) { const basis = (tage || []).slice(0, MAX_SPLIT_TAGE); if (!basis.length) basis.push(leererTag(1, true)); if (!wiederholen) { while (basis.length < MAX_SPLIT_TAGE) { basis.push(leererTag(basis.length + 1, true)); } } return { ziel, status, wiederholen: !!wiederholen, zyklus_tage: wiederholen ? basis.length : MAX_SPLIT_TAGE, tage: basis.map(splitTagFuerApi), }; } /** Die Muskelzeilen eines Tages, aufbereitet fuer die Anzeige. Die Kalender karte zeigt davon nur die Namen, die aufgeklappte Ansicht auch die Zahlen. */ function wochenTagMuskeln(tag) { if (tag.ruhetag) return []; return (tag.muskeln || []) .filter((m) => m.muskel && (Number(m.saetze) || Number(m.indirekt))) .map((m) => ({ id: m.muskel, nur: !Number(m.saetze), saetze: Number(m.saetze) || 0, indirekt: Number(m.indirekt) || 0, wdh: m.wdh || String(WDH_VORGABE), rir: m.rir === "" || m.rir === undefined || m.rir === null ? String(RIR_VORGABE) : String(m.rir), })); } /** `ausfuehrlich` schaltet von den blossen Muskelnamen auf Saetze, Wiederholungen und RIR um. Die Karte im Kalender bleibt ruhig, die geoeffnete Ansicht ist der Ort fuer die Zahlen: dort ist Platz und dort sucht man sie. */ function WochenTagDetails({ tag, muskeln, labelFuer, nameAnzeigen = true, ausfuehrlich = false }) { if (tag.ruhetag) return
    {BB.t("Ruhetag")}
    ; if (ausfuehrlich) { return ( <> {nameAnzeigen && tag.name &&
    {tag.name}
    } {muskeln.length === 0 ? (
    -
    ) : (
    {BB.t("Muskel")} {BB.t("Sätze")} {BB.t("Wdh.")} {BB.t("RIR")}
    {muskeln.map((m, i) => (
    {labelFuer(m.id)} {m.saetze} {m.indirekt > 0 && + {m.indirekt}} {m.wdh} {m.rir}
    ))} {muskeln.some((m) => m.indirekt > 0) && (

    {BB.t("Die zweite Zahl bezeichnet indirekte Sätze.")}

    )}
    )} ); } return ( <> {nameAnzeigen && tag.name &&
    {tag.name}
    } {muskeln.length === 0 ? (
    -
    ) : (
    {muskeln.map((m, i) => ( {labelFuer(m.id)} ))}
    )} ); } /** Der Zyklus als Kalender: eine ganze Woche ab Montag, plus je ein angedeuteter Tag am Rand. Ein Plan liest sich als Liste anders als als Woche. Erst im Kalender sieht man, dass zwei harte Tage aufeinanderfolgen oder dass der Zyklus quer zur Woche läuft. Die sieben hellen Karten sind die Woche selbst, die beiden blassen am Rand sind der Sonntag davor und der Montag danach: kein zusätzliches Training, sondern der Anschluss. Der Zyklus muss dafür nicht sieben Tage lang sein. Er wird ab Montag durchlaufend eingesetzt, ein Kern aus einem Trainings und einem Ruhetag ergibt also die abwechselnde Woche. Genau so rechnet auch der Server. Bewusst nur die Muskelnamen, keine Sätze und keine Wiederholungen. Der Kalender beantwortet die Frage "wann was", die Zahlen stehen darunter. */ const WOCHENTAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]; /** Rest, der auch für negative Zähler im Bereich bleibt. `-1 % 7` ist in JavaScript `-1` und nicht `6`, und der Tag vor Montag ist genau dieser Fall. */ function wkModulo(n, m) { return ((n % m) + m) % m; } function SplitWoche({ tage, labelFuer }) { const zyklus = tage.length; const [ausgewaehlt, setAusgewaehlt] = useState(null); const schliessen = useRef(null); /* Position 0 ist Montag, 6 ist Sonntag. Die Positionen -1 und 7 sind die Randtage: derselbe durchlaufende Zyklus, nur eine Woche versetzt. */ const karten = []; for (let pos = -1; pos <= 7; pos++) { const zi = zyklus ? wkModulo(pos, zyklus) : 0; karten.push({ pos, tag: tage[zi], zyklusTag: zi + 1, wochentag: WOCHENTAGE[wkModulo(pos, 7)], rand: pos < 0 ? "vor" : pos > 6 ? "nach" : null, }); } useEffect(() => { if (!ausgewaehlt) return; const vorherigesElement = document.activeElement; const vorherigerOverflow = document.body.style.overflow; const vorherigesPadding = document.body.style.paddingRight; const leiste = window.innerWidth - document.documentElement.clientWidth; document.body.style.overflow = "hidden"; if (leiste > 0) document.body.style.paddingRight = leiste + "px"; const onKey = (e) => { if (e.key === "Escape") setAusgewaehlt(null); }; window.addEventListener("keydown", onKey); if (schliessen.current) schliessen.current.focus(); return () => { document.body.style.overflow = vorherigerOverflow; document.body.style.paddingRight = vorherigesPadding; window.removeEventListener("keydown", onKey); if (vorherigesElement && typeof vorherigesElement.focus === "function") { vorherigesElement.focus({ preventScroll: true }); } }; }, [ausgewaehlt]); if (!zyklus) return null; return ( <>
    {BB.t("Wochenansicht")}
    {karten.map((k) => { const muskeln = wochenTagMuskeln(k.tag); return ( ); })}
    {ausgewaehlt && ReactDOM.createPortal(
    { if (e.target === e.currentTarget) setAusgewaehlt(null); }} >
    {BB.t(ausgewaehlt.wochentag)}

    {ausgewaehlt.tag.name || (ausgewaehlt.tag.ruhetag ? BB.t("Ruhetag") : BB.t("Trainingstag"))}

    {BB.t("Tag")} {ausgewaehlt.zyklusTag} {ausgewaehlt.rand && ( · {BB.t("Anschlusstag")} )}

    {BB.t("Klick oder Escape schließt")}

    , document.body )} ); } /** Eine sichtbare Kalenderposition und der Tag im zugrunde liegenden Muster. Die Woche ist immer sieben Tage breit; nur ihre Quelle unterscheidet sich: entweder ein fortlaufender kurzer Rhythmus oder eine feste Kalenderwoche. */ function splitKarteFuerPosition(tage, wiederholen, position) { const liste = tage && tage.length ? tage : [leererTag(1, true)]; if (wiederholen) { const quellIndex = wkModulo(position, liste.length); return { position, tag: liste[quellIndex], quellIndex, musterTag: quellIndex + 1, wiederholt: position < 0 || position >= liste.length, }; } const wochenIndex = wkModulo(position, MAX_SPLIT_TAGE); const vorhanden = wochenIndex < liste.length; return { position, tag: vorhanden ? liste[wochenIndex] : leererTag(wochenIndex + 1, true), quellIndex: vorhanden ? wochenIndex : null, musterTag: wochenIndex + 1, wiederholt: false, }; } function SplitDaySwitch({ ruhetag, onChange, kompakt = false }) { return ( ); } function SplitWeekCard({ karte, aktiv, wiederholen, musterLaenge, labelFuer, onAktiv, onTag, onUebung }) { const tag = karte.tag; const muskeln = wochenTagMuskeln(tag); const sichtbar = muskeln.slice(0, 4); const gesamt = muskeln.reduce((summe, muskel) => summe + muskel.saetze, 0); const wochentag = BB.t(WOCHENTAGE[karte.position]); return (
    onAktiv()} onFocusCapture={() => onAktiv()} >
    onTag({ ruhetag: ruhe })} />
    {!tag.ruhetag ? ( <> onTag({ name: e.target.value })} onClick={(e) => e.stopPropagation()} placeholder={BB.t("z. B. Push")} aria-label={BB.t("Name der Einheit") + ": " + wochentag} />
    {sichtbar.length ? sichtbar.map((m) => ( {labelFuer(m.id)} )) : {BB.t("Noch keine Übung")}} {muskeln.length > sichtbar.length && ( +{muskeln.length - sichtbar.length} )}
    {gesamt ? gesamt + " " + BB.t("direkte Sätze") : BB.t("Trainingstag")}
    ) : (
    {BB.t("Regeneration")}
    )}
    ); } function SplitEdgeDay({ karte, seite, labelFuer }) { const tag = karte.tag; const muskeln = wochenTagMuskeln(tag).slice(0, 2); return ( ); } function SplitDayEditor({ karte, wiederholen, musterLaenge, gruppen, uebungen, labelFuer, onTag, onZeile, onUebung, onEntfernen, onVerschieben, katalogOffen, setKatalogOffen }) { const tag = karte.tag; const [uebungsSaetze, setUebungsSaetze] = useState("3"); const zeilen = tag.muskeln && tag.muskeln.length ? tag.muskeln : [leereZeile()]; const label = BB.t(WOCHENTAGE[karte.position]); const istWiederholung = wiederholen && musterLaenge < MAX_SPLIT_TAGE; return (
    {BB.t("Ausgewählter Tag")} {istWiederholung && ( · {BB.t("Muster")} {karte.musterTag} )}

    {label} · {tag.ruhetag ? BB.t("Ruhetag") : (tag.name || BB.t("Trainingstag"))}

    {istWiederholung && (

    {BB.t("Änderungen an diesem Mustertag gelten für alle Wiederholungen.")}

    )}
    onTag({ ruhetag: ruhe })} /> {wiederholen && musterLaenge > 1 && ( <> )}
    {tag.ruhetag ? (
    {BB.t("Geplanter Ruhetag")}

    {BB.t("Wechsle auf Training oder füge direkt eine Übung hinzu.")}

    ) : (
    {BB.t("Einheit")} onTag({ name: e.target.value })} placeholder={BB.t("Name der Einheit, z. B. Push")} />
    {uebungen.length > 0 && (
    )}
    {zeilen.map((m, zeilenIndex) => (
    ))}

    {BB.t("Direkte und indirekte Sätze bleiben nach der Übungsauswahl frei anpassbar.")}

    )} {katalogOffen && ( { onUebung(uid, uebungsSaetze); setKatalogOffen(false); }} onClose={() => setKatalogOffen(false)} /> )}
    ); } function splitLobenPfad(von, bis, sichtbarBis, peak, richtung, x, y) { if (!(bis > von) || !(sichtbarBis > von)) return null; const ende = Math.min(bis, sichtbarBis); const schritte = Math.max(6, Math.ceil((ende - von) / 3)); const punkte = []; for (let i = 0; i <= schritte; i++) { const stunde = von + (ende - von) * i / schritte; const phase = Math.max(0, Math.min(1, (stunde - von) / (bis - von))); const wert = richtung * peak * Math.pow(Math.sin(Math.PI * phase), 0.82); punkte.push({ stunde, wert }); } const linie = punkte.map((punkt, index) => (index ? "L" : "M") + x(punkt.stunde).toFixed(1) + " " + y(punkt.wert).toFixed(1) ).join(" "); const basis = y(0).toFixed(1); const flaeche = linie + " L" + x(ende).toFixed(1) + " " + basis + " L" + x(von).toFixed(1) + " " + basis + " Z"; return { linie, flaeche }; } function splitAbbauFortschritt(abstand, abbauDauer) { const dauer = Math.max(24, abbauDauer); const ersterTag = Math.min(24, dauer / 4); const halbzeit = dauer / 2; if (abstand <= ersterTag) return 0; if (halbzeit <= ersterTag) return Math.min(1, abstand / dauer); if (abstand <= halbzeit) { return 0.5 * (abstand - ersterTag) / (halbzeit - ersterTag); } return Math.min(1, 0.5 + 0.5 * (abstand - halbzeit) / (dauer - halbzeit)); } function splitRecoveryPfade(muskel, zeitraum, abbauZeitraum, x, y) { const ereignisse = (muskel.einheiten || []) .filter((e) => e.start_h < zeitraum) .slice() .sort((a, b) => a.start_h - b.start_h); return ereignisse.map((ereignis, index) => { const start = Number(ereignis.start_h) || 0; const abstand = Math.max(24, Number(ereignis.abstand_h) || 24); const naechsterStart = start + abstand; const sichtbarBis = Math.min(zeitraum, naechsterStart); const mpsDauer = Math.max(1, Number(ereignis.mps_h) || 1); const mpsEnde = start + mpsDauer; const erholt = start + Math.max(1, Number(ereignis.regeneration_h) || 1); const reizPeak = Math.max(12, Math.min(100, Number(ereignis.reiz_peak_pct) || 50)); const ermuedungPeak = Math.max(12, Math.min(100, Number(ereignis.ermuedung_peak_pct) || 50)); const reizluecke = Math.max(0, naechsterStart - mpsEnde); const abbauDauer = Math.max(mpsDauer, abbauZeitraum); // Der erste Tag bleibt auf der Basislinie. Danach steigt das Schema mit // dem Abstand sichtbar an: 50 % nach sieben, 100 % nach vierzehn Tagen. const abbauFortschritt = splitAbbauFortschritt(abstand, abbauDauer); const abbauPeak = reizPeak * abbauFortschritt; const abbauEnde = Math.min(mpsEnde + abbauDauer, sichtbarBis); return { index, ereignis, aufbau: splitLobenPfad(start, mpsEnde, sichtbarBis, reizPeak, 1, x, y), ermuedung: splitLobenPfad(start, erholt, sichtbarBis, ermuedungPeak, -1, x, y), abbau: reizluecke > 0 ? splitLobenPfad(mpsEnde, abbauEnde, abbauEnde, abbauPeak, -1, x, y) : null, }; }); } function SplitRecoveryGraph({ muskel, daten }) { const zeitraum = Number(daten.zeitraum_h) || 168; const abbauZeitraum = Math.max(24, Number(daten.abbau_zeitraum_h) || 336); const abbauTage = abbauZeitraum / 24; const breite = 960; const hoehe = 390; const rand = { links: 88, rechts: 18, oben: 38, unten: 54 }; const innenBreite = breite - rand.links - rand.rechts; const innenHoehe = hoehe - rand.oben - rand.unten; const x = (stunde) => rand.links + Math.max(0, Math.min(zeitraum, stunde)) / zeitraum * innenBreite; const y = (wert) => rand.oben + (100 - Math.max(-100, Math.min(100, wert))) / 200 * innenHoehe; const pfade = splitRecoveryPfade(muskel, zeitraum, abbauZeitraum, x, y); const tage = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So", "Mo"]; const statusLabel = { anfaenger: BB.t("Anfänger"), fortgeschritten: BB.t("Fortgeschritten"), erfahren: BB.t("Erfahren"), }[daten.trainingsstand] || BB.t("Fortgeschritten"); const achsen = [ { wert: 100, label: BB.t("Aufbau") }, { wert: 0, label: BB.t("Basislinie") }, { wert: -100, label: BB.t("Belastung") }, ]; return (
    {BB.t("Trainingsstand")}: {statusLabel} {BB.t("MPS-Fenster")}: ≈ {BB.num(daten.mps_stunden)} h {BB.t("Erholungsfaktor")}: {BB.num(daten.trainingsstand_faktor)}× {BB.t("Abbau-Schema")}: ≈ {BB.num(abbauTage)} {BB.t("Tage")}
    {achsen.map((achse) => ( {achse.label} ))} {tage.map((tag, index) => { const stunde = index * 24; return ( {BB.t(tag)} ); })} {(muskel.einheiten || []).filter((e) => e.start_h < zeitraum).map((e, index) => ( T{index + 1} ))}
      {(muskel.einheiten || []).map((e, index) => { const reserve = Number(e.reserve_h) || 0; const reizluecke = Number(e.reizluecke_h) || 0; const statusText = reserve < 0 ? BB.t("{n} h vor Erholung").replace("{n}", BB.num(Math.abs(reserve))) : reserve >= 1 ? BB.t("{n} h nach Erholung").replace("{n}", BB.num(reserve)) : BB.t("Passend erholt"); return (
    1. T{index + 1} {BB.t(WOCHENTAGE[wkModulo(e.tag - 1, 7)])} · {e.label} {BB.t("{n} effektive Sätze").replace("{n}", BB.num(e.effektiv))} {" · "}{BB.t("Aufbauimpuls ≈ {n} h").replace("{n}", BB.num(e.mps_h))} {" · "}{BB.t("erholt nach ≈ {n} h").replace("{n}", BB.num(e.regeneration_h))} {reizluecke > 0 && ( <> {" · "}{BB.t("{n} h ohne neuen Reiz").replace("{n}", BB.num(reizluecke))} )} {statusText}
    2. ); })}
    ); } function SplitVolumePanel({ muskel, band }) { const wert = Number(muskel.effektiv_woche) || 0; const ziel = Number(band.opt_bis) || 1; const maximum = Math.max(Number(band.max) || ziel, wert); const radius = 48; const umfang = 2 * Math.PI * radius; const anteil = Math.max(0, Math.min(1, wert / ziel)); const ton = wert < band.opt_von ? "unter" : wert <= band.opt_bis ? "optimal" : "ueber"; const statusText = ton === "unter" ? BB.t("Noch {n} effektive Sätze bis zum Optimalbereich.") .replace("{n}", BB.num(Math.max(0, band.opt_von - wert))) : ton === "optimal" ? BB.t("Im modellierten Optimalbereich.") : BB.t("{n} effektive Sätze über dem Optimalbereich.") .replace("{n}", BB.num(Math.max(0, wert - band.opt_bis))); const marker = Math.min(100, wert / maximum * 100); const optStart = band.opt_von / maximum * 100; const optBreite = (band.opt_bis - band.opt_von) / maximum * 100; return (
    {BB.num(wert)} / {BB.num(ziel)}
    {BB.t("Effektives Wochenvolumen")}

    {muskel.label}

    {statusText}

    0 {BB.t("optimal")} {BB.num(band.opt_von)}–{BB.num(band.opt_bis)} {BB.num(maximum)}
    ); } const SPLIT_BODY_LABELS = { brust: "Brust", lat: "Latissimus", "oberer-ruecken": "Oberer Rücken", "unterer-ruecken": "Unterer Rücken", "schulter-vorne": "Vordere Schulter", "schulter-seite": "Seitliche Schulter", "schulter-hinten": "Hintere Schulter", bizeps: "Bizeps", trizeps: "Trizeps", unterarm: "Unterarm", quadrizeps: "Quadrizeps", beinbeuger: "Beinbeuger", gluteus: "Gesäß", waden: "Waden", bauch: "Bauch", nacken: "Nacken", }; function splitBodyWert(daten, id) { const muskel = (daten.muskeln || []).find((eintrag) => eintrag.id === id); return muskel ? Number(muskel.effektiv_woche) || 0 : 0; } function splitBodyFarbe(daten, id) { const wert = splitBodyWert(daten, id); const band = daten.volumen_band || {}; const von = Math.max(0, Number(band.opt_von) || 0); const bis = Math.max(von, Number(band.opt_bis) || von); const maximum = Math.max(bis + 0.01, Number(band.max) || bis); // Unter- und Überversorgung laufen jeweils von Rot über Gelb nach Grün. // Innerhalb des modellierten Optimalbereichs bleibt die Farbe stabil grün. if (wert < von) { const anteil = von > 0 ? Math.max(0, Math.min(1, wert / von)) : 1; return "hsl(" + Math.round(8 + anteil * 112) + " 74% 54%)"; } if (wert <= bis) return "hsl(132 62% 46%)"; const ueber = Math.max(0, Math.min(1, (wert - bis) / (maximum - bis))); return "hsl(" + Math.round(132 - ueber * 124) + " 74% 54%)"; } function SplitBodyRegion({ daten, id, children }) { const label = SPLIT_BODY_LABELS[id] || id; const wert = splitBodyWert(daten, id); return ( {label}: {BB.num(wert)} {BB.t("effektive Sätze pro Woche")} {children} ); } function SplitBodyIcon({ daten, seite }) { const modell = (window.BB_SPLIT_BODY_DATA || {})[seite]; const vorne = seite === "vorne"; if (!modell) return null; return (
    {Object.entries(modell.regions).map(([id, pfade]) => ( {pfade.map((pfad, index) => ( ))} ))} {vorne ? BB.t("Vorne") : BB.t("Hinten")}
    ); } function SplitBodyFigure({ daten }) { if (!daten || !daten.volumen_band || !Array.isArray(daten.muskeln)) return null; return (
    {BB.t("Muskelstatus")}
    {BB.t("Wochenvolumen")}
    {BB.t("zu wenig")} {BB.t("optimal")} {BB.t("zu viel")}
    ); } function SplitInsights({ daten, laedt = false, fehler = "", bericht = false }) { const muskeln = daten && Array.isArray(daten.muskeln) ? daten.muskeln : []; const [muskelId, setMuskelId] = useState(""); useEffect(() => { if (!muskeln.length) { setMuskelId(""); return; } if (!muskeln.some((m) => m.id === muskelId)) { const bevorzugt = muskeln.find((m) => m.kern) || muskeln[0]; setMuskelId(bevorzugt.id); } }, [daten, muskelId]); const muskel = muskeln.find((m) => m.id === muskelId) || muskeln[0]; return (
    {BB.t("Planwirkung")}

    {BB.t("Reiz, Ermüdung und Wochenvolumen")}

    {BB.t("Satzmenge und Trainingsstand bestimmen die modellierten Zeitfenster.")}

    {muskeln.length > 0 && ( )}
    {fehler && muskel &&

    {fehler}

    } {!muskel ? (
    {laedt ? BB.t("Analyse wird berechnet …") : BB.t("Keine Belastung für den Graphen")}

    {fehler || BB.t("Für diesen Plan wurden keine Muskel-Sätze berechnet.")}

    ) : (

    {daten.hinweis}

    )}
    ); } function SplitWorkspace({ tage, wiederholen, onWiederholen, gruppen, uebungen, labelFuer, onTag, onZeile, onUebung, onTagHinzufuegen, onTagEntfernen, onTagVerschieben }) { const [aktivePosition, setAktivePosition] = useState(0); const [katalogOffen, setKatalogOffen] = useState(false); const karten = Array.from({ length: MAX_SPLIT_TAGE }, (_, position) => splitKarteFuerPosition(tage, wiederholen, position)); const aktiv = splitKarteFuerPosition(tage, wiederholen, aktivePosition); function uebungenOeffnen(position) { const karte = splitKarteFuerPosition(tage, wiederholen, position); setAktivePosition(position); if (karte.tag.ruhetag) onTag(position, { ruhetag: false }); setKatalogOffen(true); } return (
    {BB.t("Wochenplan")}

    {BB.t("Deine Trainingswoche")}

    {BB.t("Tag anklicken und direkt darunter bearbeiten.")}

    {karten.map((karte) => ( setAktivePosition(karte.position)} onTag={(patch) => onTag(karte.position, patch)} onUebung={() => uebungenOeffnen(karte.position)} /> ))}
    {tage.length < MAX_SPLIT_TAGE && ( )}
    onTag(aktivePosition, patch)} onZeile={(zeile, patch) => onZeile(aktivePosition, zeile, patch)} onUebung={(uid, saetze) => onUebung(aktivePosition, uid, saetze)} onEntfernen={() => onTagEntfernen(aktivePosition)} onVerschieben={(richtung) => onTagVerschieben(aktivePosition, richtung)} katalogOffen={katalogOffen} setKatalogOffen={setKatalogOffen} />
    ); } function SplitForm({ katalog, onReport, onBusy, onRequest = (aktion) => aktion(), vorgabe, titelVorgabe }) { const start = vorgabe ? ausEingabe(vorgabe) : null; const [ziel, setZiel] = useState(start ? start.ziel : "hypertrophie"); const [status, setStatus] = useState(start ? start.status : "fortgeschritten"); const [titel, setTitel] = useState(titelVorgabe || ""); const [tage, setTage] = useState(start && start.tage.length ? start.tage.slice(0, MAX_SPLIT_TAGE) : leererZyklus(DEFAULT_SPLIT_TAGE)); const [wiederholen, setWiederholen] = useState(start ? start.wiederholen : true); const [fehler, setFehler] = useState(""); const toast = useToast(); function nummeriert(liste) { return liste.map((tag, index) => ({ ...tag, tag: index + 1 })); } function indexFuerPosition(position, liste) { if (wiederholen && liste.length) return wkModulo(position, liste.length); return Math.max(0, Math.min(MAX_SPLIT_TAGE - 1, position)); } function bisPositionAuffuellen(liste, index) { const neu = liste.slice(); while (neu.length <= index && neu.length < MAX_SPLIT_TAGE) { neu.push(leererTag(neu.length + 1, true)); } return neu; } function setzePosition(position, patch) { setTage((alt) => { const index = indexFuerPosition(position, alt); const neu = bisPositionAuffuellen(alt, index); const bisher = neu[index] || leererTag(index + 1, true); neu[index] = { ...bisher, muskeln: bisher.muskeln && bisher.muskeln.length ? bisher.muskeln : [leereZeile()], ...patch, }; return nummeriert(neu); }); } function setzeZeilePosition(position, zeilenIndex, patch) { setTage((alt) => { const index = indexFuerPosition(position, alt); const neu = bisPositionAuffuellen(alt, index); const bisher = neu[index] || leererTag(index + 1, false); const zeilen = (bisher.muskeln && bisher.muskeln.length ? bisher.muskeln : [leereZeile()]).map((zeile, i) => i === zeilenIndex ? { ...zeile, ...patch } : { ...zeile }); neu[index] = { ...bisher, ruhetag: false, muskeln: zeilen }; return nummeriert(neu); }); } function verschiebePosition(position, richtung) { setTage((alt) => { const von = indexFuerPosition(position, alt); const nach = von + richtung; if (von < 0 || nach < 0 || von >= alt.length || nach >= alt.length) return alt; const neu = alt.slice(); neu.splice(nach, 0, neu.splice(von, 1)[0]); return nummeriert(neu); }); } function entfernePosition(position) { setTage((alt) => { const index = indexFuerPosition(position, alt); if (index < 0 || index >= alt.length) return alt; if (!wiederholen) { const neu = alt.slice(); neu[index] = leererTag(index + 1, true); while (neu.length > 1) { const letzter = neu[neu.length - 1]; const hatInhalt = !letzter.ruhetag || (letzter.name || "").trim() || (letzter.muskeln || []).some((m) => m.muskel && (Number(m.saetze) || Number(m.indirekt))); if (hatInhalt) break; neu.pop(); } return nummeriert(neu); } if (alt.length <= 1) return alt; return nummeriert(alt.filter((_, i) => i !== index)); }); } /** Eine Übung füllt die Muskelzeilen vor: Hauptbeweger als direkte Sätze, Mitarbeiter als indirekte. Danach ist alles von Hand änderbar, gerechnet wird immer mit den Muskelzeilen. */ function uebungHinzu(position, uid, saetze) { const u = (katalog.uebungen || []).find((x) => x.id === uid); if (!u) return; const n = Math.max(1, Number(saetze) || 3); setTage((alt) => { const index = indexFuerPosition(position, alt); const neu = bisPositionAuffuellen(alt, index); const tag = neu[index] || leererTag(index + 1, false); const zeilen = (tag.muskeln || []).filter((m) => m.muskel).map((m) => ({ ...m })); const finde = (mid) => zeilen.find((m) => m.muskel === mid); (u.direkt || []).forEach((mid) => { const vorhanden = finde(mid); if (vorhanden) vorhanden.saetze = String((Number(vorhanden.saetze) || 0) + n); else zeilen.push({ ...leereZeile(), muskel: mid, saetze: String(n) }); }); (u.indirekt || []).forEach((mid) => { const vorhanden = finde(mid); if (vorhanden) vorhanden.indirekt = String((Number(vorhanden.indirekt) || 0) + n); else zeilen.push({ ...leereZeile(), muskel: mid, indirekt: String(n) }); }); neu[index] = { ...tag, ruhetag: false, muskeln: zeilen.length ? zeilen : [leereZeile()], }; return nummeriert(neu); }); } function absenden(e) { e.preventDefault(); const daten = splitDatenFuerApi(ziel, status, tage, wiederholen); if (!daten.tage.some((tag) => tag.muskeln && tag.muskeln.length)) { setFehler(BB.t("Trag mindestens einen Muskel mit Sätzen ein.")); return; } setFehler(""); onRequest(() => { onBusy(true); BB.post("/auswertung/split", { title: titel.trim(), data: daten, }) .then((d) => onReport(d)) .catch((err) => toast(err.message || BB.t("Das hat nicht geklappt."), "error")) .finally(() => onBusy(false)); }); } const gruppen = {}; (katalog.muskeln || []).forEach((m) => { (gruppen[m.gruppe] = gruppen[m.gruppe] || []).push(m); }); const labelFuer = (id) => { const m = (katalog.muskeln || []).find((x) => x.id === id); return m ? BB.t(m.label) : id; }; return (
    setTitel(e.target.value)} placeholder={BB.t("z. B. Push Pull Legs")} />
    setTage((alt) => alt.length >= MAX_SPLIT_TAGE ? alt : nummeriert(alt.concat(leererTag(alt.length + 1, false))))} onTagEntfernen={entfernePosition} onTagVerschieben={verschiebePosition} /> {fehler &&

    {fehler}

    }

    {BB.t("Die vollständige Analyse wird erst mit Auswerten gespeichert und berechnet.")}

    {BB.t("Indirekte Sätze zählen im Modell anteilig. Regeneration und MPS sind Modellwerte zur Einordnung, keine medizinische Messung.")}

    ); } const EXERCISE_CATEGORY_LABELS = { back: "Rücken", cardio: "Cardio", chest: "Brust", "lower arms": "Unterarme", "lower legs": "Unterschenkel", neck: "Nacken", shoulders: "Schultern", "upper arms": "Oberarme", "upper legs": "Oberschenkel", waist: "Rumpf", }; const EXERCISE_EQUIPMENT_LABELS = { assisted: "Assistiert", band: "Band", barbell: "Langhantel", "body weight": "Körpergewicht", cable: "Kabelzug", dumbbell: "Kurzhantel", "ez barbell": "SZ-Stange", kettlebell: "Kettlebell", "leverage machine": "Hebelmaschine", "medicine ball": "Medizinball", "olympic barbell": "Olympia-Langhantel", "resistance band": "Widerstandsband", roller: "Roller", rope: "Seil", "smith machine": "Smith Machine", "stability ball": "Gymnastikball", "sled machine": "Schlittenmaschine", "skierg machine": "SkiErg", "trap bar": "Trap Bar", weighted: "Zusatzgewicht", "wheel roller": "Ab-Roller", }; function katalogWertLabel(value, map) { if (!value) return ""; // Uebersetzt wird das deutsche Label aus der Zuordnung. Ein Rohwert ohne // Eintrag bleibt, was er ist: englischer Katalogtext, den zu uebersetzen // eine Liste mit 1500 Uebungsnamen voraussetzte. return map[value] ? BB.t(map[value]) : value.replace(/\b\w/g, (zeichen) => zeichen.toUpperCase()); } /** Der Übungskatalog bleibt ein echtes Auswahlfenster, obwohl die Vorschau vorerst bewusst keine lizenzierten Medien lädt. Suche und Filter laufen lokal, damit die Split-Eingabe nicht für jede Karte einen Request macht. */ function ExerciseCatalog({ uebungen, labelFuer, ausgewaehlt, onSelect, onClose, embedded = false, auswahl = true }) { const [suche, setSuche] = useState(""); const [kategorie, setKategorie] = useState(""); const [geraet, setGeraet] = useState(""); const [sichtbar, setSichtbar] = useState(60); const suchfeld = useRef(null); useEffect(() => { if (embedded) return undefined; const vorherigerOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKey = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", onKey); if (suchfeld.current) suchfeld.current.focus(); return () => { document.body.style.overflow = vorherigerOverflow; window.removeEventListener("keydown", onKey); }; }, [onClose, embedded]); useEffect(() => setSichtbar(60), [suche, kategorie, geraet]); const kategorien = useMemo(() => Array.from(new Set( uebungen.map((u) => u.kategorie).filter(Boolean) )).sort((a, b) => katalogWertLabel(a, EXERCISE_CATEGORY_LABELS) .localeCompare(katalogWertLabel(b, EXERCISE_CATEGORY_LABELS), "de")), [uebungen]); const geraete = useMemo(() => Array.from(new Set( uebungen.map((u) => u.geraet).filter(Boolean) )).sort((a, b) => katalogWertLabel(a, EXERCISE_EQUIPMENT_LABELS) .localeCompare(katalogWertLabel(b, EXERCISE_EQUIPMENT_LABELS), "de")), [uebungen]); const gefiltert = useMemo(() => { const query = suche.trim().toLocaleLowerCase("de-DE"); return uebungen.filter((u) => { if (kategorie && u.kategorie !== kategorie) return false; if (geraet && u.geraet !== geraet) return false; if (!query) return true; const suchtext = [ u.label, u.kategorie, u.koerperteil, u.geraet, u.muskelgruppe, u.ziel, ...(u.nebenmuskeln || []), ].join(" ").toLocaleLowerCase("de-DE"); return suchtext.includes(query); }); }, [uebungen, suche, kategorie, geraet]); const liste = gefiltert.slice(0, sichtbar); const dialog = (
    {!embedded &&
    {BB.t("Auswahl")}

    {BB.t("Übungskatalog")}

    }
    setSuche(e.target.value)} placeholder={BB.t("Übung suchen …")} aria-label={BB.t("Übung suchen …")} />
    {gefiltert.length} {BB.t("Übungen")} {(suche || kategorie || geraet) && {BB.t("Gefiltert")}}
    {liste.length > 0 ? (
    {liste.map((u) => { const direkt = (u.direkt || []).map(labelFuer); const indirekt = (u.indirekt || []).map(labelFuer); const muskelText = direkt.concat(indirekt).join(" · "); return (
    {BB.t("coming soon")}

    {u.label}

    {u.kategorie && {katalogWertLabel(u.kategorie, EXERCISE_CATEGORY_LABELS)}} {u.geraet && {katalogWertLabel(u.geraet, EXERCISE_EQUIPMENT_LABELS)}}
    {u.ziel &&

    {BB.t("Ziel")}: {u.ziel}

    } {muskelText ? (

    {BB.t("Vorschlag")}: {muskelText}

    ) : (

    {BB.t("Keine Muskelzuordnung")}

    )} {(u.anleitungsschritte || []).length > 0 && (
    {BB.t("Anleitung")}
      {u.anleitungsschritte.map((schritt, i) =>
    1. {schritt.replace(/^Step:\d+\s*/i, "")}
    2. )}
    )} {auswahl && ( )}
    ); })}
    ) : (

    {BB.t("Keine Übungen gefunden.")}

    )} {sichtbar < gefiltert.length && ( )}

    {BB.t("Datenquelle: ExerciseDB. Visualisierung: coming soon.")}

    ); if (embedded) return dialog; return ReactDOM.createPortal(
    { if (e.target === e.currentTarget) onClose(); }} > {dialog}
    , document.body ); } /** Ein Tag im Zyklus: entweder Ruhetag oder eine Einheit mit Muskelzeilen. Zugeklappt steht nur, was drinsteht, aufgeklappt die Eingabe. Ein Plan hat schnell sieben Tage, und sieben offene Tabellen untereinander sind keine Uebersicht mehr. Verschoben wird am Griff oder mit den beiden Pfeilen. Die Pfeile sind nicht der Notbehelf: Ziehen gibt es auf dem Telefon und mit der Tastatur nicht. Die Beschriftung haengt an der Position, nicht am Tag. Wer Push und Pull tauscht, hat Push danach am Dienstag, ohne etwas neu zu tippen. */ function TagBlock({ tag, label, gruppen, uebungen, labelFuer, entfernbar, erster, letzter, gezogen, onTag, onZeile, onUebung, onEntfernen, onHoch, onRunter, onZiehStart, onZiehUeber, onZiehEnde }) { const [uebung, setUebung] = useState(""); const [uSaetze, setUSaetze] = useState("3"); const [offen, setOffen] = useState(() => { const hatInhalt = (tag.muskeln || []) .some((m) => m.muskel && (Number(m.saetze) || Number(m.indirekt))); return !tag.ruhetag && !hatInhalt; }); const [ziehbar, setZiehbar] = useState(false); const [katalogOffen, setKatalogOffen] = useState(false); const vorherRuhetag = useRef(tag.ruhetag); const gefuellt = (tag.muskeln || []) .filter((m) => m.muskel && (Number(m.saetze) || Number(m.indirekt))); const saetzeGesamt = gefuellt.reduce((s, m) => s + (Number(m.saetze) || 0), 0); const zeigen = !tag.ruhetag && offen; const ausgewaehlteUebung = uebungen.find((u) => u.id === uebung); useEffect(() => { if (vorherRuhetag.current && !tag.ruhetag) setOffen(true); vorherRuhetag.current = tag.ruhetag; }, [tag.ruhetag]); return (
    { e.dataTransfer.effectAllowed = "move"; onZiehStart(); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; onZiehUeber(); }} onDrop={(e) => { e.preventDefault(); setZiehbar(false); onZiehEnde(); }} onDragEnd={() => { setZiehbar(false); onZiehEnde(); }} >
    {label} onTag({ name: e.target.value })} placeholder={BB.t("Name der Einheit, z. B. Push")} />
    {entfernbar && ( )}
    {!tag.ruhetag && !zeigen && gefuellt.length > 0 && ( )} {zeigen && (
    {uebungen.length > 0 && ( <>
    {ausgewaehlteUebung ? ausgewaehlteUebung.label : BB.t("Keine Übung ausgewählt")} setUSaetze(e.target.value)} aria-label={BB.t("Sätze")} />
    {katalogOffen && ( { setUebung(uid); setKatalogOffen(false); }} onClose={() => setKatalogOffen(false)} /> )} )}
    {BB.t("Muskel")} {BB.t("Direkt")} {BB.t("Indirekt")} {BB.t("Wdh.")} {BB.t("RIR")}
    {tag.muskeln.map((m, zi) => (
    onZeile(zi, { saetze: e.target.value })} /> onZeile(zi, { indirekt: e.target.value })} /> onZeile(zi, { wdh: e.target.value })} /> onZeile(zi, { rir: e.target.value })} />
    ))}

    {BB.t("Die Reihenfolge zählt: was oben steht, wird zuerst trainiert und bekommt den besseren Reiz.")}

    )}
    ); } /* --- Eingabe: Stack ------------------------------------------------------ */ /** Worauf sich ein Dosisbereich bezieht. Seit stack-1.3 traegt `einheit` nur * noch die Stoffmenge, der Zeitraum steht getrennt in `bezugTage`. */ function bezugText(bezugTage) { if (bezugTage == null) return BB.t("je Gabe"); if (bezugTage === 1) return BB.t("pro Tag"); if (bezugTage === 7) return BB.t("pro Woche"); return BB.t("pro {n} Tage").replace("{n}", BB.num(bezugTage)); } /** Der Bereich, wie er in der Auswahlliste und unter dem Feld steht. */ function bereichText(w) { if (!w || w.von == null) return w ? w.weg : ""; const menge = `${BB.num(w.von)}-${BB.num(w.bis)} ${w.einheit}`; const zusatz = w.bezugHinweis ? ` ${w.bezugHinweis}` : ""; return `${menge}${zusatz} ${bezugText(w.bezugTage)}`; } /** Vorbelegung des Intervallfelds: der Bezug des Profils ist der Normalfall. */ function standardIntervall(w) { if (!w) return "1"; if (w.weg === "injektion") return "7"; return String(w.bezugTage == null ? 1 : w.bezugTage); } /** Zahlen aus Eingabefeldern mit deutschem Dezimaltrennzeichen lesen. */ function stackZahl(wert) { if (wert === "" || wert === null || wert === undefined) return null; const text = String(wert).trim().replace(/\s/g, "").replace(",", "."); if (!text) return null; const zahl = Number(text); return Number.isFinite(zahl) ? zahl : null; } /** Interne Ester-ID aus ID oder sichtbarer Bezeichnung wiederfinden. */ function stackEsterWert(wert, w, katalog) { const roh = wert && typeof wert === "object" ? (wert.id || wert.value || wert.label || "") : wert; if (!w || roh === null || roh === undefined || String(roh).trim() === "") return ""; const schluessel = (s) => BB.foldCase(String(s || "")).replace(/[^a-z0-9]+/g, ""); const gesucht = schluessel(roh); const gefunden = (w.ester || []).find((id) => { const eintrag = (katalog.ester || {})[id]; return schluessel(id) === gesucht || schluessel(eintrag && eintrag.label) === gesucht; }); return gefunden || String(roh).trim(); } function leereStoffZeile() { return { stoff: "", dosis: "", ester: "", applikationsweg: "", intervall_tage: "1", einnahmezeitpunkt: "morgens", }; } function ausStackZeile(w) { return { stoff: w.stoff || "", dosis: w.dosis === undefined || w.dosis === null ? "" : String(w.dosis), ester: w.ester || "", applikationsweg: w.applikationsweg === "im" || w.applikationsweg === "sc" ? w.applikationsweg : "", intervall_tage: w.intervall_tage === undefined || w.intervall_tage === null ? "1" : String(w.intervall_tage), einnahmezeitpunkt: w.einnahmezeitpunkt || "morgens", }; } /** Aus einer gespeicherten Stack-Eingabe wieder ein Formularzustand. */ function ausStackEingabe(eingabe) { const liste = Array.isArray(eingabe.wirkstoffe) ? eingabe.wirkstoffe : []; const sideListe = Array.isArray(eingabe.side_control) ? eingabe.side_control : []; return { zweck: eingabe.zweck || "bulk", phase: eingabe.phase || "blast", wochen: eingabe.wochen ? String(eingabe.wochen) : "", trainingszeit: eingabe.trainingszeit ? String(eingabe.trainingszeit) : "", stoffe: (liste.length ? liste : [leereStoffZeile()]).map(ausStackZeile), sideControl: (sideListe.length ? sideListe : [leereStoffZeile()]).map(ausStackZeile), }; } function StackStoffZeile({ zeile, index, optionen, finde, katalog, wegLabel, wegOptionen, zeitpunkte, onChange, onRemove, sideControl = false }) { const w = finde(zeile.stoff); const ester = w ? w.ester || [] : []; const esterWert = stackEsterWert(zeile.ester, w, katalog); const setze = (patch) => onChange(index, patch); return (
    { const neu = finde(id); setze({ stoff: id, ester: "", applikationsweg: neu && neu.weg === "injektion" ? (zeile.applikationsweg || "im") : "", intervall_tage: standardIntervall(neu), einnahmezeitpunkt: zeile.einnahmezeitpunkt || "morgens", }); }} />
    setze({ dosis: e.target.value })} /> {w ? w.einheit : ""}
    {!sideControl && ( )} {!sideControl && ( )} setze({ intervall_tage: e.target.value })} />
    ); } function StackForm({ katalog, onReport, onBusy, onRequest = (aktion) => aktion(), vorgabe, titelVorgabe }) { const start = vorgabe ? ausStackEingabe(vorgabe) : null; const [zweck, setZweck] = useState(start ? start.zweck : "bulk"); // Blast ist die Vorgabe, weil ein Stack meistens dafuer gebaut wird. Cruise // und Ohne Hormone sind die bewusste Auswahl. const [phase, setPhase] = useState(start ? start.phase : "blast"); const [wochen, setWochen] = useState(start ? start.wochen : ""); const [trainingszeit, setTrainingszeit] = useState(start ? start.trainingszeit : ""); const [titel, setTitel] = useState(titelVorgabe || ""); const [stoffe, setStoffe] = useState(start ? start.stoffe : [leereStoffZeile()]); const [sideControl, setSideControl] = useState( start ? start.sideControl : [leereStoffZeile()] ); const [fehler, setFehler] = useState(""); const toast = useToast(); const katalogStoffe = katalog.wirkstoffe || []; const sideKatalogStoffe = katalog.sideControl || []; const alleKatalogStoffe = katalogStoffe.concat( sideKatalogStoffe.filter((x) => !katalogStoffe.some((y) => y.id === x.id)) ); const finde = (id) => alleKatalogStoffe.find((x) => x.id === id); // Beschriftung und Auswahl kommen aus dem Modell. Vorher stand hier eine // Codeliste mit genau im und sc, und intranasal waere still herausgefiltert // worden. const wege = katalog.applikationswege || { im: {}, sc: {} }; const wegLabel = Object.fromEntries( Object.entries(wege).map(([id, cfg]) => [id, BB.t(cfg.label || id)]) ); const wegOptionen = Object.keys(wege); const zeitpunkte = katalog.zeitpunkte || { pre_workout: { label: "Pre-Workout" }, morgens: { label: "Morgens" }, abends: { label: "Abends" } }; const setzeZeile = (i, patch) => setStoffe((alt) => alt.map((z, j) => (i === j ? { ...z, ...patch } : z))); const setzeSideControl = (i, patch) => setSideControl((alt) => alt.map((z, j) => (i === j ? { ...z, ...patch } : z))); function absenden(e) { e.preventDefault(); const basisEingabe = (z) => { const profil = finde(z.stoff); return { stoff: z.stoff, dosis: z.dosis === "" ? null : stackZahl(z.dosis), einheit: profil && profil.einheit ? profil.einheit : undefined, einnahmezeitpunkt: z.einnahmezeitpunkt || "morgens", }; }; const eingabeZeile = (z) => { const profil = finde(z.stoff); const ester = stackEsterWert(z.ester, profil, katalog); return { ...basisEingabe(z), ester: ester || undefined, applikationsweg: z.applikationsweg || undefined, // Die Auswahl darf nicht verloren gehen, nur weil der Katalog in // diesem Render noch nicht aufgeloest ist. Der Server validiert die // Pflichtfelder fuer injizierbare Stoffe anschliessend nochmals. intervall_tage: z.intervall_tage === "" || z.intervall_tage == null ? undefined : stackZahl(z.intervall_tage), }; }; // `map` reicht sonst Index und Ursprungsliste als weitere Argumente an // die Funktion durch. Genau dadurch wurde die erste Zeile (Index 0) zuvor // als "nicht vollständig" serialisiert und verlor Ester und Intervall. const nutzbar = stoffe.filter((z) => z.stoff).map((z) => eingabeZeile(z)); const nichtAufgeloest = stoffe.filter((z) => z.stoff && !finde(z.stoff)); if (nichtAufgeloest.length) { setFehler(BB.t("Der Wirkstoffkatalog wird noch geladen. Bitte kurz warten und erneut versuchen.")); return; } const zahlfehler = []; const pflichtfehler = []; stoffe.filter((z) => z.stoff).forEach((z) => { const w = finde(z.stoff); if (!w) return; if (z.dosis !== "" && stackZahl(z.dosis) === null) { zahlfehler.push(`${w.label}: Dosis`); } const intervall = stackZahl(z.intervall_tage); if (z.intervall_tage !== "" && (intervall === null || intervall <= 0)) { zahlfehler.push(`${w.label}: Intervall`); } if (w.weg !== "injektion") return; if (w.ester && w.ester.length && !stackEsterWert(z.ester, w, katalog)) { pflichtfehler.push(`${w.label}: Ester`); } if (intervall === null || intervall <= 0) { pflichtfehler.push(`${w.label}: Intervall`); } }); if (zahlfehler.length) { setFehler(BB.t("Bitte gib für Dosis und Intervall gültige Zahlen ein: ") + zahlfehler.join(", ")); return; } if (pflichtfehler.length) { setFehler(BB.t("Für injizierbare Wirkstoffe fehlen Pflichtangaben: ") + pflichtfehler.join(", ")); return; } const kontrollen = sideControl.filter((z) => z.stoff) .map((z) => ({ ...basisEingabe(z), intervall_tage: z.intervall_tage === "" || z.intervall_tage == null ? undefined : stackZahl(z.intervall_tage), })); if (!nutzbar.length) { setFehler(BB.t("Trag mindestens einen Wirkstoff ein.")); return; } setFehler(""); onRequest(() => { onBusy(true); BB.post("/auswertung/stack", { title: titel.trim(), data: { zweck, phase, wochen: stackZahl(wochen) || null, trainingszeit: trainingszeit || null, wirkstoffe: nutzbar, side_control: kontrollen, }, }) .then((d) => onReport(d)) .catch((err) => toast(err.message || BB.t("Das hat nicht geklappt."), "error")) .finally(() => onBusy(false)); }); } // Fuer das Suchfeld: flache Liste mit Wirkstoff und Gruppe. Mengen und // Einheiten werden erst in der ausgefuellten Dosiszeile angezeigt. // Ueberschrift ist die Stoffgruppe aus dem Modell. Sie faellt mit der // Kategorie der Wissensbasis zusammen, heisst aber so, wie die Auswertung // sie nennt, und bleibt stehen, wenn die Kategorien umgebaut werden. const stoffOptionen = katalogStoffe.map((w) => ({ id: w.id, label: w.label, aliase: w.aliase || [], gruppe: w.gruppeLabel || w.kategorieLabel || w.klasse, })); const sideControlOptionen = sideKatalogStoffe.map((w) => ({ id: w.id, label: w.label, aliase: w.aliase || [], gruppe: w.gruppeLabel || w.kategorieLabel || w.klasse, })); return (
    setTrainingszeit(e.target.value)} /> setWochen(e.target.value)} /> setTitel(e.target.value)} placeholder={BB.t("z. B. Aufbau Winter")} />
    {BB.t("Steroide, SARMs, Peptide")}
    {BB.t("Wirkstoff")} {BB.t("Dosis")} {BB.t("Applikationsweg")} {BB.t("Ester")} {BB.t("Alle x Tage")} {BB.t("Einnahmezeitpunkt")}
    {stoffe.map((z, i) => ( setStoffe(stoffe.length > 1 ? stoffe.filter((_, j) => j !== i) : [leereStoffZeile()])} /> ))}
    {BB.t("Risikominderungs-Abdeckung")}
    {BB.t("Wirkstoff")} {BB.t("Dosis")} {BB.t("Alle x Tage")} {BB.t("Einnahmezeitpunkt")}
    {sideControl.map((z, i) => ( setSideControl(sideControl.length > 1 ? sideControl.filter((_, j) => j !== i) : [leereStoffZeile()])} /> ))}

    {BB.t("Der Platzhalter im Dosisfeld ist der untere Rand dessen, was in der Anwendung verbreitet ist. Das ist eine Beschreibung, keine Empfehlung.")}

    {fehler &&

    {fehler}

    }
    ); } var KLASSEN = { aas: "Anabole Steroide", sarm: "SARMs", peptid: "Peptide", ai: "Aromatasehemmer", serm: "SERMs", hormon: "Hormone", medikament: "Medikamente", stimulanz: "Stimulanzien", supplement: "Nahrungsergänzung", sonstiges: "Sonstige", }; /** Barcode mit der Kamera lesen. Chrome und Edge bringen dafür `BarcodeDetector` mit, Safari und Firefox nicht. Statt eine Bibliothek nachzuladen, sagt die Komponente in dem Fall, dass es hier nicht geht, und lässt das Eingabefeld stehen. Ein Scanner, der nur manchmal funktioniert, ist schlimmer als einer, der sagt wann. */ function BarcodeScanner({ onCode, onAbbruch }) { const video = useRef(null); const [fehler, setFehler] = useState(""); useEffect(() => { let strom = null; let laufend = true; let timer = null; async function starten() { if (!("BarcodeDetector" in window)) { setFehler(BB.t("Dieser Browser kann keine Barcodes lesen. Chrome oder Edge können es.")); return; } try { strom = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, audio: false }); } catch (e) { setFehler(BB.t("Kein Zugriff auf die Kamera.")); return; } if (!laufend || !video.current) return; video.current.srcObject = strom; await video.current.play().catch(() => {}); const leser = new window.BarcodeDetector({ formats: ["ean_13", "ean_8", "upc_a", "upc_e", "code_128"] }); const suchen = async () => { if (!laufend || !video.current) return; try { const treffer = await leser.detect(video.current); if (treffer && treffer.length) { laufend = false; onCode(treffer[0].rawValue); return; } } catch (e) { /* einzelne Frames dürfen scheitern */ } timer = setTimeout(suchen, 250); }; suchen(); } starten(); return () => { laufend = false; clearTimeout(timer); if (strom) strom.getTracks().forEach((t) => t.stop()); }; }, [onCode]); return (
    {fehler ? (

    {fehler}

    ) : (
    ); } /* --- Eingabe: Mahlzeit --------------------------------------------------- */ function leereZutat() { return { zutat: "", gramm: "", je100g: null, label: "" }; } function ausMealEingabe(eingabe) { const liste = Array.isArray(eingabe.zutaten) ? eingabe.zutaten : []; return { land: eingabe.land || "", anlass: eingabe.anlass || "hauptmahlzeit", phase: eingabe.phase || "erhalt", zutaten: (liste.length ? liste : [leereZutat()]).map((z) => ({ zutat: z.zutat || "", gramm: z.gramm ? String(z.gramm) : "", je100g: z.je100g || null, label: z.label || "", })), }; } /* Die Vorschau rechnet dieselben Werte wie der Server, aber bewusst mit einer eigenen kleinen Normalisierung: API- und Barcodewerte kommen nicht immer mit demselben JavaScript-Typ zurück. Vor allem darf das Gesamtgewicht nie als Makronährwert in der Anzeige landen. */ function mealSumme(zutaten, liste) { const zahl = (wert) => { const n = Number(wert); return Number.isFinite(n) ? n : 0; }; const kcalJe100 = (n) => { const protein = zahl(n.protein); const kh = zahl(n.kh); const ballast = zahl(n.ballast); const fett = zahl(n.fett); const verfuegbar = Math.max(0, kh - ballast); if (n.energie_formel === "bls4") { return zahl(n.kcal); } return protein * 4 + verfuegbar * 4 + ballast * 2 + fett * 9; }; return zutaten.reduce((s, z) => { const gramm = zahl(z.gramm); const n = z.je100g || liste.find((x) => x.id === z.zutat); if (!n || gramm <= 0) return s; const faktor = gramm / 100; return { kcal: s.kcal + kcalJe100(n) * faktor, protein: s.protein + zahl(n.protein) * faktor, kh: s.kh + zahl(n.kh) * faktor, fett: s.fett + zahl(n.fett) * faktor, gramm: s.gramm + gramm, }; }, { kcal: 0, protein: 0, kh: 0, fett: 0, gramm: 0 }); } function MealForm({ katalog, onReport, onBusy, onRequest = (aktion) => aktion(), vorgabe, titelVorgabe }) { const start = vorgabe ? ausMealEingabe(vorgabe) : null; const [anlass, setAnlass] = useState(start ? start.anlass : "hauptmahlzeit"); const [phase, setPhase] = useState(start ? start.phase : "erhalt"); const [titel, setTitel] = useState(titelVorgabe || ""); const [zutaten, setZutaten] = useState(start ? start.zutaten : [leereZutat()]); const [barcode, setBarcode] = useState(""); const [scannen, setScannen] = useState(false); const [fehler, setFehler] = useState(""); const [marktOptionen, setMarktOptionen] = useState([]); const [marktLaden, setMarktLaden] = useState(false); const marktAnfrage = useRef(0); const toast = useToast(); const liste = katalog.lebensmittel || []; const land = start && start.land ? start.land : (katalog.markt && katalog.markt.land) || BB.country || "DE"; const marktLabel = katalog.markt && katalog.markt.label ? katalog.markt.label : land; const setzeZeile = (i, patch) => setZutaten((alt) => alt.map((z, j) => (i === j ? { ...z, ...patch } : z))); function marktSuchen(query) { const text = String(query || "").trim(); if (text.length < 3) return; const anfrage = ++marktAnfrage.current; setMarktLaden(true); setMarktOptionen([]); BB.get("/auswertung/lebensmittel/suche" + BB.qs({ q: text, land })) .then((d) => { if (anfrage === marktAnfrage.current) setMarktOptionen(d.lebensmittel || []); }) .catch((err) => { if (anfrage === marktAnfrage.current) setFehler(err.message || BB.t("Das hat nicht geklappt.")); }) .finally(() => { if (anfrage === marktAnfrage.current) setMarktLaden(false); }); } /* Markenprodukt über den Barcode. Die Werte kommen als je100g mit der Zeile mit und gehen so in die Rechnung, ohne die kuratierte Basis zu verwässern. */ function holeProdukt(roh) { const code = String(roh || barcode).replace(/\D/g, ""); if (code.length < 8) { setFehler(BB.t("Das sieht nicht nach einem Barcode aus.")); return; } setFehler(""); BB.get("/auswertung/produkt" + BB.qs({ code })) .then((p) => { setZutaten((alt) => alt.filter((z) => z.zutat || z.je100g) .concat({ zutat: p.zutat, gramm: "100", je100g: p.je100g, label: p.zutat })); toast(BB.t("Produkt übernommen") + ": " + p.zutat, "ok"); }) .catch((err) => setFehler(err.message)); } /* Summe live, damit man beim Tippen sieht, wohin die Mahlzeit läuft. */ const summe = mealSumme(zutaten, liste); function absenden(e) { e.preventDefault(); const nutzbar = zutaten.filter((z) => (z.zutat || z.je100g) && Number(z.gramm) > 0) .map((z) => (z.je100g ? { zutat: z.label || z.zutat, gramm: Number(z.gramm), je100g: z.je100g } : { zutat: z.zutat, gramm: Number(z.gramm) })); if (!nutzbar.length) { setFehler(BB.t("Trag mindestens eine Zutat mit Menge ein.")); return; } setFehler(""); onRequest(() => { onBusy(true); BB.post("/auswertung/meal", { title: titel.trim(), data: { land, anlass, phase, zutaten: nutzbar }, }) .then((d) => onReport(d)) .catch((err) => toast(err.message || BB.t("Das hat nicht geklappt."), "error")) .finally(() => onBusy(false)); }); } const zutatOptionen = useMemo(() => liste.map((l) => ({ id: l.id, label: l.label, gruppe: l.gruppe, zusatz: l.kcal != null ? `${Math.round(l.kcal)} kcal` : "", })).concat(marktOptionen), [liste, marktOptionen]); const katalogQuelle = katalog.quelle && katalog.quelle.name ? `${katalog.quelle.name} · ${BB.num(katalog.quelle.gesamt_eintraege)} ${BB.t("Einträge")}` : ""; const ausgefuellteZutaten = zutaten.filter((z) => z.zutat || z.je100g).length; return (
    setTitel(e.target.value)} placeholder={BB.t("z. B. Reis mit Hähnchen")} />
    {BB.t("Lebensmittel")} {ausgefuellteZutaten} {BB.t("Lebensmittel")}
    {zutaten.map((z, i) => (
    {z.je100g ? ( ) : ( setzeZeile(i, { zutat: id, je100g: option && option.je100g ? option.je100g : null, label: option && option.quelle === "Open Food Facts" ? option.label : "", })} remoteSearch={marktSuchen} remoteLabel={BB.t("Markenprodukte suchen") + " · " + marktLabel} remoteLoading={marktLaden} /> )}
    setzeZeile(i, { gramm: e.target.value })} /> g
    ))}
    {katalogQuelle && {katalogQuelle}} {BB.t("Vorschläge für")}: {marktLabel} {BB.t("Markenprodukt aus Open Food Facts.")}
    {scannen && ( setScannen(false)} onCode={(code) => { setScannen(false); holeProdukt(code); }} /> )} {summe.gramm > 0 && (

    {Math.round(summe.kcal)} kcal {Math.round(summe.protein)} g {BB.t("Eiweiß")} {Math.round(summe.kh)} g {BB.t("Kohlenhydrate")} {Math.round(summe.fett)} g {BB.t("Fett")} {Math.round(summe.gramm)} g {BB.t("Gesamtgewicht")}

    )}

    {BB.t("Mikronährstoffe werden gegen den Tagesbezug gerechnet, sofern Mengenwerte hinterlegt sind.")}

    {fehler &&

    {fehler}

    }
    ); } var GRUPPEN = { fleisch: "Fleisch", fisch: "Fisch", ei: "Eier", milch: "Milchprodukte", huelsenfrucht: "Hülsenfrüchte", getreide: "Getreide und Beilagen", gemuese: "Gemüse", obst: "Obst", nuss: "Nüsse und Samen", fett: "Fette und Öle", supplement: "Nahrungsergänzung", suess: "Süßes", sonstiges: "Sonstiges", markt: "Markenprodukte", }; /* --- Eingabe: Blutbild --------------------------------------------------- */ function leererWert() { return { analyt: "", wert: "", einheit: "", ref_von: "", ref_bis: "" }; } function ausBlutEingabe(eingabe) { const liste = Array.isArray(eingabe.werte) ? eingabe.werte : []; return { geschlecht: eingabe.geschlecht || "m", kontext: Array.isArray(eingabe.kontext) ? eingabe.kontext : [], training_her: eingabe.training_her || "unbekannt", werte: (liste.length ? liste : [leererWert()]).map((w) => ({ analyt: w.analyt || "", wert: w.wert === undefined ? "" : String(w.wert), einheit: w.einheit || "", ref_von: w.ref_von === undefined || w.ref_von === null ? "" : String(w.ref_von), ref_bis: w.ref_bis === undefined || w.ref_bis === null ? "" : String(w.ref_bis), })), }; } var KONTEXT_LABEL = { aas: "Androgene", "orale-aas": "orale Androgene", "19-nor": "19-nor-Verbindung", sarm: "SARM", gh: "Wachstumshormon", glp1: "GLP-1-Agonist", t3: "Schilddrüsenhormon", hcg: "hCG", diaet: "Diät", kreatin: "Kreatin", nuechtern: "nüchtern", diuretika: "Diuretika", }; var TRAINING_LABEL = { "unter-24h": "unter 24 Stunden", "24-72h": "24 bis 72 Stunden", "ueber-72h": "über 72 Stunden", unbekannt: "unbekannt", }; function BloodForm({ katalog, onReport, onBusy, onRequest = (aktion) => aktion(), vorgabe, titelVorgabe }) { const start = vorgabe ? ausBlutEingabe(vorgabe) : null; const [geschlecht, setGeschlecht] = useState(start ? start.geschlecht : "m"); const [kontext, setKontext] = useState(start ? start.kontext : []); const [trainingHer, setTrainingHer] = useState(start ? start.training_her : "unbekannt"); const [titel, setTitel] = useState(titelVorgabe || ""); const [werte, setWerte] = useState(start ? start.werte : [leererWert()]); const [fehler, setFehler] = useState(""); const toast = useToast(); const analyte = katalog.analyte || []; const finde = (id) => analyte.find((x) => x.id === id); const setzeZeile = (i, patch) => setWerte((alt) => alt.map((w, j) => (i === j ? { ...w, ...patch } : w))); const kippe = (k) => setKontext((alt) => (alt.includes(k) ? alt.filter((x) => x !== k) : alt.concat(k))); function absenden(e) { e.preventDefault(); const nutzbar = werte.filter((w) => w.analyt && w.wert !== "").map((w) => ({ analyt: w.analyt, wert: w.wert, einheit: w.einheit || undefined, ref_von: w.ref_von === "" ? undefined : w.ref_von, ref_bis: w.ref_bis === "" ? undefined : w.ref_bis, })); if (!nutzbar.length) { setFehler(BB.t("Trag mindestens einen Wert ein.")); return; } setFehler(""); onRequest(() => { onBusy(true); BB.post("/auswertung/blood", { title: titel.trim(), data: { geschlecht, kontext, training_her: trainingHer, werte: nutzbar }, }) .then((d) => onReport(d)) .catch((err) => toast(err.message || BB.t("Das hat nicht geklappt."), "error")) .finally(() => onBusy(false)); }); } const gruppenLabel = katalog.gruppen || {}; const analytOptionen = analyte.map((a) => ({ id: a.id, label: BB.t(a.label), gruppe: a.gruppe, zusatz: a.einheit, })); const ausgefuellteWerte = werte.filter((w) => w.analyt || w.wert).length; return (
    setTitel(e.target.value)} placeholder={BB.t("z. B. Kontrolle August")} />
    {((katalog.kontext || {}).flags || []).map((k) => ( ))}
    {BB.t("Blutwerte")} {ausgefuellteWerte} {ausgefuellteWerte === 1 ? BB.t("Wert") : BB.t("Werte")}
    {BB.t("Wert")}{BB.t("Messwert")}{BB.t("Einheit")} {BB.t("Ref. von")}{BB.t("Ref. bis")}
    {werte.map((w, i) => { const a = finde(w.analyt); return (
    setzeZeile(i, { analyt: id, einheit: "" })} /> setzeZeile(i, { wert: e.target.value })} /> setzeZeile(i, { ref_von: e.target.value })} /> setzeZeile(i, { ref_bis: e.target.value })} />
    ); })}

    {BB.t("Steht im Befund ein Referenzbereich, trag ihn mit ein: der Bereich des Labors gilt vor dem hinterlegten, weil Labore verschieden kalibrieren.")}

    {fehler &&

    {fehler}

    }
    ); } /* --- Schaufenster -------------------------------------------------------- */ /** Was die Auswertung leistet, in drei Punkten. Steht ueber dem Beispiel, damit klar ist, worauf man beim Beispiel achten soll. */ function Versprechen() { const punkte = [ ["Jede Zahl mit Begründung", "Ein Score allein sagt nichts. Unter jeder Bewertung stehen die Werte, aus denen sie entstanden ist, und der Verweis in die Wissensbasis."], ["Nach Studien gerechnet, nicht nach Bauchgefühl", "Die Kurven und Bereiche stammen aus benannten Arbeiten. Wo es keine Daten gibt, sagt die Auswertung das statt zu schätzen."], ["Nichts wird empfohlen", "Die Auswertung beschreibt und ordnet ein. Sie gibt keine Handlungsanweisung und ersetzt keine ärztliche Beratung."], ]; return (
    {punkte.map(([titel, text]) => (

    {BB.t(titel)}

    {BB.t(text)}

    ))}
    ); } /** Links Token, rechts Premium. Die Karten sind dieselben wie auf Startseite und Molekülkarte, damit Angebot und Preis überall gleich bleiben. */ function Angebot({ user }) { const Cards = window.OfferCards; return (
    {!user && (
    {BB.t("Konto anlegen")} {BB.t("Schon dabei? Einloggen")}
    )}
    ); } /* --- Auswahl ------------------------------------------------------------- */ /** Die vier Arten als Karten, so wie der Composer die Beitragsarten zeigt. Ohne `onPick` ist es reine Anzeige, im Schaufenster wird nichts gewählt. */ const SUBJECT_CATEGORY_IDS = { split: "sport", stack: "stoffe", meal: "ernahrung", blood: "gesundheit", }; // Fallbacks halten die Auswahl auch dann bildstark, wenn die Kategorie-API // beim ersten Render noch nicht zurueck ist. Die Motive entsprechen den // bestehenden Landing-Page-Kacheln. const SUBJECT_IMAGE_FALLBACKS = { split: "https://images.unsplash.com/photo-1517836357463-d25dfeac3438?auto=format&fit=crop&w=1400&q=70", stack: "https://images.unsplash.com/photo-1584308666744-24d5c474f2ae?auto=format&fit=crop&w=1400&q=70", meal: "https://images.unsplash.com/photo-1490645935967-10de6ba17061?auto=format&fit=crop&w=1400&q=70", blood: "https://images.unsplash.com/photo-1576091160399-112ba8d25d1d?auto=format&fit=crop&w=1400&q=70", }; function subjectImage(key, categories) { const categoryId = SUBJECT_CATEGORY_IDS[key]; const category = (categories || []).find((entry) => entry.id === categoryId); return (category && category.image) || SUBJECT_IMAGE_FALLBACKS[key]; } function SubjectGrid({ onPick, aktiv, categories = [], showKosten = false }) { return (
    {Object.entries(BB.SUBJECTS).map(([key, s]) => ( ))}
    ); } const ANALYSE_LADE_MIN_MS = 1400; const ANALYSE_LADE_MAX_MS = 2600; // Der Bildimport laeuft als Auftrag am Server. Der Takt ist bewusst gemuetlich: // das lokale Bildmodell braucht auf der CPU-VM Minuten, und eine Abfrage je // Sekunde waere hundert Anfragen fuer eine Auskunft, die sich alle paar // Sekunden aendert. const BILD_ABFRAGE_MS = 3000; const BILD_MAX_MB = 16; /** Bewusste kurze Denkpause vor dem Ergebnis: Sie verhindert, dass ein Report bei kleinen Eingaben wie ein zufälliger UI-Sprung erscheint. Die Leiste läuft bis 90 %, falls der Server länger braucht, und wird nach der Berechnung durch die explizite Ergebnisfreigabe ersetzt. */ function AnalyseLoading({ duration, inline = false }) { return (
    {BB.t("Analyse wird berechnet …")} {BB.t("Bitte einen Moment")}
    ); } /** Nach der Berechnung bleibt die Seite bewusst unter dem Schleier. Das Ergebnis wird erst nach einer expliziten Bestätigung eingesetzt, damit niemand vom Formular direkt in einen langen Befund springt. */ function AnalyseReviewOverlay({ bestaetigung, bereit, duration, onConfirm, onCancel }) { const bestaetigen = useRef(null); useEffect(() => { const vorher = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = vorher; }; }, []); useEffect(() => { if ((bestaetigung || bereit) && bestaetigen.current) bestaetigen.current.focus(); }, [bestaetigung, bereit]); const layer = (
    {BB.t("Analyse")}

    {bestaetigung ? BB.t("Eingaben prüfen") : bereit ? BB.t("Deine Auswertung ist fertig") : BB.t("Analyse wird berechnet …")}

    {bestaetigung && (

    {BB.t("Bitte bestätige, dass du alle Angaben vollständig eingetragen und geprüft hast.")}

    )} {!bestaetigung && !bereit && } {bereit && (
    {BB.t("Das Ergebnis ist bereit.")}
    )}

    {BB.t("Die Auswertung beschreibt und ordnet ein. Sie gibt keine Handlungsanweisung und ersetzt keine ärztliche Beratung.")}

    {bestaetigung && (
    )} {bereit && ( )}
    ); return ReactDOM.createPortal(layer, document.body); } /** Startseite der Auswertung: was es ist, ein echter Beispielreport, das Angebot. Sieht jeder ohne Guthaben und jeder Ausgeloggte. */ function AuswertungLanding({ user, categories }) { const [beispiel, setBeispiel] = useState(null); useEffect(() => { BB.get("/auswertung/beispiel" + BB.qs({ subject: "split" })) .then(setBeispiel) .catch(() => setBeispiel(null)); }, []); return (

    {BB.t("Analyse")}

    {BB.t("Vier Auswertungen für die Zahlen, die du sowieso schon hast: Trainingssplit, Stack, Mahlzeit und Blutbild. Jede liefert einen Score und darunter die Begründung, aus welchen Werten er entstanden ist.")}

    {BB.t("Worauf das Modell beruht")}

    {BB.t("So sieht eine Auswertung aus")}

    {beispiel && {BB.t("Beispiel")}: {beispiel.title}}
    {beispiel ? : }
    ); } /* --- Historie ------------------------------------------------------------ */ function AnalyseLoeschdialog({ eintrag, busy, onCancel, onConfirm }) { const dialog = useRef(null); const busyRef = useRef(busy); const onCancelRef = useRef(onCancel); busyRef.current = busy; onCancelRef.current = onCancel; useEffect(() => { const ausloeser = document.activeElement; const scrollLinks = window.scrollX; const scrollOben = window.scrollY; dialog.current?.focus({ preventScroll: true }); const escape = (event) => { if (event.key === "Escape" && !busyRef.current) onCancelRef.current(); }; const vorher = document.body.style.overflow; document.body.style.overflow = "hidden"; document.addEventListener("keydown", escape); return () => { document.body.style.overflow = vorher; document.removeEventListener("keydown", escape); if (ausloeser && ausloeser.isConnected && typeof ausloeser.focus === "function") { ausloeser.focus({ preventScroll: true }); } window.scrollTo({ left: scrollLinks, top: scrollOben, behavior: "instant" }); }; }, []); const layer = (
    { if (event.target === event.currentTarget && !busy) onCancel(); }} >
    {BB.t("Analyse")}

    {BB.t("Analyse löschen?")}

    {BB.t("Möchtest du diese gespeicherte Analyse wirklich löschen?")}

    {eintrag.title || BB.t("Ohne Namen")}
    ); return ReactDOM.createPortal(layer, document.body); } /** Die Historie ist keine Bestenliste, sondern ein Archiv: ein Klick holt die Eingabe zurück ins Formular. Wer seinen Split ändert, tippt ihn nicht neu, sondern lädt ihn und passt zwei Zeilen an. */ function Historie({ items, onReload, onOpen }) { const [loeschkandidat, setLoeschkandidat] = useState(null); const [loeschBusy, setLoeschBusy] = useState(false); const notify = useToast(); async function bestaetigeLoeschen() { if (!loeschkandidat || loeschBusy) return; setLoeschBusy(true); try { await BB.del("/auswertungen/" + loeschkandidat.id); setLoeschkandidat(null); onReload(); } catch (err) { notify(BB.t("Das hat nicht geklappt."), "bad"); } finally { setLoeschBusy(false); } } if (!items.length) return null; return ( <>
    {BB.t("Meine Analysen")} {items.map((a, i) => ( {a.score === null ? "?" : Math.round(a.score)} {BB.t((BB.SUBJECTS[a.subject] || {}).label || "Analyse")} {(a.createdAt || "").slice(0, 10)} ))}
    {loeschkandidat && ( { if (!loeschBusy) setLoeschkandidat(null); }} onConfirm={bestaetigeLoeschen} /> )} ); } // Der Fehlertext des Servers hat Vorrang vor jedem eigenen. Kommt gar kein // JSON zurueck, hat ein Proxy dazwischengefunkt, und genau das steht dann auch // da statt eines Satzes ueber das Bild. async function bildAntwortLesen(response) { const text = await response.text(); try { return text ? JSON.parse(text) : {}; } catch (_) { return null; } } function bildFehlerAus(daten, response) { if (daten && daten.detail) return new Error(daten.detail); if (daten === null) { return new Error(BB.t("Die Antwort kam nicht vom Server durch, sondern von einem Proxy dazwischen.")); } return new Error(BB.t("Das Bild konnte nicht gelesen werden.") + ` (HTTP ${response.status})`); } function AnalysisImageUpload({ onFile, busy = false, auftrag = null, onCancel = null }) { const aktiv = typeof onFile === "function"; const gesperrt = busy || !!auftrag; // Waehrend des Laufens steht die verstrichene Zeit im Knopf. Ohne sie sieht // ein Vorgang, der auf dieser CPU-VM regulaer mehrere Minuten dauert, nach // zwanzig Sekunden aus wie einer, der haengt. const button = auftrag ? BB.t("Wird analysiert …") + ` ${auftrag.sekunden}s` : BB.t("Als Bild oder PDF hochladen"); if (!aktiv) { return (
    {button}
    ); } return (
    {auftrag && ( <> {auftrag.anderswo && (

    {BB.t("Deine Datei für {art} ist noch in Arbeit. Bis sie fertig ist, geht kein weiterer Upload.") .replace("{art}", BB.t((BB.SUBJECTS[auftrag.anderswo] || {}).label || "Analyse"))}

    )} {auftrag.position > 0 && (

    {BB.t("In der Warteschlange, {n} vor dir.").replace("{n}", auftrag.position)}

    )} {auftrag.fortschritt && auftrag.fortschritt.seiten > 1 && (

    {BB.t("Seite {n} von {m}.") .replace("{n}", auftrag.fortschritt.seite) .replace("{m}", auftrag.fortschritt.seiten)}

    )}

    {BB.t("Das dauert ein paar Minuten. Du kannst die Seite verlassen und später zurückkommen.")}

    {typeof onCancel === "function" && ( )} )}
    ); } /* --- Seite --------------------------------------------------------------- */ function AuswertungPage({ params, categories }) { const { go } = useRouter(); const { locale } = useLang(); const { user, ready } = useAuth(); const toast = useToast(); const [katalog, setKatalog] = useState({ muskeln: [] }); const [katalogFehler, setKatalogFehler] = useState(""); const [antwort, setAntwort] = useState(null); const [pendingAntwort, setPendingAntwort] = useState(null); const [analyseAktion, setAnalyseAktion] = useState(null); const [busy, setBusy] = useState(false); const [ladeDauer, setLadeDauer] = useState(1800); const ladeBeginn = useRef(0); const [historie, setHistorie] = useState([]); const [guthaben, setGuthaben] = useState(null); // Eine geoeffnete gespeicherte Auswertung: ihre Eingabe fuellt das Formular, // ihr Report steht darunter. `stand` erzwingt den Neuaufbau des Formulars. const [geoeffnet, setGeoeffnet] = useState(null); const [stand, setStand] = useState(0); const ergebnis = useRef(null); const [bildImport, setBildImport] = useState(null); const [bildStatus, setBildStatus] = useState(null); // Der offene Auftrag, wie ihn der Server meldet. Er haengt am Konto und // nicht an diesem Tab: eine neu geladene Seite fragt nach ihm und sperrt den // Knopf, statt einen zweiten anzulegen. const [bildAuftrag, setBildAuftrag] = useState(null); // Fertig, aber fuer eine andere Auswertungsart als die offene Seite. const [bildAnderswo, setBildAnderswo] = useState(null); // Die laufende Nummer verwirft die Antwort einer Abfrage, die niemand mehr // sehen will, und die Kennung erlaubt das Abbrechen. const bildAnfrage = useRef(0); const bildJob = useRef(null); const bildStart = useRef(0); const [bildSekunden, setBildSekunden] = useState(0); const bildBusy = !!bildAuftrag; const neueAnalyse = useCallback(() => { setAntwort(null); setPendingAntwort(null); setAnalyseAktion(null); setGeoeffnet(null); setBildImport(null); setBildStatus(null); setStand((n) => n + 1); }, []); const setAnalyseBusy = useCallback((wert) => { if (wert) { ladeBeginn.current = Date.now(); setAntwort(null); setPendingAntwort(null); setAnalyseAktion(null); setLadeDauer( ANALYSE_LADE_MIN_MS + Math.floor(Math.random() * (ANALYSE_LADE_MAX_MS - ANALYSE_LADE_MIN_MS + 1)) ); } setBusy(wert); }, []); const was = params ? params.get("was") : null; const gewaehlt = was && BB.SUBJECTS[was] && BB.SUBJECTS[was].ready ? was : ""; const bildHier = !!bildAuftrag && bildAuftrag.subject === gewaehlt; const userId = user ? user.id : null; const bildKennung = bildAuftrag ? bildAuftrag.job : null; // Der Sekundenzaehler laeuft in der Oberflaeche und nicht im Abfragetakt: // ein Balken, der sich nur alle drei Sekunden bewegt, sieht aus wie einer, // der haengt. Er zaehlt ab dem Upload, auch nach einem Seitenwechsel. useEffect(() => { if (!bildKennung) return undefined; const takt = () => setBildSekunden( Math.max(0, Math.round((Date.now() - bildStart.current) / 1000))); takt(); const uhr = setInterval(takt, 1000); return () => clearInterval(uhr); }, [bildKennung]); // Ein Ergebnis wird nur auf der Seite seiner eigenen Auswertungsart // uebernommen, sonst landete ein Laborbefund in einem Trainingsplan. Danach // darf der Server es vergessen. const bildAbschliessen = useCallback((stand) => { if (stand.subject !== gewaehlt) { setBildAnderswo({ subject: stand.subject, status: stand.status, dateiname: stand.dateiname }); return; } fetch(`/api/auswertung/bild/${encodeURIComponent(stand.job)}`, { method: "DELETE", credentials: "same-origin", }).catch(() => {}); if (stand.status !== "fertig") { setBildImport(null); setBildStatus({ typ: "fehler", dateiname: stand.dateiname, text: stand.detail || BB.t("Das Bild konnte nicht gelesen werden."), unbekannt: stand.unbekannt || [], }); return; } const inhalt = stand.ergebnis || {}; setGeoeffnet(null); setAntwort(null); setPendingAntwort(null); setAnalyseAktion(null); setBildImport({ subject: stand.subject, input: inhalt.eingabe || {}, title: inhalt.titel || "", }); setBildStatus({ typ: "ok", dateiname: stand.dateiname, dauer: Math.round(stand.seit_s || 0), hinweise: inhalt.hinweise || [], unbekannt: inhalt.unbekannt || [], }); setStand((n) => n + 1); toast(BB.t("Bilddaten übernommen"), "ok"); }, [gewaehlt, toast]); // Abfragen statt warten. Eine offene Anfrage ueber Minuten ueberlebt keinen // Reverse Proxy; die hier ist jedes Mal in Millisekunden vorbei. Ein // Aussetzer im Tunnel beendet die Abfrage nicht: der Auftrag laeuft am Server // weiter, und eine Fehlermeldung waere hier falsch. const bildVerfolgen = useCallback(async (stand, anfrage) => { bildJob.current = stand.job; bildStart.current = Date.now() - (stand.seit_s || 0) * 1000; try { while (stand.status === "wartet" || stand.status === "laeuft") { setBildAuftrag(stand); await new Promise((weiter) => setTimeout(weiter, BILD_ABFRAGE_MS)); if (anfrage !== bildAnfrage.current) return; let nach; let neu; try { nach = await fetch(`/api/auswertung/bild/${encodeURIComponent(stand.job)}`, { credentials: "same-origin" }); neu = await bildAntwortLesen(nach); } catch (_) { continue; } if (anfrage !== bildAnfrage.current) return; if (!nach.ok) { if (neu && neu.detail && nach.status < 500) throw bildFehlerAus(neu, nach); continue; } stand = neu || {}; } bildJob.current = null; setBildAuftrag(null); bildAbschliessen(stand); } catch (err) { if (anfrage !== bildAnfrage.current) return; bildJob.current = null; setBildAuftrag(null); setBildImport(null); setBildStatus({ typ: "fehler", dateiname: stand.dateiname, text: err.message || BB.t("Das Bild konnte nicht gelesen werden."), unbekannt: [], }); } }, [bildAbschliessen]); const bildAbgleichen = useCallback(async () => { const anfrage = ++bildAnfrage.current; let stand = null; try { const response = await fetch("/api/auswertung/bild", { credentials: "same-origin" }); const daten = response.ok ? await bildAntwortLesen(response) : null; stand = daten && daten.auftrag; } catch (_) { return; } if (anfrage !== bildAnfrage.current || !stand) return; bildVerfolgen(stand, anfrage); }, [bildVerfolgen]); useEffect(() => { bildAnfrage.current += 1; bildJob.current = null; setBildImport(null); setBildStatus(null); setBildAuftrag(null); setBildAnderswo(null); setBildSekunden(0); if (gewaehlt && userId) bildAbgleichen(); }, [gewaehlt, userId]); const bildAbbrechen = useCallback(() => { const kennung = bildJob.current; bildAnfrage.current += 1; bildJob.current = null; setBildAuftrag(null); setBildStatus(null); if (kennung) { fetch(`/api/auswertung/bild/${encodeURIComponent(kennung)}`, { method: "DELETE", credentials: "same-origin", }).catch(() => {}); } }, []); const bildLesen = useCallback(async (datei) => { if (!datei || !gewaehlt || bildBusy || busy) return; if (datei.size > BILD_MAX_MB * 1024 * 1024) { setBildStatus({ typ: "fehler", text: BB.t("Die Datei ist zu groß.") + ` (max. ${BILD_MAX_MB} MB)`, }); return; } const anfrage = ++bildAnfrage.current; bildJob.current = null; bildStart.current = Date.now(); setBildSekunden(0); // Sofort gesperrt: der Upload selbst dauert bei einem grossen PDF Sekunden, // und ein zweiter Klick in der Zeit waere ein zweiter Auftrag. setBildAuftrag({ job: null, subject: gewaehlt, status: "wartet", dateiname: datei.name }); setBildStatus(null); setBildAnderswo(null); setAntwort(null); setPendingAntwort(null); setAnalyseAktion(null); const form = new FormData(); form.append("file", datei); form.append("land", BB.country || "DE"); try { const response = await fetch(`/api/auswertung/${gewaehlt}/bild`, { method: "POST", body: form, credentials: "same-origin", }); const daten = await bildAntwortLesen(response); if (anfrage !== bildAnfrage.current) return; if (response.status === 409) { // Ein anderer Tab war schneller. Dessen Auftrag gilt, und dieser // hier zeigt ihn an, statt einen Fehler zu melden. setBildAuftrag(null); bildAbgleichen(); return; } if (!response.ok) throw bildFehlerAus(daten, response); if (!daten || !daten.job) throw new Error(BB.t("Der Server hat keinen Bildauftrag angelegt.")); bildVerfolgen(daten, anfrage); } catch (err) { if (anfrage !== bildAnfrage.current) return; setBildAuftrag(null); setBildImport(null); setBildStatus({ typ: "fehler", dateiname: datei.name, text: err.message || BB.t("Das Bild konnte nicht gelesen werden."), unbekannt: err.unbekannt || [], }); } }, [bildBusy, busy, gewaehlt, bildAbgleichen, bildVerfolgen]); // Dieselbe Regel wie may_analyse() am Server. Sie steht hier nur, um die // richtige Ansicht zu wählen, entschieden wird sie am Server. const darf = !!user && (user.plan === "premium" || user.role === "admin" || user.role === "mod" || (user.tokens || 0) > 0); useEffect(() => { if (!gewaehlt) return; const PFADE = { split: "/auswertung/muskeln", stack: "/auswertung/wirkstoffe", meal: "/auswertung/lebensmittel", blood: "/auswertung/analyte" }; const pfad = PFADE[gewaehlt] || PFADE.split; const katalogPfad = gewaehlt === "meal" ? pfad + BB.qs({ land: BB.country || "DE" }) : pfad; BB.get(katalogPfad) .then((d) => { setKatalog(d); setKatalogFehler(""); }) .catch((err) => setKatalogFehler(err.message || BB.t("Das hat nicht geklappt."))); }, [gewaehlt, locale]); const ladeHistorie = useCallback(() => { if (!user) return; BB.get("/auswertungen" + BB.qs({ limit: 10, subject: gewaehlt })) .then((d) => { setHistorie(d.items); setGuthaben(d.premium ? -1 : d.tokens); }) .catch(() => {}); }, [user, gewaehlt]); useEffect(() => { ladeHistorie(); }, [ladeHistorie]); const oeffne = useCallback((zeile) => { BB.get("/auswertungen/" + zeile.id) .then((a) => { setGeoeffnet(a); setBildImport(null); setBildStatus(null); setAnalyseAktion(null); setPendingAntwort(null); setAntwort({ report: a.report, premium: true, gespeichert: true, id: a.id }); setStand((n) => n + 1); if (a.subject !== gewaehlt) go("/auswertung?was=" + a.subject); }) .catch(() => {}); }, [gewaehlt, go]); const zeigeErgebnis = useCallback(() => { if (!pendingAntwort) return; setAntwort(pendingAntwort); setPendingAntwort(null); }, [pendingAntwort]); const fordereAnalyse = useCallback((aktion) => { if (typeof aktion !== "function") return; setAntwort(null); setPendingAntwort(null); setGeoeffnet(null); setAnalyseAktion(() => aktion); }, []); const bestaetigeAnalyse = useCallback(() => { if (typeof analyseAktion !== "function") return; const aktion = analyseAktion; setAnalyseAktion(null); aktion(); }, [analyseAktion]); const brecheAnalyse = useCallback(() => { if (!busy) setAnalyseAktion(null); }, [busy]); useEffect(() => { if (antwort && ergebnis.current) { ergebnis.current.scrollIntoView({ behavior: BB.reducedMotion() ? "auto" : "smooth", block: "start" }); } }, [antwort]); if (!ready) return
    ; if (!darf) return ; const rest = guthaben === null ? (user.tokens || 0) : guthaben; const historieFuerAnsicht = gewaehlt ? historie.filter((eintrag) => eintrag.subject === gewaehlt) : historie; // Erst die Art wählen, dann die Eingabe. Wie beim Schreiben im Forum. if (!gewaehlt) { return (

    {BB.t("Was willst du auswerten?")}

    {BB.t("Tokens")}: {rest === -1 ? BB.t("Unbegrenzt") : BB.t("{n} Token übrig").replace("{n}", rest)}

    { neueAnalyse(); go("/auswertung?was=" + k); }} showKosten />
    ); } const art = BB.SUBJECTS[gewaehlt]; return (
    ← {BB.t("Zurück")}

    {BB.t(art.label)}

    {BB.t(art.blurb)}

    {bildAnderswo && (
    {(bildAnderswo.status === "fertig" ? BB.t("Deine Datei für {art} ist fertig ausgelesen.") : BB.t("Deine Datei für {art} konnte nicht gelesen werden.")) .replace("{art}", BB.t((BB.SUBJECTS[bildAnderswo.subject] || {}).label || "Analyse"))} {bildAnderswo.dateiname && {bildAnderswo.dateiname}}
    )} {bildStatus && (
    {bildStatus.typ === "ok" ? BB.t("Bilddaten übernommen") : BB.t("Das Bild konnte nicht gelesen werden.")} {bildStatus.dateiname && {bildStatus.dateiname}} {bildStatus.typ === "ok" && bildStatus.dauer > 0 && ( {bildStatus.dauer}s )} {bildStatus.text &&

    {BB.t(bildStatus.text)}

    } {(bildStatus.hinweise || []).map((hinweis, index) => (

    {BB.t(hinweis)}

    ))} {(bildStatus.unbekannt || []).length > 0 && (

    {BB.t("Nicht übernommen")}: {bildStatus.unbekannt.join(", ")}

    )}
    )}
    {katalogFehler &&

    {katalogFehler}

    }
    {(() => { // Ein Formular je Art, ein Renderer fuer alle. Die Vorgabe kommt nur // an, wenn die geoeffnete Auswertung auch von dieser Art ist. const MASKEN = { split: SplitForm, stack: StackForm, meal: MealForm, blood: BloodForm }; const Maske = MASKEN[gewaehlt] || SplitForm; const passt = geoeffnet && geoeffnet.subject === gewaehlt; const bildPasst = bildImport && bildImport.subject === gewaehlt; return ( { const verstrichen = Date.now() - ladeBeginn.current; const warten = Math.max(0, ladeDauer - verstrichen); if (warten > 0) { await new Promise((resolve) => setTimeout(resolve, warten)); } setPendingAntwort(d); setGeoeffnet(null); if (typeof d.tokens === "number") setGuthaben(d.premium ? -1 : d.tokens); ladeHistorie(); }} /> ); })()}
    {!busy && antwort && (

    {antwort.gespeichert ? BB.t("Gespeicherte Auswertung") : BB.t("Ergebnis")}

    )}
    {(analyseAktion || busy || pendingAntwort) && ( )}
    ); } Object.assign(window, { AuswertungPage, AuswertungLanding, ReportView, scoreTon, ExerciseCatalog });