From d19913a53d7e2f9fa1f81e3cb423b99ecd8abfd8 Mon Sep 17 00:00:00 2001 From: Michael Stangl Date: Mon, 27 Jul 2026 10:10:10 +0200 Subject: [PATCH] SVG-Farbprofile: --farben mit palette- und kategorien-Modus Neues Modul libs/Farben.py mit Farbschema, das Gruppen Farben zuordnet, gesteuert durch Profile aus cfg/farben.cfg. Zwei Modi: "palette" (Farbliste zyklisch je Gruppe, Profil grosse_gruppen) und "kategorien" (Hochzeit: Familie/Verein/VIP getrennt ueber Farbverlaeufe, Profil hochzeit). Sitzplatzverteilung.alsSVG bekommt Parameter Farbschema; Farben werden nun fest fuer die vollstaendige Gruppenliste (nach Id) vergeben statt lazy in Zeichenreihenfolge, damit jede Gruppe variantenuebergreifend dieselbe Farbe hat. platz.py bekommt Schalter --farben (Vorgabe grosse_gruppen). Co-Authored-By: Claude Opus 4.8 (1M context) --- cfg/farben.cfg | 44 +++++++++++ libs/Farben.py | 165 ++++++++++++++++++++++++++++++++++++++++++ libs/Strukturdaten.py | 34 +++++---- libs/platz.py | 14 +++- 4 files changed, 243 insertions(+), 14 deletions(-) create mode 100644 cfg/farben.cfg create mode 100644 libs/Farben.py diff --git a/cfg/farben.cfg b/cfg/farben.cfg new file mode 100644 index 0000000..0bdc26a --- /dev/null +++ b/cfg/farben.cfg @@ -0,0 +1,44 @@ +# Farbprofile fuer die SVG-Ausgabe (Sitzplatzverteilung.alsSVG). +# +# Jede Sektion ist ein Farbprofil, waehlbar per --farben (libs/platz.py, +# Vorgabe: grosse_gruppen) bzw. ueber PLATZ_FARBENART (Web). So laesst sich je +# Anlass ein anderes Farbschema vorwaehlen. +# +# Modus = palette +# Eine Farbliste (Palette), die zyklisch je Gruppe (nach Gruppen-Id sortiert) +# vergeben wird. Gut fuer viele/grosse Gruppen, wo es nur auf klare +# Unterscheidbarkeit ankommt. +# +# Modus = kategorien +# Trennt die Gruppen nach Art und faerbt sie unterschiedlich ein - gedacht +# fuer Hochzeiten: +# - Familie: eine Gruppe, deren Mitglieder mehrheitlich denselben +# Nachnamen tragen. Jeder Nachname bekommt einen Farbverlauf +# (abwechselnd aus FamilienVerlaeufe), Untergruppen desselben +# Nachnamens laufen den Verlauf entlang (z.B. Blau -> Gruen). +# - Verein: jede sonstige benannte Gruppe (Freunde, Kollegen, Vereine); +# laeuft ueber VereinVerlauf (z.B. Violett -> Grau). +# - VIP: fest gesetzte Gaeste ohne GA-Gruppe (Ehrentafel, Kindertisch); +# bekommen die Startfarbe von VIPVerlauf (Rot). +# +# Farbverlauf-Syntax: "Startfarbe -> Endfarbe"; mehrere Verlaeufe kommagetrennt. + + +# Fuer grosse Veranstaltungen mit vielen Gruppen: reine Palette, zyklisch. +[grosse_gruppen] +Modus = palette +Palette = #e6194b, #3cb44b, #4363d8, #f58231, #911eb4, #42d4f4, #f032e6, + #bfef45, #fabed4, #469990, #dcbeff, #9a6324, #800000, #aaffc3, + #000075, #808000, #ffd8b1, #f58231, #46f0f0, #d2f53c + + +# Fuer Hochzeiten: nach Familie / Verein / VIP getrennt. +[hochzeit] +Modus = kategorien +# Familien: abwechselnd Blau -> Gruen und Gelb -> Orange (je Nachname ein +# Verlauf, damit sich verschiedene Familien unterscheiden). +FamilienVerlaeufe = #1f4e9c -> #2fae5f, #f2c200 -> #e8590c +# Vereine / gemischte Gruppen: Violett -> Grau. +VereinVerlauf = #7b2fbf -> #9aa0a6 +# VIPs (feste Platzierung): Rot. +VIPVerlauf = #e63946 -> #8b0000 diff --git a/libs/Farben.py b/libs/Farben.py new file mode 100644 index 0000000..364483a --- /dev/null +++ b/libs/Farben.py @@ -0,0 +1,165 @@ +""" +Farbschema fuer die SVG-Ausgabe (Sitzplatzverteilung.alsSVG). + +Laedt ein Farbprofil aus cfg/farben.cfg und ordnet jeder Gruppe eine Farbe zu. +Zwei Modi (siehe cfg/farben.cfg fuer Details): + - 'palette': eine Farbliste, zyklisch je Gruppe (nach Gruppen-Id sortiert) + - 'kategorien': trennt nach Art (Hochzeit) in Familie / Verein / VIP und + faerbt sie ueber je einen Farbverlauf ein. + +Waehlbar per --farben (libs/platz.py) bzw. PLATZ_FARBENART (web). +""" + +__author__ = "Michael Stangl" +__copyright__ = "Copyright (c) 2026 Michael Stangl" +__license__ = "Python" + +import configparser +import os +from collections import Counter, defaultdict + + +def _hex_zu_rgb(h): + h = h.strip().lstrip('#') + return tuple( int(h[i:i+2], 16) for i in (0, 2, 4) ) + + +def _rgb_zu_hex(rgb): + return '#%02x%02x%02x' % tuple( max(0, min(255, int(round(c)))) for c in rgb ) + + +def _verlauf(start, ende, t): + """Farbe im linearen RGB-Verlauf start->ende an Position t in [0,1]""" + a, b = _hex_zu_rgb(start), _hex_zu_rgb(ende) + return _rgb_zu_hex( tuple( a[i] + (b[i] - a[i]) * t for i in range(3) ) ) + + +def _verlauf_liste(start, ende, n): + """n Farben gleichmaessig ueber den Verlauf start->ende (n>=1)""" + if n <= 1: + return [ _verlauf(start, ende, 0.0) ] + return [ _verlauf(start, ende, j / (n - 1)) for j in range(n) ] + + +class Farbschema: + """ordnet Gruppen Farben zu, gesteuert durch ein Profil aus farben.cfg""" + + # Rueckfall-Palette, falls keine Config / kein gueltiges Profil geladen wurde + Standard = [ + '#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4', + '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', + '#dcbeff', '#9a6324', '#800000', '#aaffc3', '#000075' ] + + def __init__(self): + self.Modus = 'palette' + self.Palette = list(self.Standard) + self.FamilienVerlaeufe = [ ('#1f4e9c', '#2fae5f'), ('#f2c200', '#e8590c') ] + self.VereinVerlauf = ('#7b2fbf', '#9aa0a6') + self.VIPVerlauf = ('#e63946', '#8b0000') + + def laden(self, FileName, Sektion='grosse_gruppen'): + config = configparser.ConfigParser() + config.read( os.path.expanduser(FileName) ) + if not config.has_section( Sektion ): + raise ValueError( + "Farbprofil '%s' nicht in %s gefunden (vorhanden: %s)" + % ( Sektion, FileName, ', '.join(config.sections()) or '-' ) ) + self.Modus = config.get( Sektion, 'Modus', fallback='palette' ).strip() + if config.has_option( Sektion, 'Palette' ): + self.Palette = self._farbliste( config.get( Sektion, 'Palette' ) ) + if config.has_option( Sektion, 'FamilienVerlaeufe' ): + self.FamilienVerlaeufe = self._verlaeufe( + config.get( Sektion, 'FamilienVerlaeufe' ) ) + if config.has_option( Sektion, 'VereinVerlauf' ): + self.VereinVerlauf = self._verlaeufe( config.get( Sektion, 'VereinVerlauf' ) )[0] + if config.has_option( Sektion, 'VIPVerlauf' ): + self.VIPVerlauf = self._verlaeufe( config.get( Sektion, 'VIPVerlauf' ) )[0] + return self + + @staticmethod + def _farbliste(text): + return [ f.strip() for f in text.replace('\n', ',').split(',') if f.strip() ] + + @staticmethod + def _verlaeufe(text): + """'A -> B, C -> D' -> [(A,B),(C,D)]; ein einzelner Wert wird zu (v,v)""" + ergebnis = [] + for teil in text.replace('\n', ',').split(','): + teil = teil.strip() + if not teil: + continue + if '->' in teil: + a, b = teil.split('->', 1) + ergebnis.append( (a.strip(), b.strip()) ) + else: + ergebnis.append( (teil, teil) ) + return ergebnis or [ ('#888888', '#888888') ] + + def farben(self, Gruppen): + """ordnet den Gruppen Farben zu. + + Rueckgabe: (GidZuFarbe {Gruppen-Id: hexfarbe}, VIPFarbe hexfarbe). + VIPFarbe gilt fuer Personen ohne GA-Gruppe (feste Platzierung).""" + GruppenListe = sorted( Gruppen, key=lambda g: g.Id ) + if self.Modus == 'kategorien': + return self._kategorien( GruppenListe ) + return self._palette( GruppenListe ) + + def _palette(self, GruppenListe): + pal = self.Palette or self.Standard + GidZuFarbe = { G.Id: pal[i % len(pal)] for i, G in enumerate(GruppenListe) } + return GidZuFarbe, '#888888' + + @staticmethod + def _nachname(G): + """dominanter Nachname der Gruppe, wenn ihn die Mehrheit traegt (bei + >= 2 Mitgliedern) - sonst None (dann gilt die Gruppe als Verein/ + gemischt). Anonyme Sammelbuchungen (alle Mitglieder derselbe Name, + z.B. 13x 'Gast Musikverein') sind keine Familie, sondern gelten als + Verein - daher werden mindestens zwei VERSCHIEDENE Personen verlangt.""" + personen = list( G ) + if len(personen) < 2: + return None + if len({ (P._Vorname, P._Name) for P in personen }) < 2: + return None # anonymer Block (alle identisch) -> kein Familienname + namen = [ P._Name for P in personen ] + name, anzahl = Counter(namen).most_common(1)[0] + return name if anzahl * 2 > len(namen) else None + + def _kategorien(self, GruppenListe): + # 1. klassifizieren: Familien (nach Nachname) vs. Vereine + familien = defaultdict(list) # Nachname -> [Gruppen] + vereine = [] + for G in GruppenListe: + nn = self._nachname( G ) + if nn is not None: + familien[nn].append( G ) + else: + vereine.append( G ) + GidZuFarbe = {} + # 2. Familien: jeder Nachname kommt (alphabetisch, abwechselnd) in einen + # Farbbereich - Blau->Gruen ODER Gelb->Orange. Innerhalb eines + # Bereichs werden ALLE zugehoerigen Familiengruppen ueber den Verlauf + # gespreizt, damit verschiedene Familien verschiedene Toene haben; + # Gruppen desselben Nachnamens bleiben benachbart (ein zusammen- + # haengendes Band). So laeuft z.B. eine mehrteilige Familie Brandt + # ueber benachbarte Gelb->Orange-Toene, waehrend Familie Peters einen + # anderen Blau->Gruen-Ton bekommt. + Nachnamen = sorted( familien ) + Bereich = { nn: i % len(self.FamilienVerlaeufe) + for i, nn in enumerate(Nachnamen) } + GruppenImBereich = defaultdict(list) + for nn in Nachnamen: + for G in sorted( familien[nn], key=lambda g: g.Id ): + GruppenImBereich[ Bereich[nn] ].append( G ) + for b, gruppen in GruppenImBereich.items(): + start, ende = self.FamilienVerlaeufe[b] + for farbe, G in zip( _verlauf_liste(start, ende, len(gruppen)), gruppen ): + GidZuFarbe[G.Id] = farbe + # 3. Vereine: ein Verlauf (z.B. Violett -> Grau) ueber alle Vereine + vstart, vende = self.VereinVerlauf + for farbe, G in zip( _verlauf_liste(vstart, vende, len(vereine)), + sorted(vereine, key=lambda g: g.Id) ): + GidZuFarbe[G.Id] = farbe + # 4. VIP-Farbe: Startfarbe des VIP-Verlaufs (i.d.R. Rot) + return GidZuFarbe, self.VIPVerlauf[0] diff --git a/libs/Strukturdaten.py b/libs/Strukturdaten.py index 6335d7f..e786bb2 100755 --- a/libs/Strukturdaten.py +++ b/libs/Strukturdaten.py @@ -608,7 +608,7 @@ class Sitzplatzverteilung: '#dcbeff', '#9a6324', '#800000', '#aaffc3', '#000075' ] def alsSVG(self, OutputSVG, PersonenRadius=12, Puffer=10, Verbindungen=True, - GruppenNamen=None): + GruppenNamen=None, Farbschema=None): """Zeichnet die Sitzplatzverteilung als SVG: Tische werden als weisse Kreise dargestellt, Personen als @@ -632,7 +632,7 @@ class Sitzplatzverteilung: def GruppenName(G): if G is None: - return 'VIP / feste Platzierung' + return 'fixiert' return GruppenNamen.get( G.Id, 'Gruppe %s' % G.Id ) def PersonName(P): @@ -685,20 +685,29 @@ class Sitzplatzverteilung: except KeyError: return None - GidZuFarbe = {} + # Feste Gruppenfarben: einmal fuer die vollstaendige Gruppenliste (nach + # Gruppen-Id) vergeben - NICHT lazy nach der Sitz-Reihenfolge. Da alle + # Varianten dieselbe Gruppenmenge mit denselben Ids teilen, hat so jede + # Gruppe in jeder Variante (und ueber Laeufe hinweg) dieselbe Farbe. + # Mit Farbschema (aus farben.cfg) kommen die Farben von dort (z.B. nach + # Familie/Verein/VIP getrennt); ohne dient die eingebaute Palette als + # Rueckfall (zyklisch je Gruppe). + if Farbschema is not None: + GidZuFarbe, VIPFarbe = Farbschema.farben( self.Gruppen ) + else: + GidZuFarbe = {} + for Index, G in enumerate( sorted( self.Gruppen, key=lambda g: g.Id ) ): + GidZuFarbe[G.Id] = self.SVGGruppenFarben[ + Index % len(self.SVGGruppenFarben) ] + VIPFarbe = '#888888' def FarbeVonGruppe(G): if G is None: - return '#888888' - if G.Id not in GidZuFarbe: - Index = len(GidZuFarbe) % len(self.SVGGruppenFarben) - GidZuFarbe[G.Id] = self.SVGGruppenFarben[Index] - return GidZuFarbe[G.Id] + return VIPFarbe # VIP / feste Platzierung (ohne GA-Gruppe) + return GidZuFarbe.get( G.Id, VIPFarbe ) # Fuer jede Gruppe pro Tisch den Mittelpunkt (Zentroid) ihrer - # an diesem Tisch sitzenden Personenkreise sammeln. Die Reihenfolge - # der Iteration (Tische, darin Personen) stimmt mit der spaeteren - # Zeichenschleife ueberein, damit die per FarbeVonGruppe() lazy - # vergebenen Farben konsistent sind. + # an diesem Tisch sitzenden Personenkreise sammeln (fuer die + # Verbindungslinien getrennter Gruppen). GidZuTischZentroiden = {} for T in self.Tische: Mx, My = TischMitte(T) @@ -713,7 +722,6 @@ class Sitzplatzverteilung: G = GruppeVonPerson(P) if G is None: continue - FarbeVonGruppe(G) # Farbe frueh (aber in Zeichenreihenfolge) festlegen sx, sy, cnt = GidZuSummen.get( G.Id, (0.0, 0.0, 0) ) GidZuSummen[G.Id] = (sx + Px, sy + Py, cnt + 1) for Gid, (sx, sy, cnt) in GidZuSummen.items(): diff --git a/libs/platz.py b/libs/platz.py index 5115273..1d39eef 100755 --- a/libs/platz.py +++ b/libs/platz.py @@ -11,6 +11,7 @@ __license__ = "Python" from ga import * from Strukturdaten import * +from Farben import Farbschema import optparse import random @@ -55,6 +56,10 @@ if __name__ == '__main__': help="Name des Strafpunkte-Profils (Sektion in strafen.cfg; " "Vorgabe: default). Erlaubt mehrere Bewertungs-Vorgehen in einer " "Datei, z.B. --strafen locker" ) + parser.add_option( "--farben", dest="farben", default="grosse_gruppen", + help="Name des Farbprofils fuer die SVG-Ausgabe (Sektion in " + "farben.cfg; Vorgabe: grosse_gruppen). Fuer Hochzeiten: " + "--farben hochzeit (Familien/Vereine/VIPs getrennt)" ) parser.add_option( "--seed", dest="seed", type="int", default=None, help="Zufalls-Startwert (int) fuer random.seed(), macht den Lauf " "reproduzierbar. Ohne Angabe laeuft der GA nichtdeterministisch. " @@ -88,6 +93,8 @@ if __name__ == '__main__': Zyklusart = options.zyklusart Strafenconfig = os.path.join( CfgDir, 'strafen.cfg' ) Strafenart = options.strafen + Farbenconfig = os.path.join( CfgDir, 'farben.cfg' ) + Farbenart = options.farben # lade Eingabedateien Tischconfig = os.path.join( InDir, 'tische.ini' ) @@ -109,6 +116,11 @@ if __name__ == '__main__': except ValueError as Fehler: parser.error( str(Fehler) ) print(SL) + FS = Farbschema() + try: + FS.laden( Farbenconfig, Farbenart ) + except ValueError as Fehler: + parser.error( str(Fehler) ) TE = Tische() TE.laden( Tischconfig ) print(TE) @@ -128,7 +140,7 @@ if __name__ == '__main__': S.speichern( AusgabeTP, AusgabePT ) if options.tosvg: S.alsSVG( AusgabeSVG, Verbindungen=options.showconn, - GruppenNamen=GruppenNamen ) + GruppenNamen=GruppenNamen, Farbschema=FS ) if options.showdev: F1.Entwicklung.alsSVG( AusgabeEntwicklung ) print( "Entwicklungsverlauf geschrieben:", AusgabeEntwicklung )