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) <noreply@anthropic.com>
This commit is contained in:
Michael Stangl
2026-07-27 10:10:10 +02:00
parent d4844ded67
commit d19913a53d
4 changed files with 243 additions and 14 deletions
+44
View File
@@ -0,0 +1,44 @@
# Farbprofile fuer die SVG-Ausgabe (Sitzplatzverteilung.alsSVG).
#
# Jede Sektion ist ein Farbprofil, waehlbar per --farben <Sektion> (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
+165
View File
@@ -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 <Sektion> (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]
+21 -13
View File
@@ -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():
+13 -1
View File
@@ -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 )