/* ========================================================================== BUILDER (Beta): zeigt, was zusammenhängt, während man baut. Bewertet nichts. Plan und Regeln in docs/BUILDER.md. Die Seite ist bewusst Bild statt Text. Waagerecht ist Zeit, senkrecht ist Art: jeder Baustein rastet in seiner Spur auf einer Viertelstunde ein, und seine Lage bedeutet etwas. Ein freier Canvas wäre hübscher und sagte nichts. Was der Builder zeigt, kommt aus /api/builder/tag und ist entweder aus gepflegten Daten gerechnet (Makros, Erholung nach dem Modell von split.py) oder rein strukturell (Abstände). Wertende Sätze gibt es hier nicht. ========================================================================== */ const BLD_TAG = 24 * 60; const BLD_RASTER = 15; const BLD_SPEICHER = "bb-builder-v1"; const BLD_ARTEN = { mahlzeit: { label: "Mahlzeit", farbe: "var(--viz-3)", start: 12 * 60, dauer: 30 }, training: { label: "Training", farbe: "var(--viz-2)", start: 17 * 60, dauer: 60 }, cardio: { label: "Cardio", farbe: "var(--k-story)", start: 7 * 60, dauer: 30 }, schlaf: { label: "Schlaf", farbe: "var(--viz-4)", start: 23 * 60, dauer: 8 * 60 }, pharmakologie: { label: "Pharmakologie", farbe: "var(--viz-1)", start: 8 * 60, dauer: 30 }, }; /* Evidenzstufen als Kürzel, in der Reihenfolge der Datenlage. Ein Weg ist so gut belegt wie sein schwächstes Glied; das Backend nennt genau diese Stufe. */ const BLD_STUFEN = { measured: { kurz: "M", label: "gemessen" }, boltz: { kurz: "B", label: "Boltz" }, rang: { kurz: "R", label: "Rang" }, predicted: { kurz: "V", label: "vorhergesagt" }, curated: { kurz: "K", label: "kuratiert" }, }; const BLD_RICHTUNG = { hoch: "▲", runter: "▼", offen: "●" }; const BLD_CHRONO = { lerche: { label: "Früh", zeichen: "☀" }, mittel: { label: "Mittel", zeichen: "◐" }, eule: { label: "Spät", zeichen: "☾" }, }; /* --- kleine Helfer ------------------------------------------------------- */ function bldUhr(min) { const m = ((Math.round(min) % BLD_TAG) + BLD_TAG) % BLD_TAG; return String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0"); } function bldDauer(min) { const h = Math.floor(min / 60); const m = Math.round(min % 60); return h ? (m ? `${h} h ${m}` : `${h} h`) : `${m} min`; } function bldId() { return Math.random().toString(36).slice(2, 9); } function bldLaden() { try { const roh = JSON.parse(localStorage.getItem(BLD_SPEICHER) || "null"); if (roh && roh.varianten && roh.varianten.A) return roh; } catch (e) { /* privates Fenster oder gesperrter Speicher: leer starten */ } return { aktiv: "A", varianten: { A: { gewicht: 85, bausteine: [ { id: bldId(), art: "schlaf", start: 23 * 60, dauer: 8 * 60, chronotyp: "mittel" }, ], }, }, }; } /* Die Icons sind Linien und keine Emoji: sie sollen in beiden Themen und in jeder Farbe der Spur gleich lesbar sein. */ function BldIcon({ art, size = 18 }) { const p = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }; if (art === "mahlzeit") return ( ); if (art === "training") return ( ); if (art === "cardio") return ( ); if (art === "koffein") return ( ); if (art === "schlaf") return ( ); return ( ); } /* Plus und Minus als große Flächen. Kleine Pfeile in einem Zahlenfeld sind auf dem Handy nicht zu treffen. */ function BldStepper({ wert, schritt, min, max, onChange, anzeige }) { const setzen = (w) => onChange(Math.max(min, Math.min(max, w))); return (
{anzeige ? anzeige(wert) : wert}
); } /* Ein Stoff steht im Tag nur, wenn seine Halbwertszeit unter einem Tag liegt. Die Grenze rechnet das Backend aus der gepflegten Kinetik; ein Depot kennt keine Uhrzeit, und ohne Kinetik wird nicht geraten. */ function bldImTag(b, kinetik) { const k = (kinetik || {})[b.slug]; return !!k && k.ebene === "tag"; } /* Ueberlappende Bausteine stapeln sich, statt sich zu verdecken. Jeder bekommt die erste Unterspur, in der er Platz hat; die Spur waechst mit ihrer Zahl. Dieselbe Rechnung wie in einem Kalender, und der Grund ist derselbe: zwei Termine zur selben Zeit sind zwei Termine und nicht einer. */ function bldUnterspuren(bausteine, dauerVon) { const spur = {}; const belegt = []; bausteine.slice().sort((a, b) => a.start - b.start).forEach((b) => { const stuecke = bldStuecke(b.start, dauerVon(b)); let i = 0; for (;;) { const frei = (belegt[i] || []).every((s) => stuecke.every((teil) => { const endeA = teil[0] + teil[1]; const endeB = s[0] + s[1]; return endeA <= s[0] || teil[0] >= endeB; })); if (frei) break; i += 1; } belegt[i] = (belegt[i] || []).concat(stuecke); spur[b.id] = i; }); return { spur, hoehe: Math.max(1, belegt.length) }; } /* --- Zeitachse ------------------------------------------------------------ */ function BldLineal() { return ( ); } /** Teilstücke eines Bausteins auf dem Tag. Über Mitternacht sind es zwei. */ function bldStuecke(start, dauer) { const ende = start + dauer; if (ende <= BLD_TAG) return [[start, dauer]]; return [[start, BLD_TAG - start], [0, ende - BLD_TAG]]; } function BldBlock({ b, ergebnis, fokus, onFokus, onVerschieben, reihe = 0 }) { const art = BLD_ARTEN[b.art]; const dauer = b.art === "mahlzeit" || b.art === "pharmakologie" ? art.dauer : (b.dauer || art.dauer); const ziehen = useRef(null); function runter(e) { const spur = e.currentTarget.closest(".bld-spur-feld"); if (!spur) return; e.currentTarget.setPointerCapture(e.pointerId); ziehen.current = { x: e.clientX, start: b.start, breite: spur.getBoundingClientRect().width, bewegt: false }; } function bewegen(e) { const z = ziehen.current; if (!z) return; const dx = e.clientX - z.x; if (Math.abs(dx) > 3) z.bewegt = true; if (!z.bewegt) return; const roh = z.start + (dx / z.breite) * BLD_TAG; const gerastert = Math.round(roh / BLD_RASTER) * BLD_RASTER; onVerschieben(b.id, ((gerastert % BLD_TAG) + BLD_TAG) % BLD_TAG); } function hoch() { const z = ziehen.current; ziehen.current = null; if (z && !z.bewegt) onFokus(fokus === b.id ? null : b.id); } let inhalt; if (b.art === "mahlzeit") { inhalt = <>{ergebnis ? Math.round(ergebnis.kcal) : "·"} {b.koffein_mg > 0 && }; } else if (b.art === "training") { const saetze = (b.muskeln || []).reduce((s, m) => s + (m.saetze || 0), 0); inhalt = <>{saetze}{BB.t("Sätze")}; } else if (b.art === "cardio") { inhalt = <>{b.label || "·"}{bldDauer(dauer)}; } else if (b.art === "pharmakologie") { inhalt = {b.label || "·"}; } else { inhalt = <>{bldDauer(dauer)}{BLD_CHRONO[b.chronotyp || "mittel"].zeichen}; } // Der Inhalt steht im längsten Teilstück. Ein Schlaf ab 23:30 hat vor // Mitternacht nur eine halbe Stunde Platz, und dort stünde er abgeschnitten. const stuecke = bldStuecke(b.start, dauer); const haupt = stuecke.reduce((best, s, i) => (s[1] > stuecke[best][1] ? i : best), 0); const mindest = b.art === "mahlzeit" ? "3.6rem" : b.art === "pharmakologie" || b.art === "cardio" ? "6.5rem" : "2.6rem"; return stuecke.map(([von, lang], i) => ( )); } /* Die Erholung läuft über den Tag hinaus. Sie steht deshalb als Schweif hinter dem Training, der am rechten Rand ausläuft, und nicht als Kästchen im Tag: 36 Stunden in eine Spur von 24 gequetscht sähen aus wie ein halber Tag. */ function BldSchweif({ t, fokus, onFokus, reihe = 0 }) { const lang = (t.muskeln[0] && t.muskeln[0].regeneration_h) || 0; if (!lang) return null; const ende = t.start + t.dauer; const bis = ende + lang * 60; const breite = Math.min(bis, BLD_TAG) - ende; if (breite <= 0) return null; return ( ); } /* Abstände zum Schlaf als Klammer über den Spuren. Nur der Abstand, kein Urteil darüber: ob zwei Stunden viel oder wenig sind, braucht eine Quelle, und die kommt mit Phase 3. */ function BldKlammern({ abstaende, bausteine, fokus }) { const nach = Object.fromEntries(bausteine.map((b) => [b.id, b])); return ( ); } function BldZeitachse({ v, erg, fokus, onFokus, onNeu, onVerschieben, fenster, kinetik, darfPharma }) { const mahl = Object.fromEntries(((erg && erg.mahlzeiten) || []).map((m) => [m.id, m])); const train = (erg && erg.training) || []; const dauerVon = (b) => { const def = BLD_ARTEN[b.art]; return b.art === "mahlzeit" || b.art === "pharmakologie" ? def.dauer : (b.dauer || def.dauer); }; const imTag = (b) => b.art !== "pharmakologie" || bldImTag(b, kinetik); const lagen = Object.fromEntries(Object.keys(BLD_ARTEN).map((art) => [ art, bldUnterspuren(v.bausteine.filter((b) => b.art === art && imTag(b)), dauerVon)])); return (
{erg && }
{Object.entries(BLD_ARTEN).map(([art, def]) => { if (art === "pharmakologie" && !darfPharma) { return (
{BB.t(def.label)}
{BB.t("Mit Premium")}
); } // Was keine Uhrzeit kennt, steht links und bekommt je Eintrag eine // eigene Zeile. Nebeneinander schoben sie sich sonst nach rechts ueber // die Stoffe, die sehr wohl eine Uhrzeit haben. const ohneZeit = art === "pharmakologie" ? v.bausteine.filter((b) => b.art === art && !imTag(b)) : []; const hoehe = Math.max(lagen[art].hoehe, ohneZeit.length || 1); return (
{[6, 12, 18].map((h) => )} {art === "mahlzeit" && fenster > 0 && v.bausteine.filter((b) => b.art === "mahlzeit" && b.koffein_mg > 0).map((b) => ( bldStuecke(b.start, fenster).map(([von, lang], i) => ( )) ))} {art === "training" && train.map((t) => ( ))} {v.bausteine.filter((b) => b.art === art && imTag(b)).map((b) => ( ))} {art === "pharmakologie" && (
{ohneZeit.map((b) => { const k = (kinetik || {})[b.slug]; const ebene = k ? k.ebene : "unbekannt"; return ( ); })}
)}
); })}
); } /* --- Bearbeiten ------------------------------------------------------------ */ function BldSuche({ onNimm }) { const [q, setQ] = useState(""); const [treffer, setTreffer] = useState([]); useEffect(() => { const wert = q.trim(); if (wert.length < 2) { setTreffer([]); return undefined; } let alive = true; const t = setTimeout(() => { BB.get("/builder/lebensmittel" + BB.qs({ q: wert, limit: 8 })) .then((d) => { if (alive) setTreffer(d || []); }) .catch(() => { if (alive) setTreffer([]); }); }, 200); return () => { alive = false; clearTimeout(t); }; }, [q]); return (
setQ(e.target.value)} placeholder={BB.t("Lebensmittel suchen …")} aria-label={BB.t("Lebensmittel suchen …")} /> {treffer.length > 0 && (
{treffer.map((t) => ( ))}
)}
); } function BldMakroBalken({ p, kh, f, hoehe = 10 }) { const kcal = p * 4 + kh * 4 + f * 9; if (!kcal) return
; return (
); } function BldEditor({ b, erg, muskeln, onAendern, onLoeschen, onSchliessen, kinetik, wochen, stamm = {}, spiegelzeile = null }) { const art = BLD_ARTEN[b.art]; const setze = (patch) => onAendern(b.id, patch); return (
{BB.t(art.label)} {(b.art !== "pharmakologie" || bldImTag(b, kinetik)) && ( setze({ start: w })} anzeige={bldUhr} /> )} {b.art !== "mahlzeit" && b.art !== "pharmakologie" && ( setze({ dauer: w })} anzeige={bldDauer} /> )}
{b.art === "mahlzeit" && (
setze({ posten: (b.posten || []).concat({ id: t.id, gramm: 100 }) })} />
0 ? " on" : "")}> setze({ koffein_mg: w })} anzeige={(w) => w + " mg"} />
{(b.posten || []).map((p, i) => { const z = erg ? erg.posten[i] : null; return (
{z ? z.label : p.id} setze({ posten: b.posten.map((x, j) => (j === i ? { ...x, gramm: w } : x)) })} anzeige={(w) => w + " g"} /> {z ? Math.round(z.kcal) : "·"}
); })}
{erg && (
{Math.round(erg.protein)} P {Math.round(erg.kh)} KH {Math.round(erg.fett)} F {Math.round(erg.kcal)} kcal
)}
)} {b.art === "training" && (
{muskeln.map((m) => { const eintrag = (b.muskeln || []).find((x) => x.muskel === m.id); const an = !!eintrag; return (
{an && ( setze({ muskeln: b.muskeln.map((x) => (x.muskel === m.id ? { ...x, saetze: w } : x)) })} /> )}
); })}
)} {b.art === "pharmakologie" && (
{b.slug ? (
{b.label}
{BB.t("Woche")} setze({ von: w })} /> → setze({ bis: w })} />
) : ( setze({ slug: s.slug, label: s.label })} /> )} {spiegelzeile && spiegelzeile.schemata.length > 0 && (
{BB.t("Anreicherung")} · {BB.t(spiegelzeile.kompartiment || "")}
{spiegelzeile.schemata.map((s) => ( ))}
{spiegelzeile.aussage && ( <>

{BB.t(spiegelzeile.aussage)}

{spiegelzeile.quelle} )}
)} {spiegelzeile && !spiegelzeile.schemata.length && (

{BB.t("Zu diesem Stoff ist kein belegter Anreicherungsverlauf hinterlegt. Gezeigt wird nur der Einnahmezeitraum.")}

)}
)} {b.art === "cardio" && (
{(stamm.cardio || []).map((s) => ( ))}
{b.sportart && (
{((stamm.cardio || []).find((s) => s.id === b.sportart) || { stufen: [] }).stufen.map((st) => { const zone = (stamm.zonen || []).find((z) => z.id === st.zone) || {}; return ( ); })}
)}
)} {b.art === "schlaf" && (
{Object.entries(BLD_CHRONO).map(([k, c]) => ( ))}
)}
); } /* --- Wirkungsbild ---------------------------------------------------------- */ /* Die Tagesuhr: derselbe Tag als Kreis. Die Spuren oben zeigen die Reihenfolge, die Uhr zeigt die Verteilung, also wo der Tag voll ist und wo leer. */ function BldUhr({ v, erg, fokus, onFokus }) { const R = 78; const winkel = (min) => (min / BLD_TAG) * Math.PI * 2 - Math.PI / 2; const punkt = (min, r) => [100 + r * Math.cos(winkel(min)), 100 + r * Math.sin(winkel(min))]; const bogen = (von, dauer, r) => { const lang = Math.min(dauer, BLD_TAG - 1); const [x1, y1] = punkt(von, r); const [x2, y2] = punkt(von + lang, r); return `M ${x1} ${y1} A ${r} ${r} 0 ${lang > BLD_TAG / 2 ? 1 : 0} 1 ${x2} ${y2}`; }; const kcal = Object.fromEntries(((erg && erg.mahlzeiten) || []).map((m) => [m.id, m.kcal])); const maxKcal = Math.max(1, ...Object.values(kcal)); const leise = (id) => (fokus && fokus !== id ? 0.25 : 1); return ( {Array.from({ length: 24 }, (_, h) => { const [x1, y1] = punkt(h * 60, R + 6); const [x2, y2] = punkt(h * 60, R + (h % 6 ? 10 : 14)); return ; })} {[0, 6, 12, 18].map((h) => { const [x, y] = punkt(h * 60, R + 24); return {String(h).padStart(2, "0")}; })} {v.bausteine.filter((b) => b.art === "schlaf").map((b) => ( onFokus(b.id)} /> ))} {v.bausteine.filter((b) => b.art === "training").map((b) => ( onFokus(b.id)} /> ))} {v.bausteine.filter((b) => b.art === "mahlzeit").map((b) => { const [x, y] = punkt(b.start, R - 34); const r = 4 + 9 * Math.sqrt((kcal[b.id] || 0) / maxKcal); return onFokus(b.id)} />; })} ); } function BldKarte({ titel, kopf, children, breit }) { return (
{titel}{kopf}
{children}
); } /* Das Proteinband: das Studienband als Fläche, der eigene Wert als Marke. Keine Wertung daneben. Wo die Marke liegt, sieht man. */ function BldProteinBand({ ergebnisse }) { const band = ergebnisse.find((e) => e.erg) && ergebnisse.find((e) => e.erg).erg.protein.band; if (!band) return null; const MAX = 3; const x = (w) => Math.max(0, Math.min(100, (w / MAX) * 100)) + "%"; return (
{ergebnisse.map(({ name, erg }) => erg && erg.protein.g_je_kg != null && ( {name} ))}
{[0, 1, 2, 3].map((w) => {w})}
{band.einheit} · {band.quelle}
); } function BldEnergie({ ergebnisse }) { const max = Math.max(1, ...ergebnisse.map(({ erg }) => (erg ? erg.summe.kcal : 0))); return (
{ergebnisse.map(({ name, erg }) => { const s = erg ? erg.summe : { kcal: 0, protein: 0, kh: 0, fett: 0 }; return (
{name}
{Math.round(s.kcal)}
); })}
{BB.t("Protein")} {BB.t("Kohlenhydrate")} {BB.t("Fett")}
); } function BldErholung({ erg, fokus, onFokus }) { const zeilen = ((erg && erg.training) || []).flatMap((t) => t.muskeln.map((m) => ({ ...m, quelle: t.id }))); if (!zeilen.length) return ; const MAX = 72; return (
{[0, 24, 48, 72].map((h) => {h})}
{zeilen.map((z, i) => ( ))}
); } function BldAbstaende({ erg, fokus, onFokus }) { const liste = (erg && erg.abstaende) || []; if (!liste.length) return ; return (
{liste.map((a, i) => { const an = fokus && (fokus === a.von || fokus === a.nach); return ( ); })}
); } /* Was Studien zu genau dieser Konstellation gefunden haben. Jede Kachel nennt ihre Quelle und ihren Umfang; keine sagt, was zu tun ist. Die Farbe sagt nur, ob die Konstellation der Studie im Plan vorliegt, nicht ob der Plan gut ist. */ const BLD_REGEL_ICONS = { training_vor_schlaf: ["training", "schlaf"], koffein_vor_schlaf: ["koffein", "schlaf"], schlafentzug_erholung: ["schlaf", "training"], }; function BldBelege({ erg, fokus, onFokus }) { const regeln = (erg && erg.regeln) || []; if (!regeln.length) return ; return (
{regeln.map((r, i) => { const [a, b] = BLD_REGEL_ICONS[r.id] || ["schlaf", "schlaf"]; const an = fokus && (fokus === r.von || fokus === r.nach); return (
r.von && onFokus(r.von)}>
{r.minuten != null && {bldDauer(r.minuten)}} {r.dosis_mg != null && ( {r.dosis_mg} mg/ {r.dosis_studie_mg} )}
{r.werte && (
{r.werte.map((w) => ( {w.richtung < 0 ? "↓" : "↑"} {w.prozent} %{BB.t(w.was)} ))}
)}

{BB.t(r.aussage)}

e.stopPropagation()}> {r.quelle} · {BB.t(r.umfang)}
); })}
); } function BldLeer({ art }) { return (
); } /* Alles, was fuer den ganzen Tag gilt, an einer Stelle statt verteilt: Koerper, Alltagsbewegung, Herzfrequenzzonen. Zugeklappt eine Zeile, denn wer einmal eingetragen hat, braucht es nicht wieder. */ function BldAllgemein({ v, stamm, energie, onAendern }) { const [auf, setAuf] = useState(!v.geschlecht || !v.groesse || !v.alter); const setze = (patch) => onAendern(patch); const zonen = (energie && energie.zonen) || []; return (
{auf && (
{BB.t("Geschlecht")}
{[["m", "Mann"], ["w", "Frau"]].map(([k, l]) => ( ))}
{BB.t("Gewicht")} setze({ gewicht: w })} anzeige={(w) => w + " kg"} />
{BB.t("Größe")} setze({ groesse: w })} anzeige={(w) => w + " cm"} />
{BB.t("Alter")} setze({ alter: w })} />
{BB.t("Schritte am Tag")} setze({ schritte: w })} anzeige={BB.num} />
{v.schritte ? BB.t("Alltag: aus den Schritten gerechnet") : BB.t("Alltag")}
{(stamm.pal || []).map((x) => ( ))}
{zonen.length > 0 && (
{BB.t("Herzfrequenzzonen")} · {energie.hfmax} {BB.t("max")}
{zonen.map((z) => (
{BB.t(z.wort)}{z.von}–{z.bis}
))}
{energie.hfmax_quelle}
)}
)}
); } /* Das Energieband. Keine Zahl, sondern eine Spanne, und darunter steht, woraus sie sich zusammensetzt. Jeder Teil traegt seine eigene Breite. */ const BLD_TEIL_LABEL = { grundumsatz: "Ruheumsatz", alltag: "Alltag", schritte: "Schritte", training: "Training", cardio: "Cardio", }; function BldEnergieband({ energie }) { if (!energie) return null; if (!energie.moeglich) { return (
{BB.t("Für den Verbrauch fehlen noch Angaben im Block „Allgemein“.")}
); } const max = Math.max(energie.bis, 1); let gelaufen = 0; return (
{BB.num(energie.von)} {BB.t("bis")} {BB.num(energie.bis)} kcal
{energie.teile.map((teil) => { const links = (gelaufen / max) * 100; gelaufen += teil.kcal; return ( ); })}
{energie.teile.map((teil) => ( {BB.t(BLD_TEIL_LABEL[teil.was] || teil.was)} {BB.num(teil.kcal)} ± {BB.num(teil.spanne)} ))}
); } /* --- Pharmakologie -------------------------------------------------------- */ const BLD_KATEGORIEN = { muskel: "Muskel", stoffwechsel: "Stoffwechsel", hormone: "Hormone", herz_kreislauf: "Herz und Kreislauf", blut: "Blut", leber: "Leber", niere: "Niere", zns: "Gehirn und Nerven", haut: "Haut", prostata: "Prostata", entzuendung: "Entzündung", zellstress: "Zellstress", }; function BldStoffSuche({ onNimm }) { const [q, setQ] = useState(""); const [treffer, setTreffer] = useState([]); useEffect(() => { const wert = q.trim(); if (wert.length < 2) { setTreffer([]); return undefined; } let alive = true; const t = setTimeout(() => { BB.get("/builder/stoffe" + BB.qs({ q: wert })) .then((d) => { if (alive) setTreffer(d || []); }) .catch(() => { if (alive) setTreffer([]); }); }, 200); return () => { alive = false; clearTimeout(t); }; }, [q]); return (
setQ(e.target.value)} autoFocus placeholder={BB.t("Stoff suchen …")} aria-label={BB.t("Stoff suchen …")} /> {treffer.length > 0 && (
{treffer.map((s) => ( ))}
)}
); } /* Das Protokoll: dieselben Stoffe in Wochen. Ein Balken je Stoff, gegriffen wird er wie ein Baustein im Tag, nur rastet er auf ganzen Wochen ein. */ /* Der Spiegel als Flaeche hinter dem Einnahmebalken: wie viel vom Plateau zu welchem Zeitpunkt steht. Belegt ist, wann das Plateau erreicht war; die Form dazwischen ist eine Saettigungskurve, und die Flaeche ist deshalb bewusst blass und ohne Achsenbeschriftung. Ohne Beleg bleibt die Flaeche leer. */ function BldSpiegel({ zeile, wochen }) { if (!zeile || !zeile.kurve) return null; const tage = zeile.tage || wochen * 7; const x = (tag) => ((zeile.von_tag + tag) / tage) * 100; const punkte = zeile.kurve.map(([tag, wert]) => `${x(tag)},${100 - wert * 100}`).join(" "); const flaeche = `${x(0)},100 ${punkte} ${x(zeile.kurve[zeile.kurve.length - 1][0])},100`; return ( ); } function BldProtokoll({ v, kinetik, fokus, onFokus, onNeu, onAendern, onWochen, spiegel }) { const wochen = v.wochen || 12; const stoffe = v.bausteine.filter((b) => b.art === "pharmakologie"); const ziehen = useRef(null); function runter(e, b) { const feld = e.currentTarget.closest(".bld-proto-feld"); if (!feld) return; e.currentTarget.setPointerCapture(e.pointerId); ziehen.current = { b, x: e.clientX, breite: feld.getBoundingClientRect().width, bewegt: false }; } function bewegen(e) { const z = ziehen.current; if (!z) return; const dx = e.clientX - z.x; if (Math.abs(dx) > 3) z.bewegt = true; if (!z.bewegt) return; const schritt = Math.round((dx / z.breite) * wochen); const lang = (z.b.bis || wochen) - (z.b.von || 1); const von = Math.max(1, Math.min(wochen - lang, (z.b.von || 1) + schritt)); onAendern(z.b.id, { von, bis: von + lang }); } function hoch(b) { const z = ziehen.current; ziehen.current = null; if (z && !z.bewegt) onFokus(fokus === b.id ? null : b.id); } return (
w + " W"} />
{Array.from({ length: wochen }, (_, i) => ( {i + 1} ))}
{stoffe.map((b) => { const k = (kinetik || {})[b.slug]; const von = b.von || 1; const bis = Math.min(b.bis || wochen, wochen); return (
{Array.from({ length: wochen - 1 }, (_, i) => ( ))} x.id === b.id)} wochen={wochen} />
); })}
); } /* Die Endpunktkarte: je Endpunkt, welcher Stoff ihn berührt, in welche Richtung und wie gut belegt. Keine Beträge. Die Deckkraft folgt der Konfidenz des Weges, die Farbe der Richtung, das Kürzel der Datenlage. */ function BldEndpunktkarte({ pharma, stoffe, fokus, onFokus, offen, setOffen }) { const label = Object.fromEntries(stoffe.map((b) => [b.slug, b.label || b.slug])); const gruppen = []; (pharma.endpunkte || []).forEach((e) => { const g = gruppen.find((x) => x.k === e.kategorie); if (g) g.e.push(e); else gruppen.push({ k: e.kategorie, e: [e] }); }); if (!gruppen.length) return ; return (
▲ {BB.t("hoch")} ▼ {BB.t("runter")} ● {BB.t("Richtung offen")} {Object.entries(BLD_STUFEN).map(([k, s]) => {s.kurz})} {BB.t("Sortiert nach Datenlage, nicht nach Wirkung")}
{gruppen.map((g) => (
{BB.t(BLD_KATEGORIEN[g.k] || g.k)}
{g.e.map((e) => { const auf = offen === e.endpoint; const an = fokus && e.effekte.some((x) => x.baustein === fokus); return (
{e.effekte.map((x, i) => ( ))}
{auf && }
); })}
))}
{(pharma.ohneKnoten || []).map((s) => ( ?{label[s] || s} ))} {(pharma.ohneWege || []).map((s) => ( ∅{label[s] || s} ))} {pharma.ohneDosis && {BB.t("Ohne Dosis gerechnet")}}
); } /* Der Rezeptorblick: wo die treibenden Proteine abgelesen werden. Eine Ableserate in mRNA aus dem HPA, keine Rezeptordichte, und die Fußzeile sagt das, damit der Balken nicht nach Wirkort aussieht. */ function BldRezeptor({ effekte, proteine }) { const gene = [...new Set(effekte.flatMap((x) => x.treiber || []).filter(Boolean))]; const mit = gene.filter((g) => (proteine[g] || []).length); if (!mit.length) return
{BB.t("Für diese Proteine liegt keine Gewebeverteilung vor.")}
; return (
{mit.map((g) => (
{g} {proteine[g].map((x) => (
{BB.t(x.label)}
))}
))} {BB.t("Ableserate des Gens (mRNA, HPA), keine Rezeptordichte.")}
); } /* Was sich messen ließe. Das einzige Stück des Builders, das etwas vorschlägt, und es schlägt eine Blutabnahme vor und keinen Stoff. Gebündelt je Laborwert: wer über drei Endpunkte dreimal GGT liest, liest dreimal dieselbe Abnahme. */ function BldMarker({ marker, fokus, onFokus }) { const [alleOhne, setAlleOhne] = useState(false); const liste = (marker && marker.marker) || []; const ohne = (marker && marker.ohne) || []; if (!liste.length && !ohne.length) return ; return (
{liste.map((m) => (
{BB.t(m.label)} {BB.t(m.guete)} {m.einheit && {m.einheit}}
{m.endpunkte.map((e) => ( ))}
))}
{ohne.length > 0 && (
{alleOhne && (
{ohne.map((o) => (
{BB.t(o.label)}{BB.t(o.grund)}
))}
)}
)}
); } /* Die Zielkarte. Eigene Cytoscape-Instanz, nicht die der Molekuelkarte: dieselbe Bibliothek, eigener Behaelter, eigenes Stylesheet. Die Molekuelkarte bleibt unberuehrt, und der Builder kann Knoten hinzufuegen und entfernen, ohne dass dort etwas nachgezeichnet werden muss. Gruppen sind zusammengesetzte Knoten (`parent`), damit die Flaeche dahinter wirklich die Gruppe ist und kein daruebergelegtes Rechteck, das beim Zoomen verrutscht. */ function BldZielkarte({ daten, ziele, onZiele, fokus, onFokus }) { const hostRef = useRef(null); const cyRef = useRef(null); const [offen, setOffen] = useState(null); useEffect(() => { if (!hostRef.current || !window.cytoscape) return undefined; /* Cytoscape zeichnet auf Canvas und loest keine CSS-Variablen auf. Die Farben werden deshalb einmal aus dem Dokument gelesen, wie es die Molekuelkarte mit `mkPalette` tut. */ const cs = getComputedStyle(hostRef.current); const f = Object.fromEntries(["accent", "ep", "fg", "fg2", "fg3", "fg4", "hoch", "ink", "runter", "stoff", "gruppe-bg", "gruppe-an", "gruppe-line"].map( (n) => [n, cs.getPropertyValue("--bld-" + n).trim()])); const cy = window.cytoscape({ container: hostRef.current, minZoom: 0.2, maxZoom: 2.5, style: [ /* Cytoscape wendet `background-opacity` getrennt an und ignoriert das Alpha im Farbwert. Die Flaeche ist deshalb eine Volltonfarbe mit eigener Deckkraft. Der Glow der gewaehlten Gruppe ist ein `overlay`, das ist das einzige Mittel der Bibliothek fuer einen Schein um ein Element herum. */ { selector: "node[art='gruppe']", style: { "background-color": f.fg, "background-opacity": 0.04, "border-width": 2, "border-color": f["gruppe-line"], "border-opacity": 1, shape: "round-rectangle", padding: 30, label: "data(label)", "text-valign": "top", "text-halign": "center", "text-margin-y": -12, color: f.fg2, "font-size": 15, "font-weight": 700, "font-family": "Instrument Sans, sans-serif", } }, { selector: "node[art='gruppe'].an", style: { "border-color": f.accent, "border-width": 2, "background-color": f.accent, "background-opacity": 0.1, color: f.accent, "overlay-color": f.accent, "overlay-opacity": 0.14, "overlay-padding": 14, } }, { selector: "node[art='endpunkt']", style: { "background-color": f.ep, width: 14, height: 14, label: "data(label)", "font-size": 11, color: f.fg3, "text-valign": "bottom", "text-halign": "center", "text-margin-y": 5, "text-wrap": "wrap", "text-max-width": 110, "font-family": "Instrument Sans, sans-serif", } }, { selector: "node[art='endpunkt'][treffer > 0]", style: { "background-color": f.accent, width: 22, height: 22, color: f.fg, "font-weight": 600, } }, { selector: "node[art='stoff']", style: { "background-color": f.stoff, shape: "round-rectangle", width: "label", height: 26, padding: 9, label: "data(label)", "font-size": 13, "font-weight": 700, color: f.ink, "text-valign": "center", "text-halign": "center", "font-family": "Instrument Sans, sans-serif", } }, { selector: "edge", style: { width: 1.5, "curve-style": "bezier", opacity: 0.55, "line-color": f.fg4, "target-arrow-color": f.fg4, "target-arrow-shape": "triangle", "arrow-scale": 0.8, } }, { selector: "edge[richtung='hoch']", style: { "line-color": f.hoch, "target-arrow-color": f.hoch } }, { selector: "edge[richtung='runter']", style: { "line-color": f.runter, "target-arrow-color": f.runter } }, { selector: "edge[stufe='measured']", style: { width: 3, opacity: 0.9 } }, { selector: ".leise", style: { opacity: 0.12 } }, ], }); cyRef.current = cy; cy.on("tap", "node[art='gruppe']", (e) => setOffen(e.target.id())); cy.on("tap", "node[art='stoff']", (e) => onFokus(e.target.data("slug"))); cy.on("tap", (e) => { if (e.target === cy) { setOffen(null); onFokus(null); } }); return () => { cy.destroy(); cyRef.current = null; }; }, []); // Daten tauschen, Anordnung neu legen. `cose` ordnet zusammengesetzte Knoten // von sich aus so, dass die Gruppen auseinanderliegen. useEffect(() => { const cy = cyRef.current; if (!cy || !daten) return; cy.elements().remove(); cy.add((daten.knoten || []).map((n) => ({ data: { id: n.id, art: n.art, label: n.label, parent: n.gruppe, slug: n.slug, treffer: n.treffer || 0 } }))); cy.add((daten.kanten || []).map((k) => ({ data: { id: k.id, source: k.von, target: k.nach, richtung: k.richtung, stufe: k.stufe } }))); cy.layout({ name: "cose", animate: false, nodeRepulsion: 42000, idealEdgeLength: 120, nestingFactor: 1.1, gravity: 0.3, componentSpacing: 120, padding: 40, nodeOverlap: 30 }).run(); cy.fit(undefined, 40); }, [daten]); // Eine Gruppe anklicken heisst: dorthin fahren und hineinzoomen. useEffect(() => { const cy = cyRef.current; if (!cy) return; cy.elements().removeClass("an leise"); if (!offen) { cy.animate({ fit: { eles: cy.elements(), padding: 40 } }, { duration: 420 }); return; } const gruppe = cy.$id(offen); if (!gruppe.length) return; gruppe.addClass("an"); const dazu = gruppe.descendants(); const nachbarn = dazu.connectedEdges().connectedNodes(); cy.elements().difference(gruppe.union(dazu).union(nachbarn).union(dazu.connectedEdges())) .addClass("leise"); cy.animate({ fit: { eles: gruppe.union(nachbarn), padding: 60 } }, { duration: 420, easing: "ease-out-cubic" }); }, [offen, daten]); return (
{((daten && daten.verfuegbar) || []).map((z) => ( ))}
{!ziele.length ? (
{BB.t("Wähle oben ein Ziel oder mehrere.")}
) : ( <>
{offen ? : {BB.t("Klick auf eine Gruppe zoomt hinein")}} {(daten && daten.leer || []).length > 0 && ( {BB.t("Ohne Berührung")}: {daten.leer.map((x) => BB.t(x)).join(", ")} )}
)}
); } /* --- Seite ------------------------------------------------------------------- */ function BuilderPage() { const { user, ready } = useAuth(); const [stand, setStand] = useState(bldLaden); const [fokus, setFokus] = useState(null); const [ansicht, setAnsicht] = useState("tag"); const [ergebnisse, setErgebnisse] = useState({}); const [stamm, setStamm] = useState(null); const [fehler, setFehler] = useState(""); const [pharma, setPharma] = useState({}); const [pharmaFehler, setPharmaFehler] = useState(""); const [offen, setOffen] = useState(null); const [karte, setKarte] = useState(null); const aktiv = stand.aktiv; const v = stand.varianten[aktiv]; // Mahlzeit, Training, Schlaf und Cardio rechnen gegen gepflegte Daten und // sind offen. Die Pharmakologie zeigt denselben Inhalt wie die Molekuelkarte // und haengt deshalb an derselben Mitgliedschaft. // Dieselbe Regel wie `require_premium` am Server und wie in der // Molekuelkarte. Sie steht hier nur, um die richtige Ansicht zu waehlen; // entschieden wird sie am Server. const darfPharma = !!user && (user.plan === "premium" || user.role === "admin" || user.role === "mod"); useEffect(() => { try { localStorage.setItem(BLD_SPEICHER, JSON.stringify(stand)); } catch (e) { /* egal */ } }, [stand]); useEffect(() => { BB.get("/builder/stammdaten").then(setStamm).catch((err) => setFehler(err.message)); }, []); /* Jede Variante wird für sich gelesen. Die Antwort kommt mit kurzer Pause, damit nicht jeder Schritt beim Ziehen eine Anfrage auslöst. */ const schluessel = JSON.stringify(stand.varianten); useEffect(() => { let alive = true; const t = setTimeout(() => { Object.entries(stand.varianten).forEach(([name, va]) => { BB.post("/builder/tag", { gewicht_kg: va.gewicht, groesse_cm: va.groesse, alter: va.alter, geschlecht: va.geschlecht, schritte: va.schritte, pal: va.pal, bausteine: va.bausteine, }) .then((d) => { if (alive) { setErgebnisse((e) => ({ ...e, [name]: d })); setFehler(""); } }) .catch((err) => { if (alive) setFehler(err.message); }); }); }, 250); return () => { alive = false; clearTimeout(t); }; }, [schluessel]); /* Die Pharmakologie wird getrennt gelesen: andere Schranke (Premium, wie die Molekülkarte), und sie hängt nur an den Stoffen, nicht an Uhrzeiten. */ const pharmaSchluessel = JSON.stringify(Object.fromEntries(Object.entries(stand.varianten).map( ([n, va]) => [n, [va.wochen || 12].concat(va.bausteine .filter((b) => b.art === "pharmakologie" && b.slug) .map((b) => [b.slug, b.id, b.von, b.bis, b.schema].join("|")))]))); useEffect(() => { if (!darfPharma) return undefined; let alive = true; const t = setTimeout(() => { Object.entries(stand.varianten).forEach(([name, va]) => { const bs = va.bausteine.filter((b) => b.art === "pharmakologie" && b.slug); if (!bs.length) { setPharma((e) => ({ ...e, [name]: null })); return; } BB.post("/builder/pharmakologie", { bausteine: bs, wochen: va.wochen || 12 }) .then((d) => { if (alive) { setPharma((e) => ({ ...e, [name]: d })); setPharmaFehler(""); } }) .catch((err) => { if (alive) setPharmaFehler(err.message); }); }); }, 300); return () => { alive = false; clearTimeout(t); }; }, [pharmaSchluessel, darfPharma]); /* Die Zielkarte haengt an denselben Stoffen plus der Zielauswahl. Sie wird getrennt geholt, damit ein Klick auf ein Ziel nicht die ganze Pharmakologie neu rechnet. */ const zieleKey = (v.ziele || []).join(","); useEffect(() => { if (!darfPharma) return undefined; const bs = v.bausteine.filter((b) => b.art === "pharmakologie" && b.slug); let alive = true; const t = setTimeout(() => { BB.post("/builder/karte", { bausteine: bs, ziele: v.ziele || [] }) .then((d) => { if (alive) setKarte(d); }) .catch(() => {}); }, 300); return () => { alive = false; clearTimeout(t); }; }, [zieleKey, pharmaSchluessel, darfPharma]); const aendereVariante = (patch) => setStand((s) => ({ ...s, varianten: { ...s.varianten, [s.aktiv]: { ...s.varianten[s.aktiv], ...patch } }, })); const aendereBaustein = (id, patch) => aendereVariante({ bausteine: v.bausteine.map((b) => (b.id === id ? { ...b, ...patch } : b)), }); const neu = (art) => { const def = BLD_ARTEN[art]; const b = { id: bldId(), art, start: def.start }; if (art !== "mahlzeit") b.dauer = def.dauer; if (art === "mahlzeit") b.posten = []; if (art === "training") b.muskeln = []; if (art === "schlaf") b.chronotyp = "mittel"; if (art === "pharmakologie") Object.assign(b, { slug: "", label: "", von: 1, bis: v.wochen || 12 }); aendereVariante({ bausteine: v.bausteine.concat(b) }); setFokus(b.id); }; const loeschen = (id) => { aendereVariante({ bausteine: v.bausteine.filter((b) => b.id !== id) }); setFokus(null); }; const variante = (name) => setStand((s) => { if (s.varianten[name]) return { ...s, aktiv: name }; // Eine neue Variante beginnt als Kopie: verglichen wird ein Tausch, nicht // zwei Pläne, die sich in allem unterscheiden. const kopie = JSON.parse(JSON.stringify(s.varianten[s.aktiv])); return { ...s, aktiv: name, varianten: { ...s.varianten, [name]: kopie } }; }); const varianteWeg = (name) => { setStand((s) => { const rest = { ...s.varianten }; delete rest[name]; return { aktiv: "A", varianten: rest }; }); setErgebnisse((e) => { const n = { ...e }; delete n[name]; return n; }); }; if (!ready) return
; const erg = ergebnisse[aktiv]; const pharmaErg = pharma[aktiv]; const kinetik = Object.fromEntries(((pharmaErg && pharmaErg.stoffe) || []).map((s) => [s.slug, s.kinetik])); const gewaehlt = v.bausteine.find((b) => b.id === fokus); const ergGewaehlt = gewaehlt && gewaehlt.art === "mahlzeit" && erg ? erg.mahlzeiten.find((m) => m.id === gewaehlt.id) : null; const liste = Object.keys(stand.varianten).sort().map((name) => ({ name, erg: ergebnisse[name] })); return (
{["A", "B"].map((name) => ( ))} {stand.varianten.B && ( )}
{fehler &&
{fehler}
} {ansicht === "tag" ? ( <> aendereBaustein(id, { start })} fenster={(((stamm && stamm.regeln) || {}).koffein_vor_schlaf || {}).fenster_min || 0} kinetik={kinetik} darfPharma={darfPharma} /> {gewaehlt && ( x.id === (gewaehlt || {}).id) || null} onAendern={aendereBaustein} onLoeschen={loeschen} onSchliessen={() => setFokus(null)} /> )}
{BB.t("Zufuhr und Verbrauch")}}> h}>
) : ( !darfPharma ? (
{BB.t("Die Pharmakologie gehört zur Mitgliedschaft")} {BB.t("Premium entdecken")}
) : ( <> aendereVariante({ wochen: w, bausteine: v.bausteine.map((b) => (b.art === "pharmakologie" ? { ...b, von: Math.min(b.von || 1, w), bis: Math.min(b.bis || w, w) } : b)), })} /> {gewaehlt && ( x.id === (gewaehlt || {}).id) || null} onAendern={aendereBaustein} onLoeschen={loeschen} onSchliessen={() => setFokus(null)} /> )} {pharmaFehler ? (
{pharmaFehler}
) : ( <> {BB.t("Was deine Stoffe darin berühren")}} breit> aendereVariante({ ziele: z })} fokus={fokus} onFokus={setFokus} /> {BB.t("Vorgeschlagen werden Messungen, keine Stoffe")}} breit> {pharmaErg ? : } {BB.t("Klick auf einen Endpunkt zeigt die Gewebe")}} breit> {pharmaErg ? b.art === "pharmakologie")} fokus={fokus} onFokus={setFokus} offen={offen} setOffen={setOffen} /> : } )} ) )}
); } function BldKopf({ children }) { return (

{BB.t("Builder")}

BETA
{children}
); } Object.assign(window, { BuilderPage });