Files
Vorrania/web/src/groupGraph.js
Scarriffle e00eef275c Gruppen hierarchisch: in allen Auswahlfeldern und in der Artikelliste
Die Gruppen-Dropdowns waren flache, alphabetische Listen - man sah nicht, dass
"Grillwurst" unter "Wurst" haengt. Ein gemeinsamer Helfer (gruppenOptionen)
rollt den Graphen zum Baum aus und rueckt ein; eine Gruppe mit mehreren
Obergruppen erscheint unter jeder, denn genau das ist ihr Sinn, und ausgewaehlt
wird ohnehin dieselbe ID.

Eingerueckt wird mit GESCHUETZTEN Leerzeichen - normale fasst der Browser in
einem <option> zusammen, die Einrueckung waere sonst wirkungslos.

Betroffen: Gruppen-Auswahl im Artikelformular (beide Zweige), beim Einlagern,
die Ziel-Auswahl der Mindestbestaende und die Obergruppen-Mehrfachauswahl (die
beim Suchen bewusst abflacht, weil eine Einrueckung ohne die Eltern in die Irre
fuehrt). In der App dasselbe in beiden Gruppen-Pickern.

Die Gruppen-Spalte der Artikelliste zeigt jetzt den Weg von oben statt eines
Etiketts - "Wurst → Grillwurst", mit gedaempften Vorfahren wie bei der
Kategorie. Gefiltert wird ueber ALLE Wege: wer auf "Wurst" filtert, sieht auch
die Artikel aus den Untergruppen. Dafuer gibt es gruppenInfoMap als Gegenstueck
zu categoryInfoMap, das mit mehreren Wegen je Gruppe umgehen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:59:32 +02:00

149 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum:
// „Grillwurst" hängt unter „Wurst" UND unter „Grillgut". Deshalb hier
// Mengen-Helfer statt der Baum-Helfer aus categoryTree.js ein Baum-Walk
// würde eine über zwei Wege erreichbare Gruppe doppelt liefern.
//
// Alle Funktionen schützen sich mit einem gesehen-Set gegen Ringe. Die API
// lässt keinen zu, eine eingespielte Sicherung könnte aber einen mitbringen.
const byId = (groups) => new Map((groups || []).map((g) => [g.id, g]));
/** IDs aller Untergruppen (transitiv, ohne die Gruppe selbst). */
export function nachfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [id];
while (offen.length) {
const cur = offen.pop();
for (const kind of map.get(cur)?.child_ids || []) {
if (!gesehen.has(kind)) {
gesehen.add(kind);
offen.push(kind);
}
}
}
gesehen.delete(id);
return gesehen;
}
/** IDs aller Obergruppen (transitiv, ohne die Gruppe selbst). */
export function vorfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [...(map.get(id)?.parent_ids || [])];
while (offen.length) {
const cur = offen.pop();
if (gesehen.has(cur)) continue;
gesehen.add(cur);
offen.push(...(map.get(cur)?.parent_ids || []));
}
gesehen.delete(id);
return gesehen;
}
/**
* Alle Wege zu einer Gruppe, z.B. [["Wurst","Grillwurst"], ["Grillgut","Grillwurst"]].
* Anders als bei Kategorien gibt es nicht DEN einen Pfad deshalb eine Liste.
*/
export function pfade(groups, id) {
const map = byId(groups);
const bauen = (cur, gesehen) => {
const g = map.get(cur);
if (!g) return [[]];
const eltern = (g.parent_ids || []).filter((p) => !gesehen.has(p));
if (eltern.length === 0) return [[g.name]];
const raus = [];
for (const p of eltern) {
for (const oben of bauen(p, new Set([...gesehen, cur]))) {
raus.push([...oben, g.name]);
}
}
return raus;
};
return bauen(id, new Set());
}
/** Die Wege als ein Text: „Wurst → Grillwurst · Grillgut → Grillwurst". */
export function pfadText(groups, id) {
return pfade(groups, id)
.map((teile) => teile.join(" → "))
.join(" · ");
}
/** Namen der Obergruppen, für Chips und Spaltentexte. */
export function elternNamen(groups, group) {
const map = byId(groups);
return (group?.parent_ids || []).map((id) => map.get(id)?.name).filter(Boolean);
}
/**
* Gruppen für ein Auswahlfeld: als Baum ausgerollt und eingerückt.
*
* Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER genau das ist
* der Sinn mehrerer Obergruppen. Der Wert ist ohnehin dieselbe ID, egal welche
* Zeile man trifft.
*
* Eingerückt wird mit GESCHÜTZTEN Leerzeichen: normale fasst der Browser in
* einem <option> zusammen, die Einrückung wäre dann wirkungslos.
*/
export function gruppenOptionen(groups) {
const liste = groups || [];
const vorhanden = new Set(liste.map((g) => g.id));
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
const kinderVon = new Map();
const wurzeln = [];
for (const g of liste) {
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
if (eltern.length === 0) wurzeln.push(g);
for (const e of eltern) {
if (!kinderVon.has(e)) kinderVon.set(e, []);
kinderVon.get(e).push(g);
}
}
const out = [];
const walk = (g, tiefe, pfad, gesehen) => {
const eigen = `${pfad}/${g.id}`;
out.push({
id: g.id,
key: eigen,
gruppe: g,
tiefe,
label: `${"\u00A0\u00A0\u00A0".repeat(tiefe)}${tiefe > 0 ? "\u21B3 " : ""}${g.name}`,
});
if (gesehen.has(g.id)) return; // Ringschutz
const weiter = new Set(gesehen).add(g.id);
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
walk(k, tiefe + 1, eigen, weiter);
}
};
for (const w of wurzeln.slice().sort(nachName)) walk(w, 0, "", new Set());
return out;
}
/**
* Gegenstück zu categoryInfoMap für Gruppen: je Gruppe der Weg von oben.
*
* Anders als bei Kategorien kann es MEHRERE Wege geben („Grillwurst" unter
* „Wurst" und unter „Grillgut"). Angezeigt wird der erste, gefiltert wird über
* alle wer auf „Wurst" filtert, will „Grillwurst" mitsehen.
*/
export function gruppenInfoMap(groups) {
const out = new Map();
for (const g of groups || []) {
const wege = pfade(groups, g.id);
const erster = wege[0]?.length ? wege[0] : [g.name];
const tokens = new Set();
for (const weg of wege) {
weg.forEach((_, i) => tokens.add(weg.slice(0, i + 1).join(" → ")));
}
out.set(g.id, {
parts: erster,
path: erster.join(" → "),
alle: wege.map((w) => w.join(" → ")),
tokens: [...tokens],
});
}
return out;
}