/* ==========================================================================
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 (
{abstaende.map((a, i) => {
const s = nach[a.nach];
if (!s) return null;
const bis = s.start;
const von = (bis - a.minuten + BLD_TAG) % BLD_TAG;
if (von > bis) return null; /* über Mitternacht: im Wirkungsbild, nicht hier */
const an = fokus && (fokus === a.von || fokus === a.nach);
return (
);
}
// 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 (
)}
);
}
/* --- 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 (
);
}
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 (
);
}
/* 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 (
);
}
/* 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 && (
)}
);
}
/* 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 (
);
}
/* 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 (
);
}
/* 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")}
);
}
/* 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 (