/* ========================================================================== MOLEKUELKARTE: Netzwerk aus Substanzen, Metaboliten, Rezeptoren, Enzymen, Transportern und regulatorischen Proteinen. Zwei Wege hinein. Die Suche schlaegt Knoten vor und setzt die Karte auf den gewaehlten neu auf, mit der eingestellten Reichweite. Danach waechst die Karte durch Anklicken: Linksklick holt die Nachbarn eines Knotens dazu, Rechtsklick nimmt wieder weg, was nur ueber ihn haengt. Die Ansicht wird dabei nie neu ausgerichtet. Wer eine Stelle im Netz gefunden hat, soll sie nicht dadurch verlieren, dass er sie erweitert: neue Knoten kommen aus dem angeklickten Knoten heraus und fahren an ihren Platz, alles andere bleibt stehen. Ausgerichtet wird nur, wenn die Karte wirklich eine neue Ansicht zeigt, siehe `ansicht` weiter unten. ========================================================================== */ const MK_TYPEN = [ "substance", "metabolite", "receptor", "enzyme", "transporter", "regulator", "endpoint", ]; /* Ab hier ist die Kantenbeschriftung gross genug, um sie zu lesen. Darunter wird sie ausgeblendet, statt als Strichmuster ueber den Linien zu liegen. */ const LESBARER_ZOOM = 0.75; /* Ab hier steht der volle Stoffname unter der Bubble statt der Abkuerzung darin. Seit die Karte auch fremde Wirkstoffe kennt, sind die meisten Namen laenger als die sieben Zeichen, die in eine Bubble passen: „mifepr…" und „methyl…" sind keine Auskunft. In den Kreis bekommt man sie nicht, ohne ihn so gross zu machen, dass die Karte auseinanderfaellt. Also darunter, und erst dann, wenn genug Platz zwischen den Knoten ist. Die Schwelle liegt bewusst ueber MK_EINSTIEG_ZOOM: beim Aufsetzen einer Ansicht bleibt es bei den Abkuerzungen, sonst ueberlagern sich die Namen zweier Nachbarn im Ring. Sichtbar werden sie, wenn jemand hineinzoomt, und dort ist der Abstand dann auch da. */ const VOLLER_NAME_ZOOM = 1.5; /* Wie weit ein Klick oder eine Suche aufmacht. Deckel wie in query.py: mehr als drei Schritte ist keine Umgebung mehr, sondern die halbe Karte. */ const MK_REICHWEITEN = [1, 2, 3]; /* Wie viele Knoten ein Klick oder eine Suche hoechstens aufmacht. Das war bis zum 2026-09-22 ein Regler („Je Klick": wenig, mittel, alles). Er stand da, weil ein Klick auf ESR1 alle 196 Stoffe daran hereinholte -- und er war die falsche Antwort darauf. Eine Zahl sagt nicht, *welche* hundert Stoffe wegfallen; sie schneidet irgendwo ab. Die Stoffauswahl weiter unten beantwortet dieselbe Frage mit einer Aussage statt mit einem Deckel: „nur Testosteron" statt „die staerksten 25". Der Deckel bleibt als Notbremse stehen, nur nicht mehr als Knopf. */ const MK_UMFANG = 25; /* Welchen Pfeilen ein Klick folgt. Die Richtung traegt hier eine Aussage und ist nicht die zufaellige Reihenfolge zweier Kennungen: bei einer Bindung steht der Stoff vorn und sein Ziel hinten, bei einer Umwandlung das Substrat vorn und das Produkt hinten. Wer einem Weg folgt, will das eine und nicht das andere. */ const MK_RICHTUNGEN = [ ["beide", "Beide Richtungen"], ["aus", "Ausgehend →"], ["ein", "Eingehend ←"], ]; /* Welche Belege die Karte zeichnet, in derselben Reihenfolge wie in `query.EVIDENZ_MODI`. „Bevorzugt" ist die Voreinstellung: eine vorhergesagte Bindung wird nur dort gezeichnet, wo an derselben Stelle keine gemessene steht. Beide nebeneinander sind keine zwei Auskuenfte, sondern eine und ihr blasserer Schatten. */ const MK_BELEGE = [ ["bevorzugt", "Gemessen, sonst vorhergesagt"], ["both", "Beides nebeneinander"], ["measured", "Nur gemessen"], ["predicted", "Nur vorhergesagt"], ]; /* Welche Stoffe die Karte zeigt. Drei Stufen, eine Auswahl. `auswahl` ist die Stufe, die es vorher nicht gab und die das Aufklappen brauchbar macht: die Art „Substanz" bleibt an, aber nur die von Hand gewaehlten Stoffe kommen mit. Wer ESR1 aufklappt, sieht dann seinen Testosteron-Knoten und nicht 196 fremde Wirkstoffe -- und muss dafuer nicht die ganze Art abwaehlen, mit der auch Testosteron verschwaende. */ const MK_STOFFKREISE = [ ["alle", "Alle Stoffe"], ["seeds", "Nur aus dem Wissen"], ["auswahl", "Nur ausgewählte"], ]; /* Bis zu dieser Groesse wird eine Fokusansicht als Ringe gezeichnet, denn dann ist die Reichweite auf einen Blick abzulesen. Darueber wuerde der aeussere Ring so gross, dass die Beschriftung nur noch als Strich ankommt: ab da uebernimmt das Kraeftelayout, und die Entfernung traegt die Knotengroesse. Gerechnet: der aeussere Ring braucht je Knoten rund 88 Pixel Umfang, bei 30 Knoten sind das etwa 340 Pixel Radius. Bei lesbarem Zoom passt das noch in die Leinwand, das Doppelte nicht mehr. */ const MK_RINGE_BIS = 32; /* Obergrenze fuers Einpassen. Ein Knoten mit fuenf Nachbarn fuellt die Leinwand sonst mit Bubbles von 90 Pixeln, und die Karte sieht aus wie ein Plakat. Nach unten begrenzt LESBARER_ZOOM, nach oben dieser Wert. */ const MK_EINSTIEG_ZOOM = 1.35; /* Mindestabstand zweier Knotenmitten beim Einsetzen neuer Knoten. Zwei Bubbles sind 42 breit, darunter kleben sie aneinander. */ const MK_ABSTAND = 74; /* Wie weit um einen Anker herum geschaut wird, um die freie Richtung zu finden. Etwa zweieinhalb Ringradien: was weiter weg steht, ist dem neuen Knoten nicht im Weg und darf ihm die Richtung nicht verbauen. */ const MK_NAHBEREICH = 320; const MK_TYP_LABEL = { substance: "Substanz", metabolite: "Metabolit", receptor: "Rezeptor", enzyme: "Enzym", transporter: "Transporter", regulator: "Regulator", endpoint: "Analyse-Endpunkt", transit: "Signal-Zwischenstufe", }; const MK_MECHANISMUS_LABEL = { agonist: "Agonist", antagonist: "Antagonist", inhibitor: "Inhibitor", partial_agonist: "Partieller Agonist", binding: "Bindung", substrate: "Substrat", produces: "Umwandlung", transport: "Transport", inverse_agonist: "Inverser Agonist", releasing_agent: "Freisetzer", degrader: "Degrader", positive_modulator: "Positiver Modulator", negative_modulator: "Negativer Modulator", /* Keine Kante aus den Quellen, sondern die Linie, die ein gerenderter Weg braucht, wo die Karte selbst nichts hat: `pfade._kettenluecken`. Sie steht hier, damit sie im Ausschnitt eine Beschriftung traegt statt einer leeren. */ curated: "Gepflegter Weg", }; /* Farben aus den festen Molekülkarten-Tokens (siehe :root in app.css). Die Karte bleibt bewusst grün, unabhängig vom hellen oder dunklen Seitentheme. Cytoscape versteht kein var(...), deshalb einmal beim Mounten aufloesen. */ function mkPalette() { const cs = getComputedStyle(document.documentElement); const v = (n) => cs.getPropertyValue(n).trim(); return { typ: { substance: v("--map-accent"), metabolite: v("--k-tierlist"), receptor: v("--viz-1"), enzyme: v("--viz-2"), transporter: v("--viz-3"), regulator: v("--viz-4"), endpoint: v("--k-wiki"), /* Nur in der Stackansicht zu sehen: Zwischenkinasen auf einem Weg, die sonst unsichtbar bleiben. Sie tragen den Weg, sind aber kein Ziel. */ transit: v("--fg-4"), }, /* Die Zuordnung folgt der Verwandtschaft: was verstaerkt, liegt im Gruenbereich des Agonisten, was daempft, im Rot/Orange des Antagonisten. Neue Farbwerte kommen keine dazu, alle Tokens gibt es schon. */ mechanismus: { agonist: v("--map-accent-deep"), antagonist: v("--danger"), inhibitor: v("--viz-3"), partial_agonist: v("--k-recipe"), binding: v("--fg-3"), substrate: v("--viz-2"), produces: v("--viz-2"), transport: v("--k-stack"), inverse_agonist: v("--k-transformation"), positive_modulator: v("--k-tierlist"), negative_modulator: v("--k-story"), releasing_agent: v("--k-wiki"), degrader: v("--viz-4"), curated: v("--fg-4"), }, fg: v("--fg"), bg: v("--bg-1"), treffer: v("--map-accent"), /* Beschriftung in der Bubble: alle sechs Knotenfarben sind hell genug, dass weisse Schrift darauf durchfaellt. Auf dem Substanz-Gruen liegt der Kontrast bei 1.1, also praktisch unlesbar, mit der dunklen Tinte bei 15.9. Auch der schlechteste Fall (Regulator) kommt damit noch auf 6.1 und bleibt ueber der Schwelle. */ knotenschrift: v("--map-accent-ink"), }; } function mkStylesheet(pal) { const typStyle = Object.entries(pal.typ).map(([typ, farbe]) => ({ selector: `node[type = "${typ}"]`, style: { "background-color": farbe }, })); const mechStyle = Object.entries(pal.mechanismus).map(([m, farbe]) => ({ selector: `edge[mechanism = "${m}"]`, style: { "line-color": farbe, "target-arrow-color": farbe }, })); return [ { selector: "node", style: { label: "data(label)", color: pal.knotenschrift, "text-valign": "center", "text-halign": "center", "font-size": 8, "font-family": "JetBrains Mono, ui-monospace, monospace", "font-weight": 600, width: 42, height: 42, "text-max-width": 40, "border-width": 0, "background-color": pal.typ.substance, /* Der Uebergang macht das Auftauchen und das Hervorheben weich. Er gehoert ins Stylesheet und nicht in einen Aufruf: Cytoscape rechnet ihn dann bei jedem Klassenwechsel selbst, egal wer ihn ausloest. */ "transition-property": "opacity, width, height, border-width, border-color", "transition-duration": "240ms", "transition-timing-function": "ease-out", }, }, { selector: "node[?seed]", style: { "border-width": 2, "border-color": pal.fg, "border-opacity": 0.35 } }, ...typStyle, { selector: "edge", style: { width: "mapData(strength, 0, 1, 1, 7)", "line-color": pal.mechanismus.binding, "target-arrow-color": pal.mechanismus.binding, "target-arrow-shape": "triangle", "curve-style": "bezier", label: "data(mechanismLabel)", "font-size": 7, color: pal.fg, "text-background-color": pal.bg, "text-background-opacity": 0.85, "text-background-padding": 1, "transition-property": "opacity, line-color, target-arrow-color", "transition-duration": "240ms", "transition-timing-function": "ease-out", }, }, ...mechStyle, /* SIGNOR trennt den biologischen Effekt vom konkreten Mechanismus. Eine Phosphorylierung kann aktivieren oder hemmen; deshalb gewinnt hier die explizite Richtung gegenueber der allgemeinen Mechanismusfarbe. */ { selector: "edge[effect = 1]", style: { "line-color": pal.mechanismus.agonist, "target-arrow-color": pal.mechanismus.agonist }, }, { selector: "edge[effect = -1]", style: { "line-color": pal.mechanismus.antagonist, "target-arrow-color": pal.mechanismus.antagonist }, }, /* Vorhergesagt wird gestrichelt und blasser gezeichnet: der Unterschied zu einer Messung muss ohne Legende erkennbar sein. */ { selector: 'edge[evidence = "predicted"]', style: { "line-style": "dashed", opacity: 0.65 } }, /* Ein gepflegter Weg ist noch einmal etwas anderes: keine Messung und auch keine Schaetzung aus einer Struktur, sondern eine Angabe aus der Wegtabelle. Gepunktet und ohne Staerke -- eine Linie, die sagt „hier entlang", und sonst nichts. */ { selector: 'edge[evidence = "curated"]', style: { "line-style": "dotted", opacity: 0.8, width: 2 } }, /* Der Knoten, auf dem die Ansicht steht, ist groesser als seine Umgebung, und was weiter weg liegt, wird kleiner. Damit bleibt die Reichweite ablesbar, auch wenn das Kraeftelayout keine Ringe zeichnet. */ { selector: "node[tiefe = 0]", style: { width: 54, height: 54, "font-size": 9, "text-max-width": 52 } }, { selector: "node[tiefe >= 2]", style: { width: 34, height: 34, "font-size": 7, "text-max-width": 34 } }, /* Der volle Name unter der Bubble. Hinterlegt, weil er sonst ueber den Kanten liegt und dort weder er noch sie zu lesen sind. */ { selector: "node.mk-lang", style: { label: "data(voll)", color: pal.fg, "text-valign": "bottom", "text-margin-y": 4, "text-max-width": 100, "text-wrap": "wrap", "font-size": 9, "text-background-color": pal.bg, "text-background-opacity": 0.8, "text-background-padding": 2, "text-background-shape": "roundrectangle", }, }, { selector: "node.mk-treffer", style: { "border-width": 4, "border-color": pal.treffer, "border-opacity": 1 } }, /* Der Zustand, aus dem heraus ein neuer Knoten auftaucht: unsichtbar und zusammengezogen. Genommen wird die Klasse im naechsten Bild, den Rest macht der Uebergang oben. */ { selector: "node.mk-neu", style: { opacity: 0, width: 6, height: 6 } }, { selector: "edge.mk-neu", style: { opacity: 0 } }, /* Beim Ueberfahren tritt der Strang hervor, statt dass nur ein Kaestchen am Mauszeiger erscheint. Der Rest bleibt sichtbar, nur zurueckgenommen: ausblenden waere ein Filter, das hier ist ein Hinweis. */ { selector: ".mk-fern", style: { opacity: 0.22 } }, { selector: "node.mk-nah", style: { "border-width": 3, "border-color": pal.fg, "border-opacity": 0.75 } }, { selector: "edge.mk-nah", style: { width: 4, opacity: 1 } }, /* Weit herausgezoomt sind die Kantentexte ohnehin zu klein zum Lesen und legen sich nur uebereinander. Dann bleiben sie weg, und die Farbe der Linie traegt die Aussage allein. */ { selector: "edge.mk-stumm", style: { label: "" } }, ]; } /* In der Bubble steht eine Abkuerzung, der volle Name kommt beim Hover. Gensymbole wie AR oder CYP19A1 sind schon kurz und bleiben, wie sie sind. */ /* Wie stark eine Verbindung ist, als Text statt nur als Strichdicke. Eine Dicke lässt sich nicht ablesen, nur vergleichen, und auch das nur nebeneinander. Wo eine gemessene Affinität dasteht, gehört sie hin: 3,2 nM ist eine Aussage, ein etwas dickerer Strich ist keine. Wo keine Messung vorliegt, steht der Rang, den die Kantenart trägt, und dass er einer ist. */ function mkStaerkeText(e) { if (e.affinityValue != null) { const v = e.affinityValue; const zahl = v >= 1000 ? `${BB.num(Math.round(v / 100) / 10)} µM` : v >= 1 ? `${BB.num(Math.round(v * 10) / 10)} nM` : `${BB.num(Math.round(v * 1000) / 1000)} nM`; return e.affinityType ? `${zahl} (${e.affinityType})` : zahl; } if (e.evidence === "predicted") return BB.t("vorhergesagt"); if (e.strength != null) return `${Math.round(e.strength * 100)} %`; return ""; } /* Mechanismus und Stärke in einer Zeile am Pfeil. */ function mkKantenBeschriftung(e) { const mech = MK_MECHANISMUS_LABEL[e.mechanism] || e.mechanism || ""; const staerke = mkStaerkeText(e); if (!staerke) return mech; return mech ? `${mech} · ${staerke}` : staerke; } function mkKuerzel(label) { if (label.length <= 7) return label; const teile = label.split(/[-_\s]/).filter(Boolean); if (teile.length > 1) { const kurz = teile.map((t) => t.slice(0, 3)).join("-"); if (kurz.length <= 8) return kurz; } return label.slice(0, 6) + "…"; } function mkNodeEl(n) { return { data: { id: n.id, label: mkKuerzel(n.label), name: n.name || n.label, type: n.type, seed: n.seed || undefined, slug: n.slug || undefined, cid: n.pubchemCid || undefined, voll: n.name || n.label, tiefe: typeof n.tiefe === "number" ? n.tiefe : undefined, }, }; } function mkEdgeEl(e) { return { data: { id: "e" + e.id, source: e.src, target: e.dst, mechanism: e.mechanism, staerkeText: mkStaerkeText(e), mechanismLabel: mkKantenBeschriftung(e), // Getrennt vom Pfeiltext: dort stehen Mechanismus und Staerke zusammen, // im Kaestchen unter der Maus stehen sie in zwei Zeilen. mechanismNur: MK_MECHANISMUS_LABEL[e.mechanism] || e.mechanism, evidence: e.evidence, strength: e.strength, kind: e.kind, effect: e.effect == null ? undefined : e.effect, confidence: e.confidence == null ? undefined : e.confidence, messungen: e.messungen, source_: e.source, sourceRef: e.sourceRef, affinityValue: e.affinityValue, affinityType: e.affinityType, }, }; } /* --- Einsetzen neuer Knoten ---------------------------------------------- Der Teil, der die Karte ruhig haelt. Ein Layout ueber den ganzen Graphen waere hier falsch: es verschiebt auch alles, was schon dasteht, und genau das kostet die Orientierung. Stattdessen bekommt jeder neue Knoten einen Platz um den Knoten herum, an dem er haengt, und alles Bestehende bleibt unberuehrt. -------------------------------------------------------------------------- */ /* Ein Platz auf dem Kreis um `mitte`, der weit genug von allem Belegten weg ist. Ist der Winkel dicht, wandert der Platz nach aussen statt sich zu ueberlagern. Nach ein paar Versuchen wird genommen, was da ist: ein leicht zu enger Knoten ist besser als eine Endlosschleife. */ function mkFreierPlatz(mitte, winkel, radius, belegt) { let letzte = null; for (let versuch = 0; versuch < 9; versuch++) { const p = { x: mitte.x + Math.cos(winkel) * radius, y: mitte.y + Math.sin(winkel) * radius, }; letzte = p; let frei = true; for (const q of belegt) { const dx = q.x - p.x, dy = q.y - p.y; if (dx * dx + dy * dy < MK_ABSTAND * MK_ABSTAND) { frei = false; break; } } if (frei) return p; radius += MK_ABSTAND * 0.6; } return letzte; } /* In welche Richtung um `mitte` herum am meisten Platz ist. Nicht der Schwerpunkt der ganzen Karte entscheidet das, sondern die Knoten in der Nachbarschaft: ein Haufen am anderen Ende der Leinwand sagt nichts darueber, ob hier oben rechts etwas steht. Genommen wird die groesste Winkelluecke zwischen den nahen Knoten. Steht nichts in der Naehe, ist der ganze Kreis frei. Zurueck kommen Mitte und Weite der Luecke. Die Weite wird gebraucht, weil ein Faecher in eine schmale Luecke enger stehen und weiter hinaus muss als einer, der ins Offene wachsen darf. */ function mkFreierSektor(mitte, belegt) { const winkel = []; for (const q of belegt) { const dx = q.x - mitte.x, dy = q.y - mitte.y; const d = Math.hypot(dx, dy); if (d < 1 || d > MK_NAHBEREICH) continue; winkel.push(Math.atan2(dy, dx)); } if (!winkel.length) return { richtung: -Math.PI / 2, weite: 2 * Math.PI }; winkel.sort((a, b) => a - b); let richtung = winkel[0] + Math.PI, weite = 0; for (let i = 0; i < winkel.length; i++) { const a = winkel[i]; // Der letzte Nachbar schliesst den Kreis mit dem ersten. const b = i + 1 < winkel.length ? winkel[i + 1] : winkel[0] + 2 * Math.PI; if (b - a > weite) { weite = b - a; richtung = a + (b - a) / 2; } } return { richtung, weite }; } /* Plaetze fuer `anzahl` neue Knoten um einen Anker herum, alle im freien Sektor. Ein voller Kreis waere der bequeme Weg und der falsche: er setzt die Haelfte der neuen Knoten dorthin, wo schon etwas steht. Der Radius ergibt sich aus der Enge. Ist die Luecke breit, reicht der Grundabstand. Ist sie schmal, waechst der Radius so weit, dass die Knoten im Bogen noch MK_ABSTAND auseinander liegen, gedeckelt auf das Dreifache: ab einer gewissen Menge laesst sich das Ueberlappen nur noch verkleinern, nicht mehr vermeiden, und ein Knoten am anderen Ende der Leinwand waere schlimmer als ein enger Nachbar. */ function mkRingPlaetze(mitte, sektor, anzahl, belegt) { const grund = Math.max(125, (anzahl * MK_ABSTAND) / (2 * Math.PI)); // Ein Stueck Rand zu beiden Seiten, damit der aeusserste Knoten nicht // unmittelbar neben dem steht, der die Luecke begrenzt. const frei = Math.max(0.7, Math.min(sektor.weite - 0.6, 2 * Math.PI)); const noetig = anzahl > 1 ? ((anzahl - 1) * MK_ABSTAND) / frei : 0; const radius = Math.min(Math.max(grund, noetig), grund * 3); const schritt = Math.min(MK_ABSTAND / radius, anzahl > 1 ? frei / (anzahl - 1) : frei); const plaetze = []; for (let i = 0; i < anzahl; i++) { // Abwechselnd nach links und rechts von der Mitte der Luecke, damit der // Faecher symmetrisch darin liegt statt an einen Rand zu kippen. const versatz = (i % 2 ? 1 : -1) * Math.ceil(i / 2) * schritt; const p = mkFreierPlatz(mitte, sektor.richtung + versatz, radius, belegt); belegt.push(p); plaetze.push(p); } return plaetze; } /* Verteilt die frisch eingefuegten Knoten und liefert je Knoten sein Ziel. Gearbeitet wird in Runden: zuerst alle, die an einem schon platzierten Knoten haengen, dann die, die an einem gerade platzierten haengen. So bekommt auch eine zwei Schritte tiefe Erweiterung eine sinnvolle Form, ohne dass irgendetwas Bestehendes sich bewegt. */ function mkNeuePlatzieren(cy, neueKnoten, vorhandeneIds) { const belegt = []; cy.nodes().forEach((n) => { if (vorhandeneIds.has(n.id())) belegt.push(n.position()); }); const schwerpunkt = { x: 0, y: 0 }; if (belegt.length) { belegt.forEach((p) => { schwerpunkt.x += p.x; schwerpunkt.y += p.y; }); schwerpunkt.x /= belegt.length; schwerpunkt.y /= belegt.length; } const ziele = new Map(); const platziert = new Set(vorhandeneIds); let offen = neueKnoten.filter((n) => !platziert.has(n.id())); for (let runde = 0; runde < 4 && offen.length; runde++) { const gruppen = new Map(); const rest = []; for (const n of offen) { // Der Anker mit den meisten Verbindungen: an ihm haengt der neue Knoten // am ehesten sichtbar dran. let anker = null, grad = -1; n.neighborhood("node").forEach((m) => { if (platziert.has(m.id()) && m.degree() > grad) { anker = m; grad = m.degree(); } }); if (!anker) { rest.push(n); continue; } const liste = gruppen.get(anker.id()) || []; liste.push(n); gruppen.set(anker.id(), liste); } if (!gruppen.size) { offen = []; break; } for (const [ankerId, kinder] of gruppen) { const anker = cy.getElementById(ankerId); const mitte = ziele.get(ankerId) || anker.position(); const plaetze = mkRingPlaetze(mitte, mkFreierSektor(mitte, belegt), kinder.length, belegt); kinder.forEach((n, i) => { ziele.set(n.id(), plaetze[i]); platziert.add(n.id()); // Startpunkt ist der Anker: der neue Knoten faehrt sichtbar aus ihm // heraus, statt irgendwo aufzuploppen. n.position({ x: mitte.x, y: mitte.y }); }); } offen = rest; } // Was an gar nichts Sichtbarem haengt, kommt neben den Schwerpunkt. if (offen.length) { const plaetze = mkRingPlaetze(schwerpunkt, mkFreierSektor(schwerpunkt, belegt), offen.length, belegt); offen.forEach((n, i) => { ziele.set(n.id(), plaetze[i]); n.position({ x: schwerpunkt.x, y: schwerpunkt.y }); }); } return ziele; } function MolekuelkarteGraph({ nodes, edges, treffer, zentrum, ansicht, onAufklappen, onZuklappen, onNodeSelect }) { const hostRef = useRef(null); const cyRef = useRef(null); const textRef = useRef(null); const ansichtRef = useRef(null); const aufraeumenRef = useRef(null); const [hover, setHover] = useState(null); const [vollbild, setVollbild] = useState(false); useEffect(() => { if (!vollbild) return undefined; const vorher = document.body.style.overflow; const escape = (event) => { if (event.key === "Escape") setVollbild(false); }; document.body.style.overflow = "hidden"; // PageTransition animiert
mit transform. Ein transformierter // Vorfahr macht aus position:fixed sonst einen Container-Vollbildmodus, // der beim Öffnen aus einer gescrollten Position oben abgeschnitten wird. document.documentElement.classList.add("bb-vollbild-aktiv"); window.addEventListener("keydown", escape); return () => { document.body.style.overflow = vorher; document.documentElement.classList.remove("bb-vollbild-aktiv"); window.removeEventListener("keydown", escape); }; }, [vollbild]); /* Die Aufhaenger unten schliessen die Rueckrufe ein, die beim Mounten gelten. Sie ueber eine Abhaengigkeitsliste neu zu setzen hiesse, Cytoscape bei jedem Zustandswechsel neu aufzubauen und damit alle Positionen zu verlieren. Deshalb liegen sie in einem Ref, das bei jedem Rendern nachgezogen wird: der Aufhaenger bleibt derselbe, liest aber immer den aktuellen Stand. */ const rueckrufe = useRef(null); rueckrufe.current = { onAufklappen, onZuklappen, onNodeSelect }; // Cytoscape einmal aufbauen. Spaetere Daten kommen ueber cy.add dazu, damit // Zoom und Position beim Aufklappen erhalten bleiben. useEffect(() => { if (!hostRef.current) return; const cy = window.cytoscape({ container: hostRef.current, style: mkStylesheet(mkPalette()), layout: { name: "preset" }, minZoom: 0.15, maxZoom: 3, }); cyRef.current = cy; window.__mkcy = cy; // Griff zum Nachsehen in der Konsole /* Der Hinweis haengt am Mauszeiger, nicht am Knoten: auf dem Knoten laege er ueber genau dem, was er erklaert. Er wandert mit, solange die Maus auf dem Element bleibt. */ /* Kaestchen und Hervorhebung haengen zusammen und werden deshalb an derselben Stelle gesetzt. Zwei getrennte Aufhaenger liefen auseinander, sobald einer von beiden ausfiel: nach einem Klick etwa steht der Zeiger noch auf dem Knoten, aber ein zweites `mouseover` kommt nicht mehr. */ let bildOffen = false; let hoverId = null; const zeigen = (evt, inhalt) => { const el = evt.target; const gewechselt = el.id() !== hoverId; if (gewechselt) { hoverId = el.id(); const nah = el.isNode() ? el.closedNeighborhood() : el.union(el.connectedNodes()); cy.batch(() => { cy.elements().removeClass("mk-fern mk-nah"); cy.elements().difference(nah).addClass("mk-fern"); nah.addClass("mk-nah"); }); } const p = evt.renderedPosition || evt.position; /* mousemove feuert oefter als der Browser zeichnet. Ohne die Bremse loest jede Zwischenmeldung ein Rendern aus, und das Kaestchen ruckelt genau dann, wenn die Maus schnell ueber die Karte faehrt. Der Wechsel auf ein anderes Element geht aber immer durch. Sonst faellt genau die Meldung aus, die den neuen Inhalt bringt, und dann steht die Hervorhebung ohne Kaestchen da, bis die Maus sich wieder bewegt. Gebremst wird das Nachfuehren der Lage, nicht der Wechsel. */ if (bildOffen && !gewechselt) return; bildOffen = true; requestAnimationFrame(() => { bildOffen = false; }); setHover({ ...inhalt, x: p.x, y: p.y }); }; cy.on("mouseover mousemove", "node", (evt) => { const n = evt.target; zeigen(evt, { text: n.data("name"), unter: [MK_TYP_LABEL[n.data("type")] || "", n.degree() + " Verbindungen"] .filter(Boolean).join(" · "), }); }); cy.on("mouseover mousemove", "edge", (evt) => { const e = evt.target; const art = e.data("evidence") === "predicted" ? "vorhergesagt" : "gemessen"; const n = e.data("messungen"); const confidence = e.data("confidence"); const score = Number.isFinite(confidence) ? `${BB.t("Evidenzscore")} ${Math.round(confidence * 100)} %` : ""; // **Die Stärke steht immer da.** Vorher nur, wenn eine gemessene // Affinität vorlag; bei einer Signalkante oder einer Vorhersage blieb // die Zeile leer, und die Dicke des Pfeils war die einzige Auskunft. // Eine Dicke lässt sich nicht ablesen. const staerke = e.data("staerkeText"); zeigen(evt, { text: `${e.data("mechanismNur")} · ${BB.t(art)}`, unter: [staerke ? `${BB.t("Stärke")}: ${staerke}` : "", score, n > 1 ? `${n} ${BB.t("Messungen")}` : ""].filter(Boolean).join(" · "), }); }); /* „node, edge" mit Komma ist Absicht: ohne Komma liest Cytoscape das als Verschachtelung, der Aufhaenger greift nie, und das Kaestchen bleibt fuer immer stehen. Genau das war der Fehler in der ersten Fassung. */ const aufraeumen = () => { hoverId = null; setHover(null); cy.batch(() => cy.elements().removeClass("mk-fern mk-nah")); }; aufraeumenRef.current = aufraeumen; cy.on("mouseout", "node, edge", aufraeumen); cy.on("tapstart", aufraeumen); /* Cytoscape meldet `mouseout` nur, wenn der Zeiger innerhalb der Leinwand von einem Element herunterfaehrt. Verlaesst er die Leinwand ganz, kommt nichts mehr, und Kaestchen wie Hervorhebung bleiben stehen. Den Fall kennt nur das DOM darunter. */ const behaelter = hostRef.current; behaelter.addEventListener("mouseleave", aufraeumen); cy.on("tap", "node", (evt) => { const n = evt.target; rueckrufe.current.onNodeSelect({ id: n.id(), name: n.data("name"), type: n.data("type"), slug: n.data("slug"), cid: n.data("cid"), }); rueckrufe.current.onAufklappen(n.id()); }); cy.on("cxttap", "node", (evt) => { aufraeumen(); rueckrufe.current.onZuklappen(evt.target.id()); }); /* Kantentexte erst ab einer Zoomstufe, bei der sie lesbar sind. Der Zustand wird gemerkt, damit beim Ziehen am Rad nicht bei jedem Zwischenschritt ueber alle Kanten gelaufen wird. Ein Aufhaenger an `add` waere hier falsch: der feuert je Kante, und jeder Aufruf ginge ueber alle, beim Aufbau also quadratisch. Das erledigt stattdessen der Abgleich weiter unten, einmal je Aenderung. */ let stummZuletzt = null; let langZuletzt = null; const textsichtbarkeit = () => { const zoom = cy.zoom(); const stumm = zoom < LESBARER_ZOOM; const lang = zoom >= VOLLER_NAME_ZOOM; if (stumm === stummZuletzt && lang === langZuletzt) return; cy.batch(() => { if (stumm !== stummZuletzt) cy.edges().toggleClass("mk-stumm", stumm); if (lang !== langZuletzt) cy.nodes().toggleClass("mk-lang", lang); }); stummZuletzt = stumm; langZuletzt = lang; }; cy.on("zoom", textsichtbarkeit); textRef.current = () => { stummZuletzt = null; langZuletzt = null; textsichtbarkeit(); }; // Der Container ist beim ersten Lauf oft noch 0 hoch, dann rechnet // Cytoscape mit falschen Massen und alles klebt in einer Ecke. const ro = new ResizeObserver(() => cy.resize()); ro.observe(hostRef.current); return () => { behaelter.removeEventListener("mouseleave", aufraeumen); ro.disconnect(); cy.destroy(); delete window.__mkcy; }; }, []); /* Die Leinwand bildet den Zustand ab, in beide Richtungen. Nur nachtragen reicht nicht: dann bliebe alles stehen, was ein Filter gerade ausschliesst, und Regler wie Umschalter waeren nach dem ersten Aufklappen wirkungslos. Aufklappen erweitert den Zustand, ein Filter ersetzt ihn, und beides landet hier an derselben Stelle. */ useEffect(() => { const cy = cyRef.current; if (!cy) return; const sollNodes = new Map(nodes.map((n) => [n.id, n])); /* Cytoscape wirft, wenn eine Kante an einem Knoten haengt, den es nicht gibt, und die Karte bliebe leer. Seit `bekannt` kommen Kanten zu Knoten zurueck, die der Aufrufer mitgeschickt hat: sind die inzwischen weggeklappt, zeigt die Kante ins Leere. Hier ist die einzige Stelle vor cy.add, also wird hier gefiltert. */ const haltbar = edges.filter((e) => sollNodes.has(e.src) && sollNodes.has(e.dst)); const sollEdges = new Set(haltbar.map((e) => "e" + e.id)); const weg = cy.elements().filter((el) => !(el.isNode() ? sollNodes.has(el.id()) : sollEdges.has(el.id()))); const neu = []; for (const n of nodes) if (!cy.hasElementWithId(n.id)) neu.push(mkNodeEl(n)); for (const e of haltbar) if (!cy.hasElementWithId("e" + e.id)) neu.push(mkEdgeEl(e)); /* Eine neue Ansicht heisst: die Karte zeigt etwas anderes als vorher, nicht nur mehr davon. Nur dann wird ausgerichtet. Ein Filterschritt oder ein Klick faellt ausdruecklich nicht darunter. */ const neueAnsicht = ansichtRef.current !== ansicht; ansichtRef.current = ansicht; if (!neu.length && !weg.length && !neueAnsicht) return; if (aufraeumenRef.current) aufraeumenRef.current(); if (neueAnsicht) { cy.batch(() => { cy.elements().remove(); cy.add(nodes.map(mkNodeEl).concat(haltbar.map(mkEdgeEl))); // Tiefe kann sich ohne Zu- oder Abgang geaendert haben, etwa wenn // dieselbe Auswahl mit anderer Reichweite geholt wurde. cy.nodes().forEach((n) => { const soll = sollNodes.get(n.id()); if (!soll) return; // removeData statt data(..., undefined): ein undefined-Wert bliebe // als Schluessel stehen, und [tiefe = 0] griffe weiter. if (typeof soll.tiefe === "number") n.data("tiefe", soll.tiefe); else n.removeData("tiefe"); }); }); if (cy.nodes().length) mkAusrichten(cy, zentrum); /* Erst rechnen, dann zusammenziehen. Die Reihenfolge ist nicht beliebig: `mk-neu` setzt die Knotenbreite auf 6, und das Ringlayout leitet seinen Radius aus der groessten Knotenbreite ab. Zuerst zusammengezogen faellt der Ring auf ein Drittel zusammen und richtet sich nie wieder auf. Zwischen dieser Zeile und dem naechsten Bild zeichnet der Browser nicht, der Umweg ist also nicht zu sehen. */ cy.batch(() => cy.elements().addClass("mk-neu")); requestAnimationFrame(() => { if (cyRef.current) cy.batch(() => cy.elements(".mk-neu").removeClass("mk-neu")); }); if (textRef.current) textRef.current(); return; } const vorhandene = new Set(cy.nodes().map((n) => n.id())); cy.batch(() => { if (weg.length) weg.remove(); if (neu.length) cy.add(neu).addClass("mk-neu"); }); if (neu.length) { const neueKnoten = cy.nodes().filter((n) => !vorhandene.has(n.id())); const ziele = mkNeuePlatzieren(cy, neueKnoten, vorhandene); /* Erst im naechsten Bild loslegen: bis dahin steht der neue Knoten zusammengezogen und unsichtbar auf seinem Anker, und beides laeuft zusammen los. Ohne den Aufschub zeichnet der Browser den Endzustand sofort, und die Bewegung faellt aus. */ requestAnimationFrame(() => { if (!cyRef.current) return; cy.batch(() => cy.elements(".mk-neu").removeClass("mk-neu")); neueKnoten.forEach((n) => { const ziel = ziele.get(n.id()); if (ziel) n.animate({ position: ziel }, { duration: 340, easing: "ease-out" }); }); }); } // Einmal je Aenderung, nicht je Kante. if (textRef.current) textRef.current(); }, [nodes, edges, ansicht, zentrum]); // Suchtreffer hervorheben, den Rest zuruecknehmen. useEffect(() => { const cy = cyRef.current; if (!cy) return; cy.batch(() => { cy.elements().removeClass("mk-treffer"); for (const id of treffer) { const n = cy.getElementById(id); if (n.length) n.addClass("mk-treffer"); } }); }, [treffer, nodes]); /* Der Hinweis darf nicht aus dem Rahmen laufen. Steht die Maus in der rechten oder unteren Haelfte, klappt er auf die andere Seite. */ const rahmen = hostRef.current ? hostRef.current.getBoundingClientRect() : null; const links = hover && rahmen && hover.x > rahmen.width - 180; const oben = hover && rahmen && hover.y > rahmen.height - 90; const vollbildText = vollbild ? "Vollbild schließen" : "Vollbild öffnen"; return (
e.preventDefault()} /> {hover && (
{hover.text} {hover.unter && {hover.unter}}
)}
); } /* Die einzige Stelle, die die Ansicht anfasst. Steht ein Knoten im Mittelpunkt und ist die Karte klein genug, werden Ringe gezeichnet: innen der gewaehlte Knoten, aussen die Reichweite. Sonst das Kraeftelayout wie bisher. */ function mkAusrichten(cy, zentrum) { const mitZentrum = zentrum && cy.hasElementWithId(zentrum); const ringe = mitZentrum && cy.nodes().length <= MK_RINGE_BIS; const layout = ringe ? { /* Zwei Schalter, die beide gesetzt sein muessen. `fit: false`, weil das Layout sonst selbst einpasst, und zwar nach `layoutstop`: die Begrenzung der Zoomstufe weiter unten liefe dann ins Leere. `animate: false`, weil das animierte Concentric seine gerechneten Positionen nicht zuverlaessig zu Ende schreibt. Gemessen: derselbe Aufruf ergibt animiert Abstaende von 491 bis 721 statt der gerechneten 117, und zwar dauerhaft, nicht nur waehrend der Bewegung. Bewegt wird deshalb nicht der Knoten, sondern sein Auftauchen: die Ansicht blendet sich an fertiger Stelle auf. */ name: "concentric", animate: false, fit: false, padding: 55, // Kleine Tiefe nach innen. Der gewaehlte Knoten hat Tiefe 0 und damit // den hoechsten Wert, also die Mitte. concentric: (n) => 10 - (n.data("tiefe") || 0), levelWidth: () => 1, /* Der Abstand zwischen zwei Knotenraendern. Bei 46 wird der Ring bei dreissig Knoten rund 480 Pixel weit, und damit passt er bei lesbarem Zoom nicht mehr auf die Leinwand: unten faellt ein Stueck ab. 24 reicht, damit sich nichts beruehrt, und haelt den Ring im Bild. */ minNodeSpacing: 24, avoidOverlap: true, equidistant: false, } : { /* Grosszuegig auseinander: bei enger Anordnung ueberlagern sich die Kantenbeschriftungen und aus dem Netz wird ein Knaeuel. Lieber scrollen und zoomen als raten, welche Linie wohin gehoert. */ name: "cose", animate: false, fit: false, padding: 60, randomize: false, /* Weit genug, dass sich Knoten und Kantentexte nicht mehr ueberlagern, eng genug, dass der Einstieg bei lesbarem Zoom noch ins Bild passt. */ nodeRepulsion: 65000, idealEdgeLength: 165, nodeOverlap: 32, componentSpacing: 130, gravity: 0.45, nestingFactor: 1.2, edgeElasticity: 55, numIter: 1400, }; const l = cy.layout(layout); l.one("layoutstop", () => { cy.fit(undefined, 50); /* Alles ins Bild zu zwingen hiesse bei dieser Streuung auf etwa 0.45 herauszuzoomen, und dort ist die Kantenbeschriftung nur noch ein Strich. Lieber eine lesbare Stufe halten und den Rest durch Verschieben erreichbar lassen: die Karte ist zum Erkunden da, nicht zum Betrachten aus der Ferne. */ const z = cy.zoom(); if (z < LESBARER_ZOOM || z > MK_EINSTIEG_ZOOM) { cy.zoom(z < LESBARER_ZOOM ? LESBARER_ZOOM : MK_EINSTIEG_ZOOM); /* Nicht auf die Mitte des umschliessenden Rechtecks: die liegt bei ungleich verteilten Knoten schnell in einer Luecke. Stattdessen auf den gewaehlten Knoten, sonst auf den mit den meisten Verbindungen. */ const ziel = mitZentrum ? cy.getElementById(zentrum) : (cy.nodes().max((n) => n.degree()).ele || undefined); cy.center(ziel || undefined); } }); l.run(); } /* --- Suche mit Vorschlaegen ----------------------------------------------- */ function MolekuelkarteSuche({ wert, setWert, vorschlaege, laedt, onWaehlen, onLeeren }) { const [offen, setOffen] = useState(false); const [markiert, setMarkiert] = useState(0); const feldRef = useRef(null); useEffect(() => { setMarkiert(0); }, [vorschlaege]); const waehlen = (n) => { setOffen(false); if (feldRef.current) feldRef.current.blur(); onWaehlen(n); }; const taste = (e) => { if (e.key === "Escape") { setOffen(false); return; } if (!vorschlaege.length) return; if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); setOffen(true); setMarkiert((i) => { const n = vorschlaege.length; return ((e.key === "ArrowDown" ? i + 1 : i - 1) + n) % n; }); } else if (e.key === "Enter") { e.preventDefault(); waehlen(vorschlaege[markiert] || vorschlaege[0]); } }; const zeigen = offen && (vorschlaege.length > 0 || (wert.trim().length > 1 && !laedt)); return (
{ setWert(e.target.value); setOffen(true); }} onFocus={() => setOffen(true)} /* Erst nach dem Klick schliessen: ein sofortiges Zuklappen bei blur nimmt dem Zeiger den Eintrag unter der Maus weg, bevor der Klick ankommt. */ onBlur={() => setTimeout(() => setOffen(false), 150)} onKeyDown={taste} placeholder={BB.t("Substanz, Zielprotein oder Analyse-Endpunkt suchen …")} aria-label={BB.t("Substanz, Zielprotein oder Analyse-Endpunkt suchen …")} aria-autocomplete="list" aria-expanded={zeigen} role="combobox" /> {wert && ( )} {zeigen && (
    {vorschlaege.map((n, i) => (
  • ))} {!vorschlaege.length && (
  • {BB.t("Nichts gefunden")}
  • )}
)}
); } /* --- Bedienelemente ------------------------------------------------------- Die Filterleiste bestand aus 21 Knoepfen in sieben Gruppen, jede Einstellung als eigener Chip. Das liest sich als Wand und nicht als Menue: bei sieben Knotenarten, drei Richtungen, drei Reichweiten und drei Belegarten steht ueberall dieselbe Pillenform, und welche davon eine Auswahl ist und welche ein Schalter, sagt nur die Ueberschrift darueber. Jetzt traegt jede Einstellung die Form ihrer Frage. Eine Auswahl aus einer Liste ist ein Auswahlfeld, eine Menge ist ein Aufklappmenue mit Schaltern, eine Zahl auf einer Skala ist ein Schieberegler. Uebrig bleiben sechs Bedienelemente in einer Zeile. -------------------------------------------------------------------------- */ function MkFeld({ titel, wert, children, weit }) { return ( ); } /* Ein Auswahlfeld fuer genau eine Antwort aus einer geschlossenen Liste. Bewusst das native `select`: es bringt Tastaturbedienung, Bildschirmleser und auf dem Telefon das Systemrad mit, und nichts davon muesste hier nachgebaut werden. */ function MkAuswahl({ titel, wert, setzen, optionen }) { return ( ); } function MkSchieber({ titel, wert, setzen, min, max, schritt, anzeige }) { return ( setzen(parseFloat(e.target.value))} aria-label={BB.t(titel)} /> ); } /* Ein Menue, das sich unter seinem Knopf aufklappt. Es traegt die beiden Einstellungen, die keine einzelne Antwort sind: die Menge der Knotenarten und die Stoffauswahl. Beide brauchen mehrere Zeilen, und beide sollen die Leiste nicht dauerhaft auseinanderziehen. */ function MkAufklapp({ titel, zusammenfassung, children }) { const [offen, setOffen] = useState(false); const halter = useRef(null); useEffect(() => { if (!offen) return undefined; const weg = (e) => { if (halter.current && !halter.current.contains(e.target)) setOffen(false); }; const taste = (e) => { if (e.key === "Escape") setOffen(false); }; document.addEventListener("pointerdown", weg); document.addEventListener("keydown", taste); return () => { document.removeEventListener("pointerdown", weg); document.removeEventListener("keydown", taste); }; }, [offen]); return (
{BB.t(titel)} {offen &&
{children}
}
); } /* Ein Schalter, kein Knopf. Die Knotenarten sind sieben unabhaengige Ja/Nein- Fragen, und genau so sehen sie jetzt auch aus. Der Punkt links traegt weiterhin die Farbe der Node im Graphen. */ function MkSchalter({ an, setzen, farbklasse, children }) { return ( ); } /* Die Stoffauswahl. Suchen, anklicken, steht als Chip da; das Kreuz nimmt ihn wieder weg. Gesucht wird ueber dieselbe Route wie die Kartensuche, nur auf Substanzen eingegrenzt: eine Auswahl, die ein Enzym aufnehmen koennte, verspraeche einen Filter, den es nicht gibt. */ function MkStoffsuche({ gewaehlt, setzen }) { const [wert, setWert] = useState(""); const [treffer, setTreffer] = useState([]); const [laedt, setLaedt] = useState(false); useEffect(() => { const q = wert.trim(); if (q.length < 2) { setTreffer([]); setLaedt(false); return undefined; } setLaedt(true); const t = setTimeout(() => { BB.get("/molekuelkarte/suche" + BB.qs({ q })) .then((d) => { setTreffer((d.treffer || []).filter((n) => n.type === "substance")); setLaedt(false); }) .catch(() => { setTreffer([]); setLaedt(false); }); }, 220); return () => clearTimeout(t); }, [wert]); const da = new Set(gewaehlt.map((s) => s.id)); const offen = treffer.filter((n) => !da.has(String(n.id))); return (
setWert(e.target.value)} placeholder={BB.t("Stoff suchen und hinzufügen …")} aria-label={BB.t("Stoff suchen und hinzufügen …")} /> {wert.trim().length >= 2 && (
    {offen.map((n) => (
  • ))} {!offen.length && (
  • {laedt ? BB.t("lädt …") : BB.t("Nichts gefunden")}
  • )}
)} {gewaehlt.length > 0 && (
{gewaehlt.map((s) => ( {s.label} ))}
)}
); } function MolekuelkarteFilter({ typen, setTypen, minStaerke, setMinStaerke, evidenz, setEvidenz, reichweite, setReichweite, stoffkreis, setStoffkreis, stoffe, setStoffe, richtung, setRichtung, standard, onZuruecksetzen }) { const umschalten = (t, an) => setTypen(an ? typen.concat(t) : typen.filter((x) => x !== t)); const artenText = typen.length === MK_TYPEN.length ? BB.t("Alle Arten") : typen.length === 0 ? BB.t("Keine Art") : `${typen.length} ${BB.t("von")} ${MK_TYPEN.length}`; const stoffText = stoffkreis === "auswahl" ? (stoffe.length === 0 ? BB.t("Noch keiner gewählt") : stoffe.length === 1 ? stoffe[0].label : `${stoffe.length} ${BB.t("ausgewählt")}`) : BB.t((MK_STOFFKREISE.find((k) => k[0] === stoffkreis) || [])[1] || "Alle Stoffe"); return (
{MK_TYPEN.map((t) => ( umschalten(t, an)}> {BB.t(MK_TYP_LABEL[t])} ))}
{stoffkreis === "auswahl" && ( <>

{BB.t("Gilt nur für Substanzen. Zielproteine, Enzyme und Metaboliten bleiben stehen, sonst fällt mit den Nachbarn das Netz weg, in dem der gewählte Stoff überhaupt etwas bedeutet.")}

)}
setReichweite(Math.round(w))} min={1} max={3} schritt={1} anzeige={`${reichweite} ${BB.t(reichweite === 1 ? "Schritt" : "Schritte")}`} /> {/* Steht nur da, wenn es etwas zurueckzusetzen gibt. Ein Knopf, der nichts tut, ist ein Knopf zu viel -- und genau davon hatte diese Leiste zu viele. */} {!standard && ( )}
); } /* --- Gewebe -------------------------------------------------------------- Wo im Koerper ein Knoten vorkommt, als Balken wie in der Auswertung. Die Werte stammen aus dem Human Protein Atlas und sind Ableseraten des Gens (nTPM), nicht Stoffmengen. Deshalb der Vergleich mit dem eigenen Hoechstwert und nicht zwischen zwei Knoten: das steht auch so darunter. -------------------------------------------------------------------------- */ const MK_GEWEBE_ZUERST = 7; function MolekuelkarteGewebe({ nodeId }) { const [daten, setDaten] = useState(null); const [laedt, setLaedt] = useState(true); const [alle, setAlle] = useState(false); useEffect(() => { let abgemeldet = false; setLaedt(true); setAlle(false); setDaten(null); BB.get("/molekuelkarte/gewebe/" + nodeId) .then((d) => { if (!abgemeldet) { setDaten(d); setLaedt(false); } }) .catch(() => { if (!abgemeldet) { setDaten(null); setLaedt(false); } }); return () => { abgemeldet = true; }; }, [nodeId]); if (laedt) return
; const liste = (daten && daten.gewebe) || []; const orte = (daten && daten.orte) || {}; const hatOrte = (orte.gewebe && orte.gewebe.length) || (orte.fluessigkeit && orte.fluessigkeit.length); if (!liste.length && !hatOrte) { return (
{BB.t("Im Körper")}

{BB.t("Für diesen Knoten liegt keine Angabe vor.")}

); } const sichtbar = alle ? liste : liste.slice(0, MK_GEWEBE_ZUERST); return (
{BB.t("Im Körper")} {liste.length > 0 && (
{sichtbar.map((g) => (
{BB.t(g.label)} {g.wert.toFixed(1)} {g.spitze !== g.label && ( {g.spitze} )}
))}
)} {liste.length > MK_GEWEBE_ZUERST && ( )} {liste.length > 0 && (

{BB.t("Ableserate des Gens in")} {daten.einheit} {BB.t(", verglichen mit dem stärksten Gewebe dieses Knotens.")} {daten.quelle && ( {daten.quelle.label} )}

)} {/* Nachgewiesen, nicht gemessen: eine Liste und bewusst keine Balken. Die Quelle nennt kein Wieviel, und ein Balken behauptete eines. */} {hatOrte && (
{orte.gewebe && orte.gewebe.length > 0 && (
{BB.t("Nachgewiesen in")}
{orte.gewebe.map((o) => ( {BB.t(o.label)} ))}
)} {orte.fluessigkeit && orte.fluessigkeit.length > 0 && (
{BB.t("Nachweisbar in")}
{orte.fluessigkeit.map((o) => ( {BB.t(o.label)} ))}
)}

{BB.t("Ohne Mengenangabe: die Quelle nennt, wo der Stoff nachgewiesen wurde, nicht wie viel.")} {orte.quelle && ( {orte.quelle.label} {orte.quelle.lizenzpflichtig ? § : null} )}

)}
); } /* Was in dieser Karte was bedeutet. Die Legende bestand aus zwei Strichen und dem Satz „Linienbreite: Bindungsstärke". Sie erklärte damit weder die sechs Knotenfarben noch die Pfeilfarben, und die Bindungsstärke stand nur als Breite da, die sich nicht ablesen lässt. Die Zahl steht jetzt am Pfeil; hier steht, wie sie zu lesen ist. */ function MolekuelkarteLegende() { const [offen, setOffen] = useState(false); const pal = useMemo(() => mkPalette(), []); return (
{offen && (
{BB.t("Knoten")}
    {Object.entries(MK_TYP_LABEL).map(([typ, label]) => (
  • {BB.t(label)}
  • ))}
{BB.t("Ein Ring um den Knoten heißt: der Stoff steht in unserer Wissensbasis. Alles andere ist ein Bindungspartner, den eine Quelle mitgebracht hat.")}
{BB.t("Pfeile")}
    {Object.entries(MK_MECHANISMUS_LABEL).map(([m, label]) => (
  • {BB.t(label)}
  • ))}
{BB.t("Die Richtung trägt eine Aussage: bei einer Bindung steht der Stoff vorn und sein Ziel hinten, bei einer Umwandlung das Substrat vorn und das Produkt hinten.")}
{BB.t("Stärke am Pfeil")}
  • 3,2 nM (Ki) {BB.t("gemessene Affinität, Median aus allen Messungen. Kleiner heißt fester gebunden.")}
  • {BB.t("vorhergesagt")} {BB.t("aus Strukturanalogie abgeleitet, keine Messung. Trägt nie eine Wirkrichtung.")}
  • 75 % {BB.t("ein Rang, keine Messung: so weit trägt die Kantenart. Signalbeziehungen und kuratierte Wege stehen so da.")}
{BB.t("Die Linienbreite zeigt dasselbe noch einmal. Ablesen lässt sich nur die Zahl.")}
{BB.t("Herkunft")}
  • {BB.t("gemessen, aus einer der Quellen unten")}
  • {BB.t("vorhergesagt")}
{BB.t("Gemessenes und Vorhergesagtes werden nie miteinander gemittelt. Voreingestellt steht eine vorhergesagte Bindung nur dort, wo an derselben Stelle keine gemessene liegt; „Beides nebeneinander\" im Filter zeigt auch die verdeckten.")}
)}
); } /* Die Verbindungen eines Knotens, mit Stärke. Die Karte zeigt sie als Pfeile, und eine Pfeildicke lässt sich vergleichen, aber nicht ablesen. Hier stehen sie als Liste: Partner, Mechanismus, Stärke, Herkunft. Sortiert nach Stärke, gemessen vor vorhergesagt. */ function MolekuelkarteVerbindungen({ node, nodes, edges }) { const [alle, setAlle] = useState(false); const namen = useMemo(() => { const m = {}; (nodes || []).forEach((n) => { m[n.id] = n; }); return m; }, [nodes]); const liste = useMemo(() => { const aus = (edges || []) .filter((e) => e.src === node.id || e.dst === node.id) .map((e) => { const anderer = namen[e.src === node.id ? e.dst : e.src]; return { id: e.id, partner: anderer ? (anderer.label || anderer.name) : "?", typ: anderer ? anderer.type : "", raus: e.src === node.id, mech: MK_MECHANISMUS_LABEL[e.mechanism] || e.mechanism || "", staerke: mkStaerkeText(e), gemessen: e.evidence !== "predicted", wert: e.affinityValue, sort: e.strength == null ? 0 : e.strength, }; }); aus.sort((a, b) => (b.gemessen - a.gemessen) || (b.sort - a.sort)); return aus; }, [node, edges, namen]); if (!liste.length) return null; const zeigen = alle ? liste : liste.slice(0, 8); return (

{BB.t("Verbindungen")} {liste.length}

    {zeigen.map((v) => (
  • {v.raus ? "→ " : "← "}{v.partner} {BB.t(v.mech)} {v.staerke}
  • ))}
{liste.length > zeigen.length && ( )}

{BB.t("Ein nM-Wert ist eine gemessene Affinität, Median aus allen Messungen. Kleiner heißt fester gebunden. Eine Prozentzahl ist ein Rang der Kantenart, keine Messung.")}

); } function MolekuelkarteDetail({ node, zentrum, onSchliessen, onFokus, nodes, edges }) { if (!node) return null; const imMittelpunkt = zentrum === node.id; return ( ); } /* Seite vor dem Einloggen. Zeigt einen echten Ausschnitt aus denselben Daten statt eines nachgebauten Schaubildes: ein vollstaendiges fremdes Beispiel ueberzeugt mehr als die halbierte eigene Ansicht. Dieselbe Ueberlegung wie bei der Auswertung, deshalb auch dasselbe Angebot darunter. */ function MolekuelkarteLanding({ user }) { const [beispiel, setBeispiel] = useState(null); useEffect(() => { BB.get("/molekuelkarte/beispiel" + BB.qs({ slug: "testosteron" })) .then(setBeispiel) .catch(() => setBeispiel(null)); }, []); return (

{BB.t("Molekülkarte")}

{BB.t("Welcher Stoff bindet an welchen Rezeptor, welches Enzym wandelt ihn wohin um. Aus kuratierten Bindungsdaten und Stoffwechselwegen, jede Verbindung mit Beleg und Stärke.")}

{BB.t("Ein Ausschnitt")}

{beispiel && beispiel.nodes && beispiel.nodes.length ? ( {}} onZuklappen={() => {}} onNodeSelect={() => {}} /> ) : }
); } function MolekuelkartePage() { const { user, ready } = useAuth(); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [typen, setTypen] = useState(MK_TYPEN); const [minStaerke, setMinStaerke] = useState(0); const [evidenz, setEvidenz] = useState("bevorzugt"); const [reichweite, setReichweite] = useState(1); const [stoffkreis, setStoffkreis] = useState("alle"); /* Die Handauswahl als `{id, label}`, nicht als blosse Kennung: der Chip soll seinen Namen tragen, auch wenn der Stoff gerade gar nicht auf der Karte steht -- und genau das ist der Regelfall, denn ausgewaehlt wird er, bevor aufgeklappt wird. */ const [stoffe, setStoffe] = useState([]); const [richtung, setRichtung] = useState("beide"); const [suche, setSuche] = useState(""); const [vorschlaege, setVorschlaege] = useState([]); const [suchtLaeuft, setSuchtLaeuft] = useState(false); const [zentrum, setZentrum] = useState(null); /* Zaehler statt Umschalter: er sagt der Karte, dass sie eine andere Ansicht zeigt und sich einmal ausrichten darf. Jede andere Aenderung laesst den Ausschnitt bewusst stehen. Hochgezaehlt wird erst, wenn die Daten da sind, nie beim Absenden der Anfrage. Sonst richtet sich die Karte auf dem alten Stand aus, und der neue rutscht danach als Nachtrag hinein: die Ringe der Fokusansicht kaemen dann nie zustande. Die Absicht dazu merkt sich das Ref. */ const [ansicht, setAnsicht] = useState(0); const ausrichtenRef = useRef(false); const [gewaehlt, setGewaehlt] = useState(null); const [quellen, setQuellen] = useState([]); const [laedt, setLaedt] = useState(true); const [fehler, setFehler] = useState(""); const [gekappt, setGekappt] = useState(false); const [hinweis, setHinweis] = useState(""); /* Die Stackansicht: `?analyse=` aus dem Knopf unter einer Auswertung. Solange sie steht, zeigt die Karte genau diesen Stack mit seinen Wegen und Endpunkten und laedt nichts darueber. Aufklappen geht weiter, denn wer einen Weg sieht, will oft wissen, was daneben liegt. */ const [stackAnsicht, setStackAnsicht] = useState(() => { const id = new URLSearchParams(window.location.search).get("analyse"); return id && /^\d+$/.test(id) ? { id, titel: "", endpunkte: 0, stoffe: 0 } : null; }); /* Die Stoffauswahl greift erst, wenn sie auch etwas enthaelt. „Nur ausgewaehlte" ohne einen einzigen Stoff waere sonst eine Karte ohne Substanzen, und zwar genau in dem Moment, in dem jemand die Stufe waehlt und noch nichts eingetragen hat. */ const stoffFilter = stoffkreis === "auswahl" && stoffe.length ? stoffe.map((s) => s.id).join(",") : ""; const nurSeeds = stoffkreis === "seeds"; const filterQs = () => ({ minStaerke: minStaerke || "", evidenz: evidenz === "bevorzugt" ? "" : evidenz, nurSeeds: nurSeeds ? 1 : "", stoffe: stoffFilter, }); /* Nur fuer den Umkreis: die Grundansicht kennt keine Richtung, sie ist ein Ausschnitt und kein Weg. */ const umkreisQs = () => ({ ...filterQs(), tiefe: reichweite, limit: MK_UMFANG, richtung: richtung === "beide" ? "" : richtung, }); const typenQs = () => typen.length === MK_TYPEN.length ? "" : typen.map((t) => "&typen=" + encodeURIComponent(t)).join(""); /* Dieselbe Regel wie require_premium am Server. Sie steht hier nur, um die richtige Ansicht zu waehlen, entschieden wird sie am Server. */ const darf = !!user && (user.plan === "premium" || user.role === "admin" || user.role === "mod"); /* Grundansicht und Fokusansicht laufen durch dieselbe Wirkung: ein Filter muss auf beide gleich wirken, und ohne gemeinsamen Weg gibt es zwei Wahrheiten darueber, was gerade auf der Karte steht. */ useEffect(() => { if (!darf || !stackAnsicht) return; setLaedt(true); BB.get("/molekuelkarte/stack/" + stackAnsicht.id) .then((d) => { setNodes(d.nodes || []); setEdges(d.edges || []); setGekappt(false); setFehler(""); setLaedt(false); setStackAnsicht((s) => s && ({ ...s, titel: d.titel || "", endpunkte: (d.endpunkte || []).length, stoffe: (d.nodes || []).filter((n) => n.seed && n.type === "substance").length, })); setAnsicht((x) => x + 1); }) .catch((err) => { setFehler(err.message || BB.t("Das hat nicht geklappt.")); setLaedt(false); }); }, [darf, stackAnsicht && stackAnsicht.id]); function stackAnsichtVerlassen() { const url = new URL(window.location.href); url.searchParams.delete("analyse"); window.history.replaceState({}, "", url.pathname + url.search); ausrichtenRef.current = true; setStackAnsicht(null); } useEffect(() => { if (!darf || stackAnsicht) return; /* Keine Art gewaehlt heisst leere Karte. Ohne diesen Zweig ginge die Anfrage ohne `typen` hinaus, und der Server verstuende das als "kein Filter" und schickte alles zurueck: das Gegenteil dessen, was jemand erwartet, der gerade den letzten Chip abgewaehlt hat. */ if (!typen.length && !zentrum) { setNodes([]); setEdges([]); setGekappt(false); setFehler(""); setLaedt(false); return; } setLaedt(true); const pfad = zentrum ? "/molekuelkarte/umkreis/" + zentrum + BB.qs(umkreisQs()) : "/molekuelkarte/graph" + BB.qs({ ...filterQs(), limit: 60 }); BB.get(pfad + typenQs()) .then((d) => { setNodes(d.nodes); setEdges(d.edges); setGekappt(d.truncated); setFehler(""); setLaedt(false); if (ausrichtenRef.current) { ausrichtenRef.current = false; setAnsicht((x) => x + 1); } }) .catch((err) => { setFehler(err.message || BB.t("Das hat nicht geklappt.")); setLaedt(false); }); // reichweite steht bewusst nur mittelbar in der Liste, siehe // reichweiteSetzen weiter unten. }, [darf, !!stackAnsicht, typen, minStaerke, evidenz, nurSeeds, stoffFilter, zentrum, zentrum ? reichweite : 0, zentrum ? richtung : ""]); useEffect(() => { BB.get("/molekuelkarte/quellen").then((d) => setQuellen(d.quellen || [])).catch(() => {}); }, []); useEffect(() => { if (!hinweis) return; const t = setTimeout(() => setHinweis(""), 5000); return () => clearTimeout(t); }, [hinweis]); /* Vorschlaege mit Verzoegerung, sonst eine Anfrage je Tastendruck. Die Suche ruehrt die Karte nicht an: sie schlaegt nur vor. Erst die Auswahl setzt die Ansicht neu, und dann sichtbar und mit Absicht. */ useEffect(() => { const q = suche.trim(); if (!darf || q.length < 2) { setVorschlaege([]); setSuchtLaeuft(false); return; } setSuchtLaeuft(true); const t = setTimeout(() => { BB.get("/molekuelkarte/suche" + BB.qs({ q })) .then((d) => { setVorschlaege(d.treffer || []); setSuchtLaeuft(false); }) .catch(() => { setVorschlaege([]); setSuchtLaeuft(false); }); }, 220); return () => clearTimeout(t); }, [suche, darf]); function fokussieren(n) { setSuche(n.label || n.name || ""); setVorschlaege([]); setHinweis(""); setGewaehlt({ id: n.id, name: n.name || n.label, type: n.type, slug: n.slug, cid: n.pubchemCid || n.cid }); ausrichtenRef.current = true; setZentrum(String(n.id)); } function zurueckZurKarte() { setSuche(""); setVorschlaege([]); setHinweis(""); ausrichtenRef.current = true; setZentrum(null); setGewaehlt(null); } /* Die Reichweite gilt fuer den naechsten Klick, sie beschreibt nicht, was gerade auf der Karte steht. Ohne Mittelpunkt darf ihre Aenderung deshalb nichts neu holen: das warf sonst jede per Klick geholte Node wieder weg, bevor sie ueberhaupt gebraucht wurde. Mit Mittelpunkt aendert sie sehr wohl die Ansicht, und dann wird auch neu ausgerichtet. */ function reichweiteSetzen(r) { if (zentrum) ausrichtenRef.current = true; setReichweite(r); } function richtungSetzen(r) { if (zentrum) ausrichtenRef.current = true; setRichtung(r); } /* Der Wechsel des Stoffkreises laesst die Handauswahl stehen. Wer von „nur ausgewaehlte" kurz auf „alle" geht und zurueck, soll seine drei Stoffe wiederfinden statt sie neu zu suchen. Weggeworfen wird nur ueber das Kreuz am Chip. */ function stoffkreisSetzen(k) { if (zentrum) ausrichtenRef.current = true; setStoffkreis(k); } /* Was „unberuehrt" heisst. Steht alles hier, damit der Knopf unten und die Voreinstellungen oben nicht auseinanderlaufen koennen. */ const imStandard = typen.length === MK_TYPEN.length && minStaerke === 0 && evidenz === "bevorzugt" && reichweite === 1 && richtung === "beide" && stoffkreis === "alle" && stoffe.length === 0; function filterZuruecksetzen() { if (zentrum) ausrichtenRef.current = true; setTypen(MK_TYPEN); setMinStaerke(0); setEvidenz("bevorzugt"); setReichweite(1); setRichtung("beide"); setStoffkreis("alle"); setStoffe([]); } function ergaenzen(neueNodes, neueEdges, ankerId) { setNodes((alt) => { const da = new Map(alt.map((n) => [n.id, n])); const ankerTiefe = (da.get(ankerId) || {}).tiefe || 0; const dazu = []; for (const n of neueNodes || []) { if (da.has(n.id)) continue; /* Die Tiefe kommt vom Server relativ zum aufgeklappten Knoten. Auf der Karte muss sie relativ zum Mittelpunkt gelten, sonst saehe ein per Klick geholter Knoten so aus, als saesse er in der Mitte. */ dazu.push({ ...n, tiefe: Math.min(ankerTiefe + (n.tiefe || 0), 3) }); } return dazu.length ? alt.concat(dazu) : alt; }); setEdges((alt) => { const da = new Set(alt.map((e) => e.id)); const dazu = (neueEdges || []).filter((e) => !da.has(e.id)); return dazu.length ? alt.concat(dazu) : alt; }); } /* Ein Klick, der nichts bringt, sieht aus wie ein Klick, der nicht ankam. Mit einer eingestellten Richtung passiert das regelmaessig und zu Recht: an einem Rezeptor gibt es nichts Ausgehendes. Gesagt werden muss es trotzdem, sonst sucht jemand den Fehler bei sich. */ function aufklappen(nodeId) { setHinweis(""); /* Was schon dasteht, geht mit hinaus. Sonst kaemen nur die Kanten innerhalb der Antwort zurueck, und jede Verbindung vom neuen Knoten zu einem laengst sichtbaren fehlte, bis zufaellig auch deren anderes Ende aufgeklappt wird. Bei Metaboliten fiel das am staerksten auf: die haengen quer durch die Karte an mehreren Enzymen. */ const qs = BB.qs({ ...umkreisQs(), bekannt: nodes.map((n) => n.id).join(",") }); BB.get("/molekuelkarte/umkreis/" + nodeId + qs + typenQs()) .then((d) => { const da = new Set(nodes.map((n) => n.id)); const dazu = (d.nodes || []).filter((n) => !da.has(n.id)).length; /* Auch nachgereichte Kanten sind ein Ergebnis. Seit `bekannt` kann ein Klick null Knoten und trotzdem Verbindungen bringen, und „nichts Neues" waere dann schlicht falsch. */ const daK = new Set(edges.map((e) => e.id)); const dazuK = (d.edges || []).filter((e) => !daK.has(e.id)).length; ergaenzen(d.nodes, d.edges, nodeId); if (!dazu && !dazuK) { const el = nodes.find((n) => n.id === nodeId); const name = el ? el.label : ""; setHinweis(richtung === "beide" ? BB.t("Hier hängt nichts Neues mehr.") + (name ? ` (${name})` : "") : `${name}: ` + BB.t("in dieser Richtung gibt es hier nichts.")); } }) .catch(() => {}); } /* Rechtsklick nimmt wieder weg, was nur ueber diesen Knoten haengt. Nicht einfach „alle Nachbarn": ein Nachbar, der auch woanders haengt, gehoert zur Karte und nicht zu dieser einen Aufklappung. Ermittelt wird das ueber die Erreichbarkeit: was ohne den angeklickten Knoten nicht mehr an die Wurzel kommt, ist nur seinetwegen da. Damit faellt auch eine ueber mehrere Klicks gewachsene Kette in einem Zug wieder zusammen. Haengt nichts an ihm, ist er selbst gemeint. Ein Blatt am Rand hat nichts mitgebracht, das man ihm wegnehmen koennte, und bliebe sonst als einziger Knoten unentfernbar auf der Karte stehen. Bei einem gut verbundenen Knoten wird daraus ein Zweischritt, der sich von selbst erklaert: der erste Rechtsklick klappt seine Nachbarschaft zu, der zweite nimmt ihn weg. Der Mittelpunkt bleibt in jedem Fall stehen. Ihn wegzunehmen hiesse, die Ansicht zu loeschen, auf der die Karte gerade steht, und dafuer gibt es „Ganze Karte". */ function zuklappen(nodeId) { const behalten = mkErreichbar(nodes, edges, nodeId, zentrum); if (behalten.size >= nodes.length) { if (nodes.length <= 1 || nodeId === zentrum) return; behalten.delete(nodeId); } setNodes(nodes.filter((n) => behalten.has(n.id))); setEdges(edges.filter((e) => behalten.has(e.src) && behalten.has(e.dst))); setGewaehlt((g) => (g && !behalten.has(g.id) ? null : g)); } const genutzt = quellen.filter((q) => q.kanten > 0); const zentrumNode = zentrum ? nodes.find((n) => n.id === zentrum) : null; if (!ready) return
; if (!darf) return ; return (

{BB.t("Molekülkarte")}

{BB.t("Substanzen, Zielproteine und virtuelle Analyse-Endpunkte als Netzwerk. Linksklick auf eine Node holt ihre Nachbarn dazu, Rechtsklick nimmt sie wieder weg.")}

{stackAnsicht && !zentrum && (
{BB.t("Stackansicht")}{stackAnsicht.titel ? ": " : ""} {stackAnsicht.titel} {" · "}{stackAnsicht.stoffe} {BB.t("Stoffe")} {" · "}{stackAnsicht.endpunkte} {BB.t("Analyse-Endpunkte")} {" · "}{BB.t("gestrichelt: gepflegter Weg, nicht gemessen")}
)} {zentrum && (
{BB.t("Im Mittelpunkt")}: {zentrumNode ? zentrumNode.label : suche} {" · "}{BB.t("Reichweite")} {reichweite} {richtung !== "beide" && ( <> · {BB.t((MK_RICHTUNGEN.find((r) => r[0] === richtung) || [])[1])} )} {stoffFilter && ( <> · {BB.t("Stoffauswahl")} ({stoffe.length}) )} {" · "}{nodes.length} {BB.t("Knoten")}
)}
{fehler &&

{fehler}

} {laedt && !nodes.length && } {!laedt && !fehler && !nodes.length && ( {BB.t("Kein Knoten passt zu den gewählten Filtern.")} )} {nodes.length > 0 && (
setGewaehlt(null)} onFokus={fokussieren} />
)}
{hinweis &&

{hinweis}

} {gekappt && (

{BB.t("Die Karte zeigt einen Ausschnitt. Klick auf eine Node holt mehr dazu.")}

)} {genutzt.length > 0 && (

{BB.t("Daten aus")}{" "} {genutzt.map((q, i) => ( {i > 0 && ", "} {q.label} {/* Lizenzpflichtige Quellen tragen ein Zeichen. Wer die Karte ansieht, soll erkennen koennen, welcher Teil an eine Lizenz gebunden ist, statt es erst im Kleingedruckten zu erfahren. */} {q.lizenzpflichtig ? § : null} ({q.license}) ))}

)}
); } /* Welche Knoten bleiben, wenn `ohne` nicht mehr weiterleitet. Gestartet wird beim Mittelpunkt, sonst beim bestverbundenen Knoten. Der angeklickte Knoten selbst bleibt immer: weggenommen wird, was er mitgebracht hat, nicht er. */ function mkErreichbar(nodes, edges, ohne, zentrum) { const nachbarn = new Map(); for (const n of nodes) nachbarn.set(n.id, []); for (const e of edges) { if (nachbarn.has(e.src) && nachbarn.has(e.dst)) { nachbarn.get(e.src).push(e.dst); nachbarn.get(e.dst).push(e.src); } } let wurzel = zentrum && nachbarn.has(zentrum) ? zentrum : null; if (!wurzel || wurzel === ohne) { // Rechtsklick auf den Mittelpunkt heisst: alles wieder zuklappen. if (zentrum && ohne === zentrum) return new Set([zentrum]); let best = -1; for (const n of nodes) { const grad = (nachbarn.get(n.id) || []).length; if (n.id !== ohne && grad > best) { best = grad; wurzel = n.id; } } } if (!wurzel) return new Set(nodes.map((n) => n.id)); const erreicht = new Set([wurzel, ohne]); const stapel = [wurzel]; while (stapel.length) { const id = stapel.pop(); if (id === ohne) continue; // ueber den angeklickten Knoten geht es nicht weiter for (const m of nachbarn.get(id) || []) { if (!erreicht.has(m)) { erreicht.add(m); stapel.push(m); } } } return erreicht; }