From 9fa45f85b5ba4827d3a9f9bb704a076a06f2d4ef Mon Sep 17 00:00:00 2001
From: untergeekDE <jan@eggers-elektronik.de>
Date: Sat, 11 Mar 2023 15:32:24 +0100
Subject: [PATCH] =?UTF-8?q?V1.0=20-=20getestet=20f=C3=BCr=20Kassel?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .gitignore                                    |  11 +
 R/aktualisiere_karten.R                       | 482 ++++++++++++++++--
 R/generiere_testdaten.R                       | 140 -----
 R/its_alive.R                                 |  41 ++
 R/lies_aktuellen_stand.R                      | 374 +++++++++++---
 R/lies_konfiguration.R                        |  87 ++++
 R/main.R                                      | 117 +++++
 R/main_oneshot.R                              |  95 ++++
 R/messaging.R                                 |  15 +-
 R/update_all.R                                | 203 --------
 README.md                                     |  37 +-
 howto_shapefiles.md                           |  26 +
 index/config.csv                              |  29 +-
 index/config_test.csv                         |  23 +
 index/ffm_config.csv                          |  37 ++
 index/ffm_config_test.csv                     |  37 ++
 index/obwahl_ffm_2023/kandidaten.xlsx         | Bin 0 -> 6920 bytes
 index/obwahl_ffm_2023/stadtteile.csv          |  45 ++
 .../zuordnung_wahllokale.csv                  |   0
 index/obwahl_ks_2023/kandidaten.xlsx          | Bin 0 -> 6265 bytes
 ...tadtteile-mit-d\303\266nchelandschaft.csv" |  25 +
 index/obwahl_ks_2023/ks-stadtteile.csv        |  24 +
 index/obwahl_ks_2023/wahlbezirke.xlsx         | Bin 0 -> 10250 bytes
 index/stadtteile.csv                          |  45 --
 sitemap.md                                    |  41 ++
 25 files changed, 1394 insertions(+), 540 deletions(-)
 delete mode 100644 R/generiere_testdaten.R
 create mode 100644 R/its_alive.R
 create mode 100644 R/lies_konfiguration.R
 create mode 100644 R/main.R
 create mode 100644 R/main_oneshot.R
 delete mode 100644 R/update_all.R
 create mode 100644 howto_shapefiles.md
 create mode 100644 index/config_test.csv
 create mode 100644 index/ffm_config.csv
 create mode 100644 index/ffm_config_test.csv
 create mode 100644 index/obwahl_ffm_2023/kandidaten.xlsx
 create mode 100644 index/obwahl_ffm_2023/stadtteile.csv
 rename index/{ => obwahl_ffm_2023}/zuordnung_wahllokale.csv (100%)
 create mode 100644 index/obwahl_ks_2023/kandidaten.xlsx
 create mode 100644 "index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv"
 create mode 100644 index/obwahl_ks_2023/ks-stadtteile.csv
 create mode 100644 index/obwahl_ks_2023/wahlbezirke.xlsx
 delete mode 100644 index/stadtteile.csv
 create mode 100644 sitemap.md

diff --git a/.gitignore b/.gitignore
index fae8299..5cc2df5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
 
 # Session Data files
 .RData
+.DS_Store
 
 # User-specific files
 .Ruserdata
@@ -37,3 +38,13 @@ vignettes/*.pdf
 
 # R Environment Variables
 .Renviron
+
+# This file
+.gitignore
+
+# Test and sample data
+/testdaten/
+/vorlagen/
+/daten/
+/png/
+/R/Vorbereitung/
diff --git a/R/aktualisiere_karten.R b/R/aktualisiere_karten.R
index 28626b7..406c3bb 100644
--- a/R/aktualisiere_karten.R
+++ b/R/aktualisiere_karten.R
@@ -1,49 +1,443 @@
 #' aktualisiere_karten.R
+#' 
+#' Die Funktionen, um die Grafiken zu aktualisieren - und Hilfsfunktionen
+#' 
 
+#---- Generiere den Fortschrittsbalken ----
 
-aktualisiere_karten <- function(wl_url = stimmbezirke_url) {
-  # Lies Ortsteil-Daten ein und vergleiche
-  neue_orts_df <- lies_gebiet(wl_url) %>% 
-    aggregiere_stadtteile() %>% 
-    mutate(quorum = ifelse(wahlberechtigt == 0,
-                           0,
-                           ja / wahlberechtigt * 100)) %>% 
-    mutate(status = ifelse(meldungen_anz == 0,
-                           "KEINE DATEN",
-                           paste0(ifelse(meldungen_anz < meldungen_max,
-                                          "TREND ",""),
-                                   ifelse(ja < nein,
-                                          "NEIN",
-                                          ifelse(quorum < 30,
-                                                 "JA",
-                                                 "JA QUORUM")))
-    ))
-  alte_orts_df <- hole_letztes_df("daten/ortsteile")
-  # Datenstand identisch? Dann brich ab. 
-  if(vergleiche_stand(alte_orts_df,neue_orts_df)) {
-    return(FALSE) 
+
+generiere_auszählungsbalken <- function(anz = gezaehlt,max_s = stimmbezirke_n,ts = ts) {
+  fortschritt <- floor(anz/max_s*100)
+  annotate_str <- paste0("Ausgezählt sind ",
+                         # Container Fake-Balken
+                         "<span style='height:24px;display: flex;justify-content: space-around;align-items: flex-end; width: 100%;'>",
+                         # Vordere Pufferzelle 70px
+                         "<span style='width:70px; text-align:center;'>",
+                         anz,
+                         "</span>",
+                         # dunkelblauer Balken
+                         "<span style='width:",
+                         fortschritt,
+                         "%; background:#002747; height:16px;'></span>",
+                         # grauer Balken
+                         "<span style='width:",
+                         100-fortschritt,
+                         "%; background:#CCC; height:16px;'></span>",
+                         # Hintere Pufferzelle 5px
+                         "<span style='width:5px;'></span>",
+                         # Ende Fake-Balken
+                         "</span>",
+                         "<br>",
+                         " von ",max_s,
+                         " Stimmbezirken - ",
+                         "<strong>Stand: ",
+                         format.Date(ts, "%d.%m.%y, %H:%M Uhr"),
+                         "</strong>"
+  )
+  
+}
+
+generiere_auszählung_nurtext <- function(anz = gezaehlt,max_s = stimmbezirke_n,ts = ts) {
+  fortschritt <- floor(anz/max_s*100)
+  annotate_str <- paste0("Ausgezählt: ",
+                         anz,
+                         " von ",max_s,
+                         " Stimmbezirken - ",
+                         "<strong>Stand: ",
+                         format.Date(ts, "%d.%m.%y, %H:%M Uhr"),
+                         "</strong>"
+  )
+  
+}
+
+#---- Hilfsfunktionen: Switcher generieren, Farben anpassen ----
+# Funktion gibt Schwarz zurück, wenn Farbe hell ist, und Weiß, wenn sie 
+# relativ dunkel ist. 
+font_colour <- function(colour) {
+  # convert color to hexadecimal format and extract RGB values
+  hex <- substr(colour, 2, 7)
+  r <- strtoi(paste0("0x",substr(hex, 1, 2)))
+  g <- strtoi(paste0("0x",substr(hex, 3, 4)))
+  b <- strtoi(paste0("0x",substr(hex, 5, 6)))
+
+  # suggested by chatGPT: 
+  # calculate the brightness of the color using the formula Y = 0.2126*R + 0.7152*G + 0.0722*B
+  brightness <- 0.2126 * r + 0.7152 * g + 0.0722 * b
+  # compare the brightness to a reference value and return the result
+  return(ifelse(brightness > 128,"#000000","#FFFFFF"))
+}
+
+aufhellen <- function(Farbwert, heller = 128) {
+  #' Funktion gibt einen um 0x404040 aufgehellten Farbwert zurück
+  hex <- substr(Farbwert, 2, 7)
+  r <- strtoi(paste0("0x",substr(hex, 1, 2))) + heller
+  g <- strtoi(paste0("0x",substr(hex, 3, 4))) + heller
+  b <- strtoi(paste0("0x",substr(hex, 5, 6))) + heller
+  if (r > 255) r <- 255
+  if (g > 255) g <- 255
+  if (b > 255) b <- 255
+  return(paste0("#",as.hexmode(r),as.hexmode(g),as.hexmode(b)))
+  
+}
+
+# Generiere den Linktext für den Switcher
+link_text <- function(id,id_colour,text) {
+  lt = paste0("<a target='_self' href='https://datawrapper.dwcdn.net/",
+                id,"' style='background:",id_colour,
+                "; padding:1px 3px; border-radius:3px; color:",font_colour(id_colour),
+                "; cursor:pointer;' rel='nofollow noopener noreferrer'>",
+                text,"</a> ")
+  return(lt)
+}
+
+# Gibt einen String mit HTML/CSS zurück. 
+# Nutzt die Kandidierenden-Datei 
+generiere_switcher <- function(switcher_df,selected = 0) {
+  # Ist der Switcher auf 0 - der Stärkste-Kandidaten-Übersichtskarte?
+  if (selected == 0) {
+    text <- link_text(karte_sieger_id,"#F0F0FF","<strong>Sieger nach Stadtteil</strong>")
   } else {
-    # Zeitstempel holen
-    archiviere(neue_orts_df,"daten/ortsteile")
-    ts <- neue_orts_df %>% pull(zeitstempel) %>% last()
-    # Datentabelle übertragen
-    dw_data_to_chart(neue_orts_df,choropleth_id)
-    dw_data_to_chart(neue_orts_df,symbol_id)
-    dw_data_to_chart(neue_orts_df,tabelle_id)
-    # Anmerkungen aktualisieren
-    wahlberechtigt <- neue_orts_df %>% pull(wahlberechtigt) %>% sum()
-    # Prozentsatz ausgezählte Stimmen: abgerundet auf ganze Prozent
-    ausgezählt <- floor(wahlberechtigt / ffm_waehler *100)
-    annotate_str <- generiere_auszählungsbalken(ausgezählt,
-                                                anz = neue_orts_df %>% pull(meldungen_anz) %>% sum(),
-                                                max = neue_orts_df %>% pull(meldungen_max) %>% sum(),
-                                                ts = ts)
-    dw_edit_chart(symbol_id,annotate=annotate_str)
-    dw_edit_chart(choropleth_id,annotate=annotate_str)
-    dw_edit_chart(tabelle_id,annotate=annotate_str)
-    dw_publish_chart(symbol_id)
-    dw_publish_chart(choropleth_id)
-    dw_publish_chart(tabelle_id)
-    return(TRUE)
+    text <- link_text(karte_sieger_id,"#333333","Sieger nach Stadtteil")
+  }
+  for (i in 1:nrow(switcher_df)) {
+    if (i == selected) {
+      switcher_df$html[i] <- link_text(switcher_df$dw_id[i],
+                                       "#F0F0FF",
+                                       paste0("<strong>",switcher_df$Name[i],"</strong>"))
+    } else {
+      switcher_df$html[i] <- link_text(switcher_df$dw_id[i],
+                                       switcher_df$Farbwert[i],
+                                       switcher_df$Name[i])
+    }
+
+  }
+  return(paste0(text,paste0(switcher_df$html,collapse="")))
+} 
+
+# HTML-Code für die Tooltipp-Mouseovers generieren
+# Für alle Karten: Den jeweiligen Kandidaten in den Titel, 
+# orientieren an top (Anzahl der Top-Kandidaten),
+# Bargraph-Code generieren. 
+#
+# Die Mouseovers stehen in den Schlüsseln
+# - visualize[["tooltip"]][["title"]]
+# - visualize[["tooltip"]][["body"]]
+
+karten_titel_html <- function(kandidat_s) {
+  # TBD
+}
+
+karten_body_html <- function(top = 5) {
+  # TBD
+  text <- "<p style='font-weight: 700;'>Ausgezählt: {{ meldungen_anz }} von {{ meldungen_max }} Wahllokalen{{ meldungen_max != meldungen_anz ? ' - Trendergebnis' : ''}}</p>"
+  # Generiere String mit allen Prozent-Variablen
+  prozent_s <- paste0(paste0("prozent",1:top),collapse=",")
+  # Tabellenöffnung
+  text <- text %>% paste0("<table style='width: 100%; border-spacing: 0.1em; border-collapse: collapse;'><thead>")
+  for (i in 1:top) {
+    text <- text %>%  paste0("<tr><th>{{ kand",i,
+                             " }}</th><td style='width: 120px; height=16px;'>",
+                             "<div style='width: {{ ROUND(((prozent",i,
+                             " / MAX(",
+                             prozent_s,
+                             ")) *100),1) }}%; background-color: {{ farbe",i,
+                             " }}; padding-bottom: 5%; padding-top: 5%; border-radius: 5px;'></div></div></td>",
+                              "<td style='padding-left: 3px; text-align: right; font-size: 0.8em;'>{{ FORMAT(prozent",i,
+                              ", '0.0%') }}</td></tr>")
+  }
+  # Tabelle abbinden
+  text <- text %>% paste0(
+    "</thead></table>",
+    "<p>Wahlberechtigte: {{ FORMAT(wahlberechtigt, '0,0') }}, abgegebene Stimmen: {{ FORMAT(stimmen, '0,0') }}, Briefwahl: {{ FORMAT(stimmen_wahlschein, '0,0') }}, ungültig {{ FORMAT(ungueltig, '0,0') }}"
+  )
+} 
+
+
+# Schreibe die Switcher und die Farbtabellen in alle betroffenen Datawrapper-Karten
+vorbereitung_alle_karten <- function() {
+  # Vorbereiten: df mit Kandidierenden und Datawrapper-ids
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  switcher_df <<- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+  text_pre <- "<strong>Wählen Sie eine Karte über die Felder:</strong><br>"
+  text_post <- "<br><br>Klick auf den Stadtteil zeigt, wer dort führt"
+  balken_text <- generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  dw_intro=paste0(text_pre,generiere_switcher(switcher_df,0),text_post)
+  # Farbskala und Mouseover anpassen
+  metadata_chart <- dw_retrieve_chart_metadata(karte_sieger_id)
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Farbwerte für die Kandidierenden
+  # erst mal löschen
+  visualize[["colorscale"]][["map"]] <- NULL
+  visualize[["colorscale"]][["map"]] <- setNames(as.list(kandidaten_df$Farbwert),
+                                                 kandidaten_df$Name)
+  # Karten-Tooltipp anpassen
+  # visualize[["tooltip"]][["title"]] <- karten_title_html(kandidat_s)
+  visualize[["tooltip"]][["body"]] <- karten_body_html(top)
+  
+  dw_edit_chart(karte_sieger_id,intro = dw_intro, annotate = balken_text, visualize = visualize)
+  # dw_data_to_chart()
+  # dw_publish_chart(karte_sieger_id)
+  
+  # Jetzt die n Choroplethkarten für alle Kandidaten
+  # Müssen als Kopie angelegt sein. 
+  for (i in 1:nrow(switcher_df)) {
+    dw_intro <- paste0(text_pre,generiere_switcher(switcher_df,0),text_post)
+    titel_s <- paste0(switcher_df$Vorname[i]," ",
+                     switcher_df$Name[i]," (",
+                     switcher_df$Parteikürzel[i],") - ",
+                     "Ergebnis nach Stadtteil")
+    kandidat_s <- paste0(switcher_df$name[i],
+                         " (",
+                         switcher_df$Parteikürzel[i])
+    metadata_chart <- dw_retrieve_chart_metadata(switcher_df$dw_id[i])
+    visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+    # Zwei Farben: Parteifarbe bei Pos. 1, aufgehellte Parteifarbe
+    # (zu RGB jeweils 0x40 addiert) bei Pos. 0
+    visualize[["colorscale"]][["colors"]][[2]]$color <- switcher_df$Farbwert[i]
+    visualize[["colorscale"]][["colors"]][[2]]$position <- 1
+    visualize[["colorscale"]][["colors"]][[1]]$color <- aufhellen(switcher_df$Farbwert[i])
+    visualize[["colorscale"]][["colors"]][[1]]$position <- 0
+    # Karten-Tooltipp anpassen
+    # visualize[["tooltip"]][["title"]] <- karten_title_html(kandidat_s)
+    visualize[["tooltip"]][["body"]] <- karten_body_html(top)
+    dw_edit_chart(switcher_df$dw_id[i],
+                  title = titel_s,
+                  intro = dw_intro,
+                  visualize = visualize,
+                  annotate = balken_text)
+    # dw_data_to_chart()
+    # dw_publish_chart(switcher_df$dw_id)
+  }
+}
+
+#---- Generiere und pushe die Grafiken für Social Media
+generiere_socialmedia <- function() {
+  # Fairly straightforward. Rufe zwei Datawrapper-Karten über die API auf, 
+  # generiere aus ihnen PNGs, benenne sie mit Zeitstempel, schiebe die auf 
+  # den Bucket und gib einen Text mit Link zurück. 
+  #
+  # Die beiden Karten sind: 
+  # - die Aufmacher-Grafik = top_id
+  # - die nüchterne Balkengrafik mit allen 20 Kandidat:innen S9BbQ
+  #
+  # Die Funktion aktualisiert KEINE Daten, sondern nimmt das, was gerade im 
+  # Datawrapper ist. Das ggf extra mit dw_data_to_chart(meine_df,social1_id,parse_dates =TRUE)
+  # 
+  # Setzt gültigen Zeitstempel ts voraus!
+  
+  # Erste Grafik ist sowieso aktuell und wird nur anders exportiert.
+  # dw_data_to_chart(tag_df,social1_id,parse_dates =TRUE)
+  social1_png <- dw_export_chart(social1_id,type = "png",unit="px",mode="rgb", scale = 1, 
+                  width = 640, height = 640, plain = TRUE, transparent = T)
+  social1_fname <- paste0("png/social1_",format.Date(ts,"%Y-%m-%d--%H%Mh"),".png")
+  # Zweite Grafik muss aktualisiert und vermetadatet werden
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(social2_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  visualize[["color-category"]][["map"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             paste0(kandidaten_df$Name," (",
+                    kandidaten_df$Parteikürzel,")"))
+  dw_edit_chart(chart_id = social2_id, visualize = visualize)
+  dw_data_to_chart(kand_tabelle_df, chart_id = social2_id)
+  social2_png <- dw_export_chart(social2_id,type = "png",unit="px",mode="rgb", scale = 1, 
+                  width = 640, height = 640, plain = TRUE, transparent = T)
+  social2_fname <- paste0("png/social2_",format.Date(ts,"%Y-%m-%d--%H%Mh"),".png")
+  # PNG-Dateien generieren...
+  if (!dir.exists("png")) {dir.create("png")}
+  magick::image_write(social1_png,social1_fname)
+  magick::image_write(social2_png,social2_fname)
+  #...und auf den Bucket schieben. 
+  if (SERVER) {
+    system(paste0('gsutil -h "Cache-Control:no-cache, max_age=0" ',
+                  'cp ',social1_fname,' gs://d.data.gcp.cloud.hr.de/', social1_fname))
+    system(paste0('gsutil -h "Cache-Control:no-cache, max_age=0" ',
+                  'cp ',social2_fname,' gs://d.data.gcp.cloud.hr.de/', social2_fname))
   }
-}
\ No newline at end of file
+  linktext <- paste0("<a href='https://d.data.gcp.cloud.hr.de/",social1_fname,
+                     "'>Download Social-Grafik 1 (5 stärkste)</a><br/>",
+                     "<a href='https://d.data.gcp.cloud.hr.de/",social2_fname,
+                     "'>Download Social-Grafik 2 (alle Stimmen)</a><br/>")
+  return(linktext)
+}
+
+#---- Datawrapper-Grafiken generieren ----
+aktualisiere_top <- function(kand_tabelle_df,top=5) {
+  daten_df <- kand_tabelle_df %>% 
+    arrange(desc(Prozent)) %>% 
+    select(`Kandidat/in`,Stimmenanteil = Prozent) %>% 
+    head(top)
+  # Daten pushen
+  dw_data_to_chart(daten_df,chart_id = top_id)
+  # Intro_Text nicht anpassen. 
+  # Balken reinrendern
+  balken_text <- generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(top_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Der Schlüssel liegt unter custom-colors als Liste
+  visualize[["custom-colors"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             paste0(kandidaten_df$Name," (",
+                    kandidaten_df$Parteikürzel,")"))
+  dw_edit_chart(chart_id = top_id,annotate = balken_text, visualize=visualize)
+  dw <- dw_publish_chart(chart_id = top_id)
+}
+
+aktualisiere_tabelle_alle <- function(kand_tabelle_df) {
+  dw_data_to_chart(kand_tabelle_df, chart_id = tabelle_alle_id)
+  balken_text <- generiere_auszählung_nurtext(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(tabelle_alle_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  visualize[["columns"]][["Prozent"]][["customColorBarBackground"]] <- NULL
+  visualize[["columns"]][["Stimmen"]][["customColorBarBackground"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             kandidaten_df$Nummer)
+  # Irrtümlich waren die Werte auch noch in visualize[["custom-color"]] gespeichert.
+  visualize[["custom-colors"]] <- NULL
+  visualize[["color-category"]] <- NULL
+  dw_edit_chart(chart_id = tabelle_alle_id, annotate = balken_text, visualize = visualize)
+  dw_publish_chart(chart_id = tabelle_alle_id)
+}
+
+aktualisiere_karten <- function(ergänzt_df) {
+  # Als erstes die Übersichtskarte
+  cat("Aktualisiere Karten\n")
+  # Die noch überhaupt nicht gezählten Bezirke ausfiltern
+  ergänzt_f_df <- ergänzt_df %>% filter(meldungen_anz > 0)
+  balken_text = generiere_auszählungsbalken(gezaehlt,stimmbezirke_n,ts)
+  dw_edit_chart(chart_id = karte_sieger_id, annotate = balken_text)
+  dw_data_to_chart(ergänzt_f_df,chart_id = karte_sieger_id)
+  dw <- dw_publish_chart(karte_sieger_id)
+  # Jetzt die Choropleth-Karten für alle Kandidierenden
+  for (i in 1:nrow(switcher_df)) {
+    dw_edit_chart(chart_id=switcher_df$dw_id[i],annotate = balken_text)
+    dw_data_to_chart(ergänzt_f_df, chart_id = switcher_df$dw_id[i])
+    dw <- dw_publish_chart(switcher_df$dw_id[i])
+  }
+  cat("Karten neu publiziert\n")
+}
+
+aktualisiere_hochburgen <- function(hochburgen_df) {
+  # Das ist ziemlich geradeheraus. 
+  dw_data_to_chart(hochburgen_df, chart_id = hochburgen_id)
+  balken_text <- generiere_auszählung_nurtext(gezaehlt,stimmbezirke_n,ts)
+  # Metadaten anpassen: Farbcodes für Parteien
+  metadata_chart <- dw_retrieve_chart_metadata(hochburgen_id)
+  # Save the visualise path
+  visualize <- metadata_chart[["content"]][["metadata"]][["visualize"]]
+  # Die Farben für die Kandidaten werden in dieser Tabelle nur für die Balkengrafiken
+  # in der Spalte "Prozent" benötigt und richten sich nach der Nummer.
+  visualize[["columns"]][["Prozent"]][["customColorBarBackground"]] <- 
+    setNames(as.list(kandidaten_df$Farbwert), 
+             kandidaten_df$Nummer)
+  # Irrtümlich waren die Werte auch noch in visualize[["custom-color"]] gespeichert.
+  visualize[["custom-colors"]] <- NULL
+  dw_edit_chart(chart_id = hochburgen_id, annotate = balken_text, visualize = visualize)
+  dw_publish_chart(chart_id = hochburgen_id)
+  cat("Hochburgen-Grafik neu publiziert\n")
+}
+
+aktualisiere_ergebnistabelle <- function(stadtteildaten_df) {
+  # Nr des Stadtteils, Stadtteil, Wahlbeteiligung (Info), Ergebnis
+  # Wahlbeteiligung und Ergebnis sind jeweils HTML-Text mit den Daten
+  # Unleserlich, aber funktional
+  e_tmp_df <- stadtteildaten_df %>% 
+    select(nr,name,meldungen_anz:ncol(.)) %>% 
+    # Nach Stadtteil sortieren
+    arrange(name) %>% 
+    mutate(sort = row_number())
+  # Nochmal ansetzen, um als erste Zeile das Gesamtergebnis einzusetzen
+  ergebnistabelle_df <- e_tmp_df %>% summarize(nr = 0, sort = 0,
+                               name = "GESAMTERGEBNIS",
+                               across(meldungen_anz:ncol(.), ~sum(.,na.r =FALSE))) %>% 
+    bind_rows(e_tmp_df) %>% 
+    # Mit den Kandidaten-Namen anreichern
+    # Ins Langformat umformen, Nummer ist die Kandidatennummer
+    pivot_longer(cols=starts_with("D"),names_to = "Nummer", values_to = "Stimmen") %>% 
+    mutate(Prozent = if_else(Stimmen == 0,0,Stimmen / gueltig * 100)) %>% 
+    # D1... in Integer umwandeln
+    mutate(Nummer = as.numeric(str_extract(Nummer,"[0-9]+"))) %>% 
+    left_join(kandidaten_df %>% select (Nummer, Vorname, Name, Parteikürzel),
+              by = "Nummer") %>% 
+    mutate(`Kandidat/in` = paste0(Name," (",Parteikürzel,")")) %>% 
+    # Kandidaten-Tabelle wieder zurückpivotieren
+    select(-Vorname, -Name, -Parteikürzel, -Nummer) %>%
+    # Nach Stadtteil gruppieren
+    group_by(sort,nr,name) %>% 
+    # Zusätzliche Variable: Stadtteil gezählt oder TREND?
+    mutate(trend = meldungen_anz < meldungen_max) %>% 
+    # Big bad summary - jeweils Daten aus den Spalten generieren
+    summarize(Stadtteil = paste0("<strong>",first(name),
+                                "</strong><br><br>",
+                                # TREND oder ERGEBNIS?
+                                if_else(first(trend),
+                                        paste0("TREND: ",
+                                               first(meldungen_anz)," von ",
+                                               first(meldungen_max)," Stimmbezirken ausgezählt"),
+                                        #...oder alles ausgezählt?
+                                        paste0("Alle ",
+                                               first(meldungen_max),
+                                               " Stimmbezirke ausgezählt"))),
+              Wahlbeteiligung = paste0("Wahlberechtigt: ",
+                                       # Wenn noch nicht ausgezählt, leer lassen
+                                       if_else(first(trend),"",
+                                               first(wahlberechtigt) %>% 
+                                         format(big.mark = ".", decimal.mark =",")),
+                                       "<br>",
+                                       "abg. Stimmen: ",first(stimmen) %>% format(big.mark = ".", decimal.mark =","),
+                                       " (",
+                                       # Nicht gezählte Bezirke haben 0 Wahlberechtigte
+                                       if_else(first(trend),"--", 
+                                                (first(stimmen)/first(wahlberechtigt) *100) %>% 
+                                         round(digits=1) %>% format(decimal.mark=",", nsmall = 1)),
+                                       "%)<br>",
+                                       "davon Briefwahl: ",
+                                       first(stimmen_wahlschein) %>% format(big.mark = ".", decimal.mark =","),
+                                       " (",
+                                       # Falls noch nicht alles ausgezählt, keinen Prozentwert angeben
+                                       if_else(first(trend),
+                                               "--",
+                                       (first(stimmen_wahlschein) / first(stimmen) * 100) %>% 
+                                         round(digits = 1) %>% format(decimal.mark=",", nsmall = 1)),
+                                       "%)<br>",
+                                       "<br>Ungültig: ",
+                                       first(ungueltig) %>% format(big.mark = ".", decimal.mark =","),
+                                       "<br>Gültige Stimmen: ",
+                                       first(gueltig) %>% format(big.mark = ".", decimal.mark =",")),
+              # Hier geschachtelte paste0-Aufrufe: 
+              # Der innere baut einen Vektor mit allen Kandidaten plus Ergebnissen
+              # Der äußere fügt diesen Vektor zu einem String zusammen (getrennt durch <br>)
+              Ergebnis = paste0(paste0("<strong>",`Kandidat/in`,"</strong>: ",
+                                       Stimmen %>% format(big.mark=".",decimal.mark=","),
+                                       " (",
+                                       Prozent %>% round(1) %>% format(decimal.mark=",", nsmall=1),"%)"),
+                collapse="<br>")
+              ) %>% 
+    ungroup() %>% 
+    arrange(sort) %>% 
+    select(-name,-sort)
+  dw_data_to_chart(ergebnistabelle_df %>% select(-nr), chart_id = tabelle_stadtteile_id)
+    # Trendergebnis? Schreibe "Trend" oder "Endergebnis" in den Titel
+  gezählt <- e_tmp_df %>% pull(meldungen_anz) %>% sum(.)
+  stimmbezirke_n <- e_tmp_df %>% pull(meldungen_max) %>% sum(.)
+  ts <- stadtteildaten_df %>% pull(zeitstempel) %>% first()
+  titel_s <- paste0(ifelse(gezählt < stimmbezirke_n,"TREND: ",""),
+                    "Ergebnisse nach Stadtteil")
+  dw_edit_chart(chart_id = tabelle_stadtteile_id,title = titel_s,
+                annotate=generiere_auszählung_nurtext(gezählt,stimmbezirke_n,ts))
+  dw_publish_chart(tabelle_stadtteile_id)
+  cat("Ergebnistabelle nach Stadtteil publiziert\n")
+  return(ergebnistabelle_df)
+}
diff --git a/R/generiere_testdaten.R b/R/generiere_testdaten.R
deleted file mode 100644
index 3eed958..0000000
--- a/R/generiere_testdaten.R
+++ /dev/null
@@ -1,140 +0,0 @@
-#' generiere_testdaten.R
-#' 
-#' Macht aus den Templates für Ortsteil- und Wahllokal-Ergebnisse
-#' jeweils eine Serie von fiktiven Livedaten, um das Befüllen der
-#' Grafiken testen zu können. 
-#' 
-
-require(tidyr)
-require(dplyr)
-require(readr)
-
-# Alles weg, was noch im Speicher rumliegt
-rm(list=ls())
-
-source("R/lies_aktuellen_stand.R")
-
-#---- Funktion zum Testdaten-Löschen ----
-lösche_testdaten <- function(){
-  q <- tolower(readline(prompt = "Testdaten löschen - sicher? "))
-  if (!(q %in% c("j","y","ja"))) { return() }
-  # Datenarchiv weg
-  if (file.exists("daten/fom_df.rds")){
-    file.remove("daten/fom_df.rds")
-  }
-  # Testdaten
-  testdaten_files <- list.files("testdaten", full.names=TRUE) 
-  for (f in testdaten_files) {
-    # Grausam, I know. 
-    if (str_detect(f,"ortsteile[0-9]+\\.csv") |
-        str_detect(f,"wahllokale[0-9]+\\.csv")) {
-      file.remove(f)
-    }
-  }
-}
-
-# Vorlagen laden
-vorlage_wahllokale_df <- read_delim("testdaten/Open-Data-06412000-Buergerentscheid-zur-Abwahl-des-Oberbuergermeisters-der-Stadt-Frankfurt-am-Main_-Herrn-Peter-Feldmann-Stimmbezirk.csv", 
-                                   delim = ";", escape_double = FALSE, 
-                                   locale = locale(date_names = "de", 
-                                                   decimal_mark = ",", 
-                                                   grouping_mark = "."), 
-                                   trim_ws = TRUE)
-
-wahllokale_max <- sum(vorlage_wahllokale_df$`max-schnellmeldungen`)
-
-# Konstanten für die Simulation - werden jeweils um bis zu +/-25% variiert
-c_wahlberechtigt = 510000 / wahllokale_max # Gleich große Wahlbezirke
-c_wahlbeteiligung = 0.3 # Wahlbeteiligung um 30%, wird im Lauf der "Wahl" erhöht (kleinere WL sind schneller ausgezählt)
-c_wahlschein = 0.25 # 25% Briefwähler
-c_nv = 0.05 # 0,5% wählen "spontan" und sind nicht verzeichnet (nv) im Wählerverzeichnis
-c_ungültig = 0.01 # 1% Ungültige
-c_nein = 0.15 # unter den gültigen: 85% Ja-Stimmen (Varianz also von ca 81-89%)
-
-variiere <- function(x = 1) {
-  # Variiert den übergebenen Wert zufällig um -25% bis +25%:
-  # Zufallswerte zwischen 0,75 und 1,25 erstellen und multiplizieren
-  #
-  # Die Length-Funktion ist wichtig - sonst erstellt runif() nur einen 
-  # Zufallswert, mit dem alle Werte von x multipliziert werden. 
-  return(floor(x * (runif(length(x),0.75,1.25))))
-}
-
-
-
-i = 1
-# Schleife für die Wahllokale: Solange noch nicht alle "ausgezählt" sind...
-while(sum(vorlage_wahllokale_df$`anz-schnellmeldungen`) < wahllokale_max) {
-  # ...splitte das df in die gemeldeten (meldungen_anz == 1) und nicht gemeldeten Zeilen
-  tmp_gemeldet_df <- vorlage_wahllokale_df %>% filter(`anz-schnellmeldungen` == 1)
-  # Die Variable rand wird als Anteil von 20 Meldungen an debn noch offenen Wahllokale berechnet
-  rand <- 20 / (nrow(vorlage_wahllokale_df) - nrow(tmp_gemeldet_df)) 
-  tmp_sample_df <- vorlage_wahllokale_df %>% 
-    filter(`anz-schnellmeldungen` == 0) %>% 
-    # Bei den noch nicht ausgefüllten "Meldungen" mit einer Wahrscheinlichkeit
-    # von rand in die Gruppe sortieren, die neu "gemeldet" wird
-    mutate(sample = (runif(nrow(.)) < rand))
-  tmp_offen_df <- tmp_sample_df %>% 
-    filter(sample == 0) %>%
-    # sample-Variable wieder raus
-    select(-sample)
-  tmp_neu_df <- tmp_sample_df %>% 
-    filter(sample == 1) %>% 
-  select(-sample) %>% 
-    # Alle als gemeldet markieren
-    mutate(`anz-schnellmeldungen` = 1) %>%
-    # Und jetzt der Reihe nach (weil die Werte z.T. aufeinander aufbauen)
-    # Wahlberechtigte
-    mutate(A = floor(c_wahlberechtigt * runif(nrow(.),0.75,1.25))) %>% 
-    # Wahlschein
-    mutate(A2 = floor(A * c_wahlschein * runif(nrow(.),0.75,1.25))) %>% 
-    # Nicht verzeichnet
-    mutate(A3 = floor(A * c_nv * runif(nrow(.),0.75,1.25))) %>% 
-    # Regulär Wahlberechtigte (ohne Wahlschein oder nv)
-    mutate(A1 = A - A2 - A3) %>% 
-    # Abgegebene Stimmen
-    mutate(B = floor(A * c_wahlbeteiligung * runif(nrow(.),0.75,1.25))) %>% 
-    # davon mit Wahlschein
-    mutate(B1 = floor(B * c_wahlschein * runif(nrow(.),0.75,1.25))) %>% 
-    # davon ungültig
-    mutate(C = floor(B * c_ungültig * runif(nrow(.),0.75,1.25))) %>% 
-    # gültig
-    mutate(D = B - C) %>% 
-    # davon ja
-    mutate(D2 = floor(D * c_nein *runif(nrow(.),0.75,1.25))) %>% 
-    mutate(D1 = D - D2)
-  # Kurze Statusmeldung
-  cat("Neu gemeldet:",nrow(tmp_neu_df),"noch offen:",nrow(tmp_offen_df))
-  # Phew. Aktualisierte Testdatei zusammenführen und anlegen. 
-  vorlage_wahllokale_df <- tmp_gemeldet_df %>% 
-    bind_rows(tmp_neu_df) %>% 
-    bind_rows(tmp_offen_df) %>%
-    # wieder in die Reihenfolge nach Wahllokal-Nummer
-    arrange(`gebiet-nr`)
-  
-  write_csv2(vorlage_wahllokale_df,
-             paste0("testdaten/wahllokale",
-                    sprintf("%02i",i),
-                    ".csv"),
-             escape = "backslash")
-  # Generiere die passende Ortsteil-Meldung
-  # Geht aus irgeneindem Grund nicht, aber wir brauchens ja auch nicht. 
-  # ortsteile_df <- zuordnung_wahllokale_df %>% 
-  #   select(`gebiet-name` = name,ortsteilnr) %>% 
-  #   left_join(vorlage_wahllokale_df,by="gebiet-name") %>% 
-  #   # Zuordnung der Wahllokale
-  #   group_by(ortsteilnr) %>% 
-  #   # Das crasht - WTF???
-  #   summarize(across(7:18, ~ sum(.,na.rm = T))) %>%
-  #   left_join(stadtteile_df %>% select(ortsteilnr = nr,name),by="ortsteilnr") %>% 
-  #   rename(`gebiet-nr` = ortsteilnr) %>% 
-  #   mutate(`gebiet-name` = name) %>% 
-  #   select(-ortsteilnr) 
-    
-  i <- i+1
-  # Wahlbeteiligung schrittweise ein wenig anheben - um zu simulieren, 
-  # dass "kleinere" Wahllokale zuerst ausgezählt werden
-  c_wahlbeteiligung <- c_wahlbeteiligung + 0.002
-}
-
-
diff --git a/R/its_alive.R b/R/its_alive.R
new file mode 100644
index 0000000..750c52b
--- /dev/null
+++ b/R/its_alive.R
@@ -0,0 +1,41 @@
+# its_alive.R - Watchdog-Skript für update_all.R
+#
+# Wird per CRON-Job aufgerufen: Wenn das letzte Update der Log-Datei "wahl.log"
+# länger als x Minuten zurückliegt, löst das Skript einen Alarm über Teams aus.
+
+# Mehr als die Datumsfunktionen brauchen wir nicht
+library(lubridate)
+library(this.path)
+
+# Das Projektverzeichnis "obwahl" als Arbeitsverzeichnis wählen
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Teams-Funktionen einbinden
+source("R/messaging.R")
+
+# Maximales Alter in Sekunden?
+max_alter = 120
+
+# Startzeit festhalten
+ts = now()
+
+# Gibt es überhaupt eine Logdatei? 
+
+if (file.exists("obwahl.log")) {
+  metadaten <- file.info("obwahl.log")
+  # Berechne Alter der Logdatei in Sekunden
+  alter = as.integer(difftime(ts,metadaten$mtime,units="secs"))
+  if (alter > max_alter)
+  {
+    cat("WATCHDOG its_alive.R: obwahl.log seit ",alter," Sekunden unverändert")
+    cat("Benenne obwahl.log um in obwahl_crash.log")
+    file.rename("obwahl.log","obwahl_crash.log")
+    teams_error("PROGRAMM STEHEN GEBLIEBEN? obwahl.log ist seit ",alter," Sekunden unverändert")
+  }
+} else {
+  # Tue nichts.
+  cat("its_alive.R: obwahl.log im Arbeitsverzeichnis",getwd(),"nicht gefunden")
+}
diff --git a/R/lies_aktuellen_stand.R b/R/lies_aktuellen_stand.R
index 70b7bca..2ffc5fc 100644
--- a/R/lies_aktuellen_stand.R
+++ b/R/lies_aktuellen_stand.R
@@ -1,56 +1,40 @@
+# Library-Aufrufe kann man sich eigentlich sparen, aber...
 library(readr)
 library(lubridate)
 library(tidyr)
 library(stringr)
 library(dplyr)
+library(openxlsx)
+library(curl)
 
 # lies_aktuellen_stand.R
 #
 # Enthält die Funktion zum Lesen der aktuellen Daten. 
 
-#---- Vorbereitung ----
-# Statische Daten einlesen
-# (das später durch ein schnelleres .rda ersetzen)
+#---- Hilfsfunktionen ----
 
-# Enthält drei Datensätze: 
-# - opendata_wahllokale_df mit der Liste aller Stimmwahlbezirke nach Wahllokal
-# - statteile_df: Stadtteil mit Namen und laufender Nummer, Geokoordinaten, Ergebnissen 2018
-# - zuordnung_stimmbezirke: Stimmbezirk-Nummer (als int und String) -> Stadtteilnr.
- 
-load ("index/index.rda")
-
-# Konfiguration auslesen und in Variablen schreiben
-config_df <- read_csv("index/config.csv")
-for (i in c(1:nrow(config_df))) {
-  # Erzeuge neue Variablen mit den Namen und Werten aus der CSV
-  assign(config_df$name[i],
-         # Kann man den Wert auch als Zahl lesen?
-         # Fieses Regex sucht nach reiner Zahl oder Kommawerten.
-         # Keine Exponentialschreibweise!
-         ifelse(grepl("^[0-9]*\\.*[0-9]+$",config_df$value[i]),
-                # Ist eine Zahl - wandle um
-                as.numeric(config_df$value[i]),
-                # Keine Zahl - behalte den String
-                config_df$value[i]))
-}
-
-
-#---- Daten ins Archiv schreiben oder daraus lesen
 archiviere <- function(df,a_directory = "daten/stimmbezirke") {
+  #' Schreibt das Dataframe mit den zuletzt geholten Stimmbezirks-Daten
+  #' als Sicherungskopie in das angegebene Verzeichnis
+  #' 
   if (!dir.exists(a_directory)) {
     dir.create(a_directory)
   }
-  write_csv(df,
-            paste0(a_directory,"/",
-                   # Zeitstempel isolieren und alle Doppelpunkte
-                   # durch Bindestriche ersetzen
-                   str_replace_all(df %>% pull(zeitstempel) %>% last(),
-                               "\\:","_"),
-                   ".csv"))
+  fname = paste0(a_directory,"/",
+                # Zeitstempel isolieren und alle Doppelpunkte
+                # durch Bindestriche ersetzen
+                str_replace_all(df %>% pull(zeitstempel) %>% last(),
+                                "\\:","_"),
+                ".csv")
+  write_csv(df,fname)
+  cat(as.character(now())," - Daten archiviert als ",paste0(a_directory,fname))
 }
 
 hole_letztes_df <- function(a_directory = "daten/stimmbezirke") {
+  #' Schaut im angegebenen Verzeichnis nach der zuletzt angelegten Datei
+  #' und holt die Daten zurück in ein df
   if (!dir.exists(a_directory)) return(tibble())
+  # Die zuletzt geschriebene Datei finden und einlesen
   neuester_file <- list.files(a_directory, full.names=TRUE) %>% 
     file.info() %>% 
     # Legt eine Spalte namens path an
@@ -67,11 +51,56 @@ hole_letztes_df <- function(a_directory = "daten/stimmbezirke") {
   }
 }
 
+# Sind die beiden df abgesehen vom Zeitstempel identisch?
+# Funktion vergleicht die numerischen Werte - Spalte für Spalte.
+vergleiche_stand <- function(alt_df, neu_df) {
+  #' Spaltenweiser Vergleich: Haben die Daten sich verändert? 
+  #' (Anders gefragt: ist die Summe aller numerischen Spalten gleich?)
+  #' Wurde für die Feldmann-Wahl benötigt; bei OB-Wahlen eigentlich überflüssig
+  neu_sum_df <- alt_df %>% summarize_if(is.numeric,sum,na.rm=T)
+  alt_sum_df <- neu_df %>% summarize_if(is.numeric,sum,na.rm=T)
+  # Unterschiedliche Spaltenzahlen? Dann können sie keine von Finns Männern sein.
+  if (length(neu_sum_df) != length(alt_sum_df)) return(FALSE)
+  # Differenzen? Dann können sie keine von Finns Männern sein. 
+  return(sum(abs(neu_sum_df - alt_sum_df))==0)
+}
+
+
+#--- CURL-Polling (experimentell!)
+#
+# Gibt das Änderungsdatum der Daten-Datei auf dem Wahlamtsserver zurück - 
+# wenn es sich verändert hat, ist das das Signal, neue Daten zu holen. 
+check_for_timestamp <- function(my_url) {
+  # Erst checken: Wirklich eine Internet-Verbindung? 
+  # Sonst behandle als lokale Datei. 
+  if(str_detect(my_url,"^http")) {
+    tmp <- curlGetHeaders(my_url, redirect = T, verify = F)
+    # Redirect 
+    # if (stringr::str_detect(tmp[1]," 404")) {
+      # Library(curl)
+      h <- new_handle()
+      # Das funktioniert, holt aber alle Daten -> hohe Last
+      t <- curl_fetch_memory(my_url,handle=h)$modified %>% 
+        as_datetime()
+    # } else {
+    #   t <- tmp[stringr::str_detect(tmp,"last-modified")] %>% 
+    #     stringr::str_replace("last-modified: ","") %>% 
+    #     parse_date_time("%a, %d %m %Y %H:%M:%S",tz = "CET") + hours(1)
+    # }
+  } else { # lokale Datei
+    t = file.info(my_url)$mtime %>%  as_datetime
+  }
+  return(t)
+}
+
 
 #---- Lese-Funktionen ----
-lies_gebiet <- function(stand_url = stimmbezirke_url) {
-  ts <- now()
-  # Versuch Daten zu lesen - und gib ggf. Warnung oder Fehler zurück
+
+# Das hier ist die Haupt-Lese-Funktion
+lies_stimmbezirke <- function(stand_url = stimmbezirke_url) {
+  #' Versuche, Daten vom Wahlamtsserver zu lesen - und gib ggf. Warnung oder Fehler zurück
+  #' Schreibt eine Meldung ins Logfile - zugleich ein Lesezeichen
+  cat(as.character(now())," - Neue Daten lesen\n") # Touch logfile
   check = tryCatch(
     { stand_df <- read_delim(stand_url, 
                          delim = ";", escape_double = FALSE, 
@@ -80,7 +109,10 @@ lies_gebiet <- function(stand_url = stimmbezirke_url) {
                                          grouping_mark = "."), 
                          trim_ws = TRUE) %>% 
       # Spalten umbenennen, Zeitstempel-Spalte einfügen
-                    mutate(zeitstempel=ts) %>% 
+                    mutate(zeitstempel=ts) %>%
+      # Sonderregel: wir haben einen Zeitstempel, die "datum"-Spalte macht
+      # Probleme, weil: starts_with("D"). 
+                    select(-datum) %>% 
                     select(zeitstempel,
                            nr = `gebiet-nr`,
                            name = `gebiet-name`,
@@ -96,57 +128,243 @@ lies_gebiet <- function(stand_url = stimmbezirke_url) {
                            stimmen_wahlschein = B1, 
                            ungueltig = C,
                            gueltig = D,
-                           ja = D1,
-                           nein = D2)
+                           # neu: alle Zeilen mit Stimmen (D1..Dn)
+                           starts_with("D"))
+      
       },
-    warning = function(w) {teams_warning(w,title="Feldmann: Datenakquise")},
-    error = function(e) {teams_warning(e,title="Feldmann: Datenakquise")})
-  # Spalten umbenennen, 
+    warning = function(w) {teams_warning(w,title="OB-Wahl: Datenakquise")},
+    error = function(e) {teams_warning(e,title="OB-Wahl: Datenakquise")})
   return(stand_df)
 }
 
-
-# Sind die beiden df abgesehen vom Zeitstempel identisch?
-# Funktion vergleicht die numerischen Werte - Spalte für Spalte.
-vergleiche_stand <- function(alt_df, neu_df) {
-  neu_sum_df <- alt_df %>% summarize_if(is.numeric,sum,na.rm=T)
-  alt_sum_df <- neu_df %>% summarize_if(is.numeric,sum,na.rm=T)
-  # Unterschiedliche Spaltenzahlen? Dann können sie keine von Finns Männern sein.
-  if (length(neu_sum_df) != length(alt_sum_df)) return(FALSE)
-  # Differenzen? Dann können sie keine von Finns Männern sein. 
-  return(sum(abs(neu_sum_df - alt_sum_df))==0)
-}
-
-#' Liest Stimmbezirke, gibt nach Ortsteil aggregierte Daten zurück
-#' (hier: kein Sicherheitscheck)
-aggregiere_stadtteile <- function(stimmbezirke_df) {
-  ortsteile_df <- stimmbezirke_df %>% 
-    left_join(zuordnung_stimmbezirke_df,by=c("nr","name")) %>% 
-    group_by(ortsteilnr) %>% 
+aggregiere_stadtteildaten <- function(stimmbezirksdaten_df = stimmbezirksdaten_df) {
+  #' Liest Stimmbezirke, gibt nach Ortsteil aggregierte Daten zurück
+  #' (hier: kein Sicherheitscheck)
+  stadtteildaten_df <- stimmbezirksdaten_df %>% 
+    left_join(stimmbezirke_df %>% select(nr,ortsteilnr,stadtteil),
+              by="nr") %>% 
+    group_by(ortsteilnr)   %>% 
+    # Fasse alle Spalten von meldungen_anz bis Ende der Tabelle zusammen - 
+    # mit der sum()-Funktion (NA wird wie null behandelt)
     summarize(zeitstempel = last(zeitstempel),
-              across(meldungen_anz:nein, ~ sum(.,na.rm = T))) %>%
-    rename(nr = ortsteilnr) %>% 
-    # Stadtteilnamen, 2018er Ergebnisse, Geokoordinaten dazuholen
+              nr = first(ortsteilnr), 
+              meldungen_anz = sum(meldungen_anz,na.rm =T),
+              meldungen_max = sum(meldungen_max,na.rm = T),
+              wahlberechtigt = sum(wahlberechtigt, na.rm = T),
+              waehler_regulaer = sum(waehler_regulaer, na.rm = T),
+              waehler_wahlschein = sum(waehler_wahlschein, na.rm = T),
+              waehler_nv = sum(waehler_nv, na.rm = T),
+              stimmen = sum(stimmen, na.rm = T),
+              stimmen_wahlschein = sum(stimmen_wahlschein, na.rm = T),
+              ungueltig = sum(ungueltig, na.rm = T),
+              gueltig = sum(gueltig, na.rm = T),
+              across(starts_with("D"), ~ sum(.,na.rm = T))) %>%
+    mutate(across(where(is.numeric), ~ifelse(is.na(.), 0, .))) %>% 
+    # Stadtteilnamen, Geokoordinaten dazuholen
     left_join(stadtteile_df, by="nr") %>% 
-    # Nach Ortsteil sortieren
-    arrange(nr) %>% 
     # Wichtige Daten für bessere Lesbarkeit nach vorn
     relocate(zeitstempel,nr,name,lon,lat)
     
   # Sicherheitscheck: Warnen, wenn nicht alle Ortsteile zugeordnet
-  if (nrow(ortsteile_df) != nrow(stadtteile_df)) teams_warnung("Nicht alle Ortsteile zugeordnet")
-  if (nrow(zuordnung_stimmbezirke_df) != length(unique(stimmbezirke_df$nr))) teams_warnung("Nicht alle Stimmbezirke zugeordnet")
-  return(ortsteile_df)
+  if (nrow(stadtteildaten_df) != nrow(stadtteile_df)) teams_warnung("Nicht alle Stadtteile zugeordnet")
+  if (nrow(stimmbezirke_df) != length(unique(stimmbezirke_df$nr))) teams_warnung("Nicht alle Stimmbezirke zugeordnet")
+  cat("Stadtteildaten aggregiert.\n")
+  return(stadtteildaten_df)
+}
+
+#---- Die Tabellen für die DW-Grafiken ergänzen ----
+berechne_ergänzt <- function(stadtteildaten_df = stadtteildaten_df, top = top) {
+  #' Ergänze die jeweils fünf führenden Kandidaten, ihre Prozentanteile ihre 
+  #' Stimmen und ihre Farbwerte. Und benenne die D1...Dn-Spalten nach 
+  #' Kandidat/in/Partei in der Form "Müller (ABC)" - gleichzeitig der Index für DW
+  #' 
+  #' Gibt eine megalange Tabelle für Datawrapper zurück. 
+
+  # Zuerst ein temporäres Langformat, bei dem jede/d Kand in jedem Stadtteil eine Zeile 
+  # hat. Das brauchen wir 2x, um es wieder zusammenführen zu können. 
+  tmp_long_df <- stadtteildaten_df %>%
+    pivot_longer(cols = starts_with("D"), names_to = "kand_nr", values_to = "kand_stimmen") %>% 
+    mutate(kand_nr = as.integer(str_extract(kand_nr,"[0-9]+"))) %>% 
+    # Ortsteil- bzw. Stimmbezirks-Gruppen, um dort nach Stimmen zu sortieren
+    group_by(nr,name) %>% 
+    arrange(desc(kand_stimmen)) %>% 
+    mutate(Platz = row_number()) %>%
+    left_join(kandidaten_df %>% select(kand_nr = Nummer, 
+                                       kand_name = Name,
+                                       kand_partei = Parteikürzel,
+                                       farbe= Farbwert), by="kand_nr") %>% 
+    mutate(kand = paste0(kand_name," (",kand_partei,")")) %>% 
+    mutate(prozent = if_else(gueltig != 0,kand_stimmen / gueltig * 100, 0)) 
+  ergänzt_df <- tmp_long_df %>% 
+    # Ist noch nach Stadtteil (name, nr) sortiert
+    arrange(kand_nr) %>% 
+    # Alles weg, was verhindert, was individuell auf den Kand ist - außer
+    #  kand und Prozentwert
+    select(-kand_stimmen, -kand_nr, -Platz, -kand_name, -kand_partei, -farbe) %>% 
+    # Kandidatennamen in die Spalten zurückverteilen
+    pivot_wider(names_from = kand, values_from = prozent) %>% 
+    ungroup() %>% 
+    # und die zweite Hälfte dazu: 
+    left_join(
+      tst <- tmp_long_df %>% 
+                # Brauchen nur die Kand-Ergebnisse - und den (Stadtteil-)name
+                select(name, Platz, kand=kand_name,prozent,farbe) %>% 
+                # Nur die ersten (top) Plätze
+                filter(Platz <= (top)) %>% 
+    #The Big Pivot: Breite die ersten (top) aus. 
+    pivot_wider(names_from = Platz,
+                values_from = c(kand,prozent,farbe),
+                names_glue = "{.value}{Platz}") %>% 
+        ungroup() %>% 
+        select(-nr),   
+    by="name") %>% 
+    # Sonderregelung: Wenn keine Stimmen, weise kand1-(top) NA zu (wg. Stadtteilen ohne Daten)
+    mutate(across(starts_with("kand"), ~ if_else(meldungen_anz > 0, .,""))) %>% 
+    mutate(across(starts_with("farbe"),  ~ if_else(meldungen_anz > 0, .,"#aaaaaa")))
+  cat("Ergänzte Stadtteildaten berechnet.\n")
+  return(ergänzt_df)
 }
 
-lies_stadtteil_direkt <- function(stand_url = ortsteile_url) {
-  neu_df <- lies_gebiet(stand_url)  %>%
-    # nr bei Ortsteil-Daten leer/ignorieren
-    select(!nr) %>% 
-    # Stadtteilnr., Geodaten und Feldmann-2018-Daten reinholen:
-    left_join(stadtteile_df, by=c("name")) %>% 
-    mutate(trend = (meldungen_anz < meldungen_max),
-           quorum_erreicht = (ja >= (wahlberechtigt * 0.3)))
-  return(neu_df)
+berechne_kand_tabelle <- function(stimmbezirksdaten_df = stimmbezirksdaten_df) {
+  # Nimmt die Stadtteildaten - oder auch die Wahllokale - und berechne daraus die
+  # Nummer, Kandidat(in) in der Form "Müller (XYZ)", Parteikürzel, Stimmen, Prozent
+  kand_tabelle_df <- stimmbezirksdaten_df %>% 
+    summarize(gueltig = sum(gueltig, na.rm = T),
+      across(starts_with("D"), ~ sum(.,na.rm=TRUE))) %>% 
+    pivot_longer(cols=starts_with("D"),names_to = "nr", values_to = "Stimmen") %>% 
+    # Namen in Nr. umwandeln
+    mutate(Nummer = as.integer(str_extract(nr,"[0-9]+"))) %>% 
+    left_join(kandidaten_df %>%  select(Nummer, Name, Parteikürzel, Farbwert), 
+              by="Nummer") %>% 
+    mutate(name = paste0(Name," (",Parteikürzel,")")) %>% 
+    mutate(Prozent = Stimmen / gueltig * 100) %>% 
+    select(Nummer, `Kandidat/in` = name, Parteikürzel, Stimmen, Prozent)
+  cat("Gesamttabelle alle Kandidaten berechnet.\n")
+  return(kand_tabelle_df)
 }
 
+berechne_hochburgen <- function(stadtteildaten_df = stadtteildaten_df) {
+  # Tabelle mit den drei stärksten und drei schwächsten Stadtteilen
+  # im Vergleich zu GESAMT
+  hochburgen_df <- stadtteildaten_df %>% 
+    select(name,gueltig,D1:ncol(.)) %>% 
+    # Eine Zeile für Frankfurt dazu
+    bind_rows(stadtteildaten_df %>% 
+                select(name,gueltig,D1:ncol(.)) %>% 
+                summarize(gueltig = sum(gueltig, na.rm = T),
+                          across(starts_with("D"), ~ sum(.,na.rm=TRUE))) %>% 
+                mutate(name = "GESAMT")) %>% 
+    # Ins Langformat umformen, Nummer ist die Kandidatennummer
+    pivot_longer(cols=starts_with("D"),names_to = "Nummer", values_to = "Stimmen") %>% 
+    mutate(Prozent = if_else(Stimmen == 0,0,Stimmen / gueltig * 100)) %>% 
+    # D1... in Integer umwandeln
+    mutate(Nummer = as.numeric(str_extract(Nummer,"[0-9]+"))) %>% 
+    mutate(ist_gesamt = (name == "GESAMT")) %>% 
+    # Wichtig: "Currently, group_by() internally orders in ascending order."
+    group_by(Nummer,ist_gesamt) %>% 
+    arrange(desc(Prozent)) %>% 
+    mutate(Platz = row_number()) %>% 
+    filter(Platz <= 3 | Platz > (nrow(stadtteile_df) - 3)) %>% 
+    mutate(Platz = if_else(ist_gesamt, as.integer(0),row_number())) %>% 
+    ungroup(ist_gesamt) %>% 
+    arrange(Platz) %>% 
+    # Namen dazuholen
+    left_join(kandidaten_df %>% select (Nummer, Vorname, Name, Parteikürzel),
+              by = "Nummer") %>% 
+    mutate(`Kandidat/in` = if_else(ist_gesamt,
+                                   paste0(Vorname," ",Name," (",Parteikürzel,")"),
+                                   "")) %>% 
+    # sortieren
+    mutate(sort = 7* Nummer + Platz) %>% 
+    ungroup() %>% 
+    arrange(sort) %>% 
+    select(Nummer, `Kandidat/in`, Stadtteil = name, Prozent)
+  cat("Hochburgen nach Kandidaten berechnet.\n")
+  return(hochburgen_df)    
+}
+#---- Haupt-Funktion ----
+#
+# 
+hole_wahldaten <- function() {
+      # Hole und archiviere die Stimmbezirks-Daten; 
+      # erzeuge ein df mit den Stimmen nach Stadtteil. 
+      stimmbezirksdaten_df <<- lies_stimmbezirke(stimmbezirke_url)
+      gezaehlt <<- stimmbezirksdaten_df %>% pull(meldungen_anz) %>% sum(.)
+      archiviere(stimmbezirksdaten_df,paste0("daten/",wahl_name,"/"))
+      kand_tabelle_df <<- berechne_kand_tabelle(stimmbezirksdaten_df)
+      stadtteildaten_df <<- aggregiere_stadtteildaten(stimmbezirksdaten_df)
+      ergänzt_df <<- berechne_ergänzt(stadtteildaten_df,top)
+      hochburgen_df <<- berechne_hochburgen(stadtteildaten_df)
+      # Neue Daten: Die Stimmdaten-zeilen, die ausgezählt sind. 
+      neue_daten <<- stimmbezirksdaten_df %>%
+        # Filtere auf alle gezählten Stimmbezirke
+        filter(meldungen_anz == 1) %>% 
+        # Ziehe die ab, die schon in den alten Daten gezählt waren
+        anti_join(alte_daten %>% 
+                    filter(meldungen_anz == 1) %>% 
+                    select(name),
+                  by="name")
+      alte_daten <<- stimmbezirksdaten_df
+  # Aktualisiere die Karten (bzw. warne, wenn keine neuen da.)
+  if (nrow(neue_daten)==0) {
+    # teams_warning("Neue Stimmbezirk-Daten, aber keine neuen Ortsdaten?",title=wahl_name)
+    cat("Frischer Zeitstempel, aber keine neu ausgezählten Stimmbezirke")
+  } 
+  
+  check = tryCatch(
+    { # Die Ergebnistabellen mit allen Stimmen/Top-Kandidaten und Ergebnistabelle.
+      aktualisiere_top(kand_tabelle_df,top)
+      aktualisiere_tabelle_alle(kand_tabelle_df)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Grafiken A"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Grafiken A"))}
+  )  
+  ergebnistabelle_df <<- aktualisiere_ergebnistabelle(stadtteildaten_df)
+  # Jetzt erst mal die Teams-Meldung absetzen. 
+  meldung_s <- paste0(nrow(neue_daten),
+                      " Stimmbezirke neu ausgezählt ",
+                      "(insgesamt ",gezaehlt,
+                      " von ",stimmbezirke_n,")<br>",
+                      "<br><strong>DERZEITIGER STAND: GANZE STADT</strong><br>",
+                      # Oberste Zeile der Ergebnistabelle ausgeben
+                      ergebnistabelle_df %>% head(1) %>% pull(Wahlbeteiligung),
+                      "<br>",
+                      ergebnistabelle_df %>% head(1) %>% pull(Ergebnis))
+  # Neue Stadtteile? Dann 
+  neue_stadtteile <- stadtteildaten_df %>%
+    # Ausgezählte Stadtteile ausfiltern
+    filter(meldungen_anz == meldungen_max) %>% 
+    # ...und schauen, ob da ein neuer dabei ist
+    inner_join(neue_daten %>%
+                 # Neu gezählte Stimmbezirks-Meldung um Stadtteile ergänzen
+                 left_join(stimmbezirke_df, by="nr") %>% 
+                 select(name = stadtteil) %>% unique(),
+               by="name")
+  #                
+  if(nrow(neue_stadtteile)>0) {
+    for (s in neue_stadtteile %>% pull(nr)) {
+      # Isoliere den Stadtteil, dessen Nummer wir gerade anschauen
+      stadtteil <- stadtteildaten_df %>% filter(nr == s)
+      meldung_s <- paste0(meldung_s,
+                          "<br><br><strong>Ausgezählter Stadtteil: ",
+                          stadtteil$name,
+                          "</strong><br>",
+                          ergebnistabelle_df %>% filter(nr == s) %>% pull(Wahlbeteiligung),
+                          "<br>",
+                          ergebnistabelle_df %>% filter(nr == s) %>% pull(Ergebnis))
+    }
+    # Stadtteil neu ausgezählt?
+  }
+  meldung_s <- paste0(meldung_s,"<br><br>",
+                      generiere_socialmedia())
+  teams_meldung(meldung_s,title=wahl_name)
+  
+  check = tryCatch(
+    {
+      aktualisiere_karten(ergänzt_df)
+      aktualisiere_hochburgen(hochburgen_df)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Grafiken B"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Grafiken B"))}
+  )
+}
\ No newline at end of file
diff --git a/R/lies_konfiguration.R b/R/lies_konfiguration.R
new file mode 100644
index 0000000..b36523a
--- /dev/null
+++ b/R/lies_konfiguration.R
@@ -0,0 +1,87 @@
+#---- Vorbereitung ----
+# Statische Daten einlesen
+# (das später durch ein schnelleres .rda ersetzen)
+
+# Enthält drei Datensätze: 
+# - opendata_wahllokale_df mit der Liste aller Stimmwahlbezirke nach Wahllokal
+# - statteile_df: Stadtteil mit Namen und laufender Nummer, Geokoordinaten, Ergebnissen 2018
+# - zuordnung_stimmbezirke: Stimmbezirk-Nummer (als int und String) -> Stadtteilnr.
+
+# load ("index/index.rda")
+
+
+# Konfiguration auslesen und in Variablen schreiben
+#
+# Generiert für jede Zeile die genannte Variable mit dem Wert value
+#
+# Derzeit erwartet das Programm: 
+# - wahl_name - Name der Wahl ("obwahl_ffm", "obwahl_kassel_stichwahl" etc.)
+# - stimmbezirke_url - URL auf Ergebnisdaten
+# - kandidaten_fname - Dateiname der Kandidierenden-Liste (s.u.)
+# - datawrapper_fname - Dateiname für die Datawrapper-Verweis-Datei
+# - stadtteile_fname
+# - zuordnung_fname
+# - startdatum - wann beginne ich zu arbeiten?
+# - wahlberechtigt - Zahl der Wahlberechtigen (kommt Sonntag)
+# - briefwahl - Zahl der Briefwahlstimmen (kommt Sonntag)
+
+if (TEST) {
+  config_df <- read_csv("index/config_test.csv")
+} else {
+  config_df <- read_csv("index/config.csv")
+}
+for (i in c(1:nrow(config_df))) {
+  # Erzeuge neue Variablen mit den Namen und Werten aus der CSV
+  assign(config_df$name[i],
+         # Kann man den Wert auch als Zahl lesen?
+         # Fieses Regex sucht nach reiner Zahl oder Kommawerten.
+         # Keine Exponentialschreibweise!
+         ifelse(grepl("^[0-9]*\\.*[0-9]+$",config_df$value[i]),
+                # Ist eine Zahl - wandle um
+                as.numeric(config_df$value[i]),
+                # Keine Zahl - behalte den String
+                config_df$value[i]))
+}
+
+lies_daten <- function(fname) {
+  if (toupper(str_extract(fname,"(?<=\\.)[A-zA-Z]+$")) %in% c("XLS","XLSX")) {
+    # Ist offensichtlich eine Excel-Datei. Lies das erste Sheet.
+    return(read.xlsx(fname))
+  } else {
+    # Geh von einer CSV-Datei aus.
+    first_line <- readLines(fname, n = 1)
+    commas <- str_split(first_line, ",") %>% unlist() %>% length()
+    semicolons <- str_split(first_line, ";") %>% unlist() %>% length()
+    if (commas > semicolons) {
+      return(read_csv(fname))
+    } else {
+      # Glaube an das Gute im Menschen: Erwarte UTF-8 und deutsche Kommasetzung.
+      return(read_csv2(fname,
+                       locale = locale(
+                         date_names = "de",
+                         date_format = "%Y-%m-%d",
+                         time_format = "%H:%M:%S",
+                         decimal_mark = ",",
+                         grouping_mark = ".",
+                         encoding = "UTF-8",
+                         asciify = FALSE
+                       )))
+    }
+    
+  }
+}
+# Stadtteilname und -nr; Geokoordinaten. Später: Ergebnisse
+# der 2021er Kommunalwahl
+stadtteile_df <- lies_daten(paste0("index/",wahl_name,"/",stadtteile_fname))
+# Zuordnung Stimmbezirk (Wahllokale und Briefwahl-Bezirke) -> Stadtteil
+stimmbezirke_df <- lies_daten(paste0("index/",wahl_name,"/",zuordnung_fname)) %>% 
+  # Nummer-Spalten in numerische INdizes umwandeln
+  mutate(ortsteilnr = as.integer(ortsteilnr)) %>% 
+  mutate(nr = as.integer(nr)) %>% 
+  left_join(stadtteile_df %>% select(nr=1,stadtteil=2), by=c("ortsteilnr"="nr"))
+# Kandidat:innen-Index
+kandidaten_df <- lies_daten(paste0("index/",wahl_name,"/",kandidaten_fname))
+
+# Läufst du auf dem Server?
+SERVER <- dir.exists("/home/jan_eggers_hr_de") 
+
diff --git a/R/main.R b/R/main.R
new file mode 100644
index 0000000..6ddc7f1
--- /dev/null
+++ b/R/main.R
@@ -0,0 +1,117 @@
+library(pacman)
+
+# Laden und ggf. installieren
+p_load(this.path)
+p_load(readr)
+p_load(lubridate)
+p_load(tidyr)
+p_load(stringr)
+p_load(dplyr)
+p_load(DatawRappr)
+p_load(curl)
+p_load(magick)
+p_load(openxlsx)
+p_load(R.utils)
+
+rm(list=ls())
+
+TEST = TRUE
+DO_PREPARE_MAPS = TRUE
+
+
+
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Logfile anlegen, wenn kein Test
+if (!TEST) {
+  logfile = file("obwahl.log")
+  sink(logfile, append=T)
+  sink(logfile, append=T, type="message")
+
+}
+
+# Messaging-Funktionen einbinden
+source("R/messaging.R")
+
+
+# Hole die Konfiguration und die Index-Daten
+check = tryCatch(
+  { 
+    source("R/lies_konfiguration.R")
+  },
+  warning = function(w) {teams_warning(w,title="OBWAHL: Warnung beim Lesen der Konfigurationsdatei")},
+  error = function(e) {teams_error(e,title="OBWAHL: Konfigurationsdatei nicht gelesen!")})
+
+# Funktionen einbinden
+# Das könnte man auch alles hier in diese Datei schreiben, aber ist es übersichtlicher.
+source("R/lies_aktuellen_stand.R")
+source("R/aktualisiere_karten.R")
+
+#---- MAIN ----
+# Vorbereitung
+gezaehlt <- 0 # Ausgezählte Stimmbezirke
+ts <- as_datetime(startdatum) # ts, Zeitstempel, der letzten gelesenen Daten
+stimmbezirke_n <- nrow(stimmbezirke_df) # Anzahl aller Stimmbezirke bei der Wahl
+alte_daten <- lies_stimmbezirke(stimmbezirke_url) # Leere Stimmbezirke
+# Grafiken einrichten: Farbwerte und Switcher für die Karten
+# Richtet auch die globale Variable switcher ein, deshalb brauchen wir sie
+
+
+if (DO_PREPARE_MAPS) {
+  check = tryCatch(
+    vorbereitung_alle_karten(),
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Vorbereitung"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Vorbereitung"))}
+  )
+} else {
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  # Nur die Globale switcher_df definieren (mit den IDs der DW-Karten zum Kandidaten/Farbwert)
+  switcher_df <- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+}
+
+# Schleife.
+# Arbeitet so lange, bis alle Wahlbezirke ausgezählt sind. 
+# Als erstes immer einmal durchlaufen lassen. 
+while (gezaehlt < stimmbezirke_n) {
+  check = tryCatch(
+    { # Zeitstempel der Daten holen
+      ts_daten <- check_for_timestamp(stimmbezirke_url)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": CURL-Polling"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": CURL-Polling"))}
+  )  
+  # Neuere Daten? 
+  if (ts_daten > ts) {
+    ts <- ts_daten
+    hole_wahldaten()
+  } else {
+    # Logfile erneuern und 15 Sekunden schlafen
+    system("touch obwahl.log")
+    if (TEST) cat("Warte...\n")
+    Sys.sleep(15)
+  }
+}
+# Titel der Grafik "top" umswitchen
+dw_edit_chart(top_id,title="Ergebnis: Wahlsieger")
+dw_publish_chart(top_id)
+
+# Logging beenden
+if (!TEST) {
+  sink()
+  sink(type="message")
+  file.rename("obwahl.log","obwahl_success.log")
+}
+teams_meldung(wahl_name," erfolgreich abgeschlossen.")
+
+
+# EOF
\ No newline at end of file
diff --git a/R/main_oneshot.R b/R/main_oneshot.R
new file mode 100644
index 0000000..d4914a8
--- /dev/null
+++ b/R/main_oneshot.R
@@ -0,0 +1,95 @@
+library(pacman)
+
+# Laden und ggf. installieren
+p_load(this.path)
+p_load(readr)
+p_load(lubridate)
+p_load(tidyr)
+p_load(stringr)
+p_load(dplyr)
+p_load(DatawRappr)
+p_load(curl)
+p_load(magick)
+p_load(openxlsx)
+
+rm(list=ls())
+
+TEST = TRUE
+DO_PREPARE_MAPS = FALSE
+
+
+
+# Aktuelles Verzeichnis als workdir
+setwd(this.path::this.dir())
+# Aus dem R-Verzeichnis eine Ebene rauf
+setwd("..")
+
+# Logfile anlegen, wenn kein Test
+# if (!TEST) {
+#   logfile = file("obwahl.log")
+#   sink(logfile, append=T)
+#   sink(logfile, append=T, type="message")
+#   
+# }
+
+# Messaging-Funktionen einbinden
+source("R/messaging.R")
+
+
+# Hole die Konfiguration und die Index-Daten
+check = tryCatch(
+  { 
+    source("R/lies_konfiguration.R")
+  },
+  warning = function(w) {teams_warning(w,title="OBWAHL: Warnung beim Lesen der Konfigurationsdatei")},
+  error = function(e) {teams_error(e,title="OBWAHL: Konfigurationsdatei nicht gelesen!")})
+
+# Funktionen einbinden
+# Das könnte man auch alles hier in diese Datei schreiben, aber ist es übersichtlicher.
+source("R/lies_aktuellen_stand.R")
+source("R/aktualisiere_karten.R")
+
+#---- MAIN ----
+# Vorbereitung
+gezaehlt <- 0 # Ausgezählte Stimmbezirke
+ts <- as_datetime(startdatum) # ts, Zeitstempel, der letzten gelesenen Daten
+stimmbezirke_n <- nrow(stimmbezirke_df) # Anzahl aller Stimmbezirke bei der Wahl
+alte_daten <- lies_stimmbezirke(stimmbezirke_url) # Leere Stimmbezirke
+# Grafiken einrichten: Farbwerte und Switcher für die Karten
+# Richtet auch die globale Variable switcher ein, deshalb brauchen wir sie
+
+
+if (DO_PREPARE_MAPS) {
+  check = tryCatch(
+    vorbereitung_alle_karten(),
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": Vorbereitung"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": Vorbereitung"))}
+  )
+} else {
+  # Alle Datawrapper-IDs in einen Vektor extrahieren
+  id_df <- config_df %>% 
+    filter(str_detect(name,"_kand[0-9]+")) %>% 
+    mutate(Nummer = as.integer(str_extract(name,"[0-9]+"))) %>% 
+    select(Nummer,dw_id = value)#
+  # Mach aus der Switcher-Tabelle eine globale Variable
+  # Nur die Globale switcher_df definieren (mit den IDs der DW-Karten zum Kandidaten/Farbwert)
+  switcher_df <- kandidaten_df %>% 
+    select(Nummer, Vorname, Name, Parteikürzel, Farbwert) %>% 
+    left_join(id_df, by="Nummer")
+}
+
+# One-shot. 
+check = tryCatch(
+    { # Zeitstempel der Daten holen
+      ts_daten <- check_for_timestamp(stimmbezirke_url)
+    },
+    warning = function(w) {teams_warning(w,title=paste0(wahl_name,": CURL-Polling"))},
+    error = function(e) {teams_warning(e,title=paste0(wahl_name,": CURL-Polling"))}
+  )  
+    # Zeitstempel aktualisieren, Datenverarbeitung anstoßen
+ts <- ts_daten
+# Hole die neuen Daten
+
+hole_wahldaten()
+
+# EOF
\ No newline at end of file
diff --git a/R/messaging.R b/R/messaging.R
index b2b93b0..77807ca 100644
--- a/R/messaging.R
+++ b/R/messaging.R
@@ -9,15 +9,18 @@ library(teamr)
 #' 
 #' Kommunikation mit Teams
 #' 
-#' Webhook wird als URL im Environment gespeichert. Wenn nicht dort, dann 
+#' Webhook wird als URL im Environment gespeichert. Wenn nicht dort, dann Datei im Nutzerverzeichnis ~/key/ einlesen.
+#' MSG-Funktion schreibt alles in die Logdatei und auf den Bildschirm. (Vgl. Corona.)
+
+
 
 # Webhook schon im Environment? 
-if (Sys.getenv("WEBHOOK_REFERENDUM") == "") {
-  t_txt <- read_file("../key/webhook_referendum.key")
+if (Sys.getenv("WEBHOOK_OBWAHL") == "") {
+  t_txt <- read_file("~/key/webhook_obwahl.key")
   Sys.setenv(WEBHOOK_REFERENDUM = t_txt)
 }
 
-teams_meldung <- function(...,title="Feldmann-Update") {
+teams_meldung <- function(...,title="OB-Wahl-Update") {
   cc <- teamr::connector_card$new(hookurl = t_txt)
   cc$title(paste0(title," - ",lubridate::with_tz(lubridate::now(),
                                                  "Europe/Berlin")))
@@ -29,13 +32,13 @@ teams_meldung <- function(...,title="Feldmann-Update") {
 
 teams_error <- function(...) {
   alert_str <- paste0(...)
-  teams_meldung(title="Feldmann: FEHLER: ", ...)
+  teams_meldung(title="OB-Wahl: FEHLER: ", ...)
   stop(alert_str)
 } 
 
 teams_warning <- function(...) {
   alert_str <- paste0(...)
-  teams_meldung("Feldmann: WARNUNG: ",...)
+  teams_meldung("OB-Wahl: WARNUNG: ",...)
   warning(alert_str)
 } 
 
diff --git a/R/update_all.R b/R/update_all.R
deleted file mode 100644
index e0dd5d6..0000000
--- a/R/update_all.R
+++ /dev/null
@@ -1,203 +0,0 @@
-library(pacman)
-
-# Laden und ggf. installieren
-p_load(this.path)
-p_load(readr)
-p_load(lubridate)
-p_load(tidyr)
-p_load(stringr)
-p_load(dplyr)
-p_load(DatawRappr)
-
-rm(list=ls())
-
-# Aktuelles Verzeichnis als workdir
-setwd(this.path::this.dir())
-# Aus dem R-Verzeichnis eine Ebene rauf
-setwd("..")
-
-
-source("R/messaging.R")
-source("R/lies_aktuellen_stand.R")
-source("R/aktualisiere_karten.R")
-source("R/generiere_balken.R")  
-
-
-#----aktualisiere_fom() ----
-# fom ist das "Feldmann-o-meter", die zentrale Grafik mit dem Stand der Auszählung.
-
-aktualisiere_fom <- function(wl_url = stimmbezirke_url) {
-  
-  # Einlesen: Feldmann-o-meter-Daten so far. 
-  # Wenn die Daten noch nicht existieren, generiere ein leeres df. 
-  if(file.exists("daten/fom_df.rds")) {
-    fom_df <- readRDS("daten/fom_df.rds")
-  } else {
-    # Leeres df mit einer Zeile
-    fom_df <- tibble(zeitstempel = as_datetime(startdatum),
-                           meldungen_anz = 0,
-                           meldungen_max = 575,
-                           # Ergebniszellen
-                           wahlberechtigt = 0,
-                           # Mehr zum Wahlschein hier: https://www.bundeswahlleiter.de/service/glossar/w/wahlscheinvermerk.html
-                           waehler_regulaer = 0,
-                           waehler_wahlschein = 0,
-                           waehler_nv = 0,
-                           stimmen = 0,
-                           stimmen_wahlschein = 0, 
-                           ungueltig = 0,
-                           gueltig = 0,
-                           ja = 0,
-                           nein = 0)
-    # SAVE kann man sich schenken; df ist schneller neu erzeugt
-    # save(feldmann_df,"daten/feldmann_df.rda")
-  }
-  # Daten zur Sicherheit sortieren, dann die letzte  Zeile rausziehen
-  letzte_fom_df <- fom_df %>% 
-    arrange(zeitstempel) %>% 
-    tail(1)
-  # Neue Daten holen (mit Fehlerbehandlung)
-  stimmbezirke_df <- lies_gebiet(wl_url) 
-  neue_fom_df <- stimmbezirke_df %>% 
-    # Namen raus
-    select(-name,-nr) %>% 
-    # Daten aufsummieren
-    summarize(zeitstempel = last(zeitstempel),
-      across(2:ncol(.), ~ sum(.,na.rm=T)))
-  # Alte und neue Daten identisch? Dann brich ab. 
-  if (vergleiche_stand(letzte_fom_df,neue_fom_df)) {
-    return(FALSE)
-  } else {
-    # Archiviere die Rohdaten 
-    archiviere(stimmbezirke_df,"daten/stimmbezirke/")
-    # Ergänze das fom_df um die neuen Daten und sichere es
-    fom_df <- fom_df %>% bind_rows(neue_fom_df)
-    saveRDS(fom_df,"daten/fom_df.rds")
-    # Bilde das Dataframe
-    # Sende die Daten an Datawrapper und aktualisiere
-    fom_dw_df <- fom_df %>% 
-      mutate(ausgezählt = meldungen_anz / meldungen_max *100) %>% 
-      mutate(prozent30 = NA) %>% 
-      mutate(quorum = ja / wahlberechtigt * 100) %>% 
-      select(ausgezählt, wahlberechtigt, ungueltig, ja, nein, quorum, prozent30) %>% 
-      # Noch den Endpunkt der 30-Prozent-Linie
-      bind_rows(tibble(ausgezählt = 100, prozent30 = ffm_waehler * 0.3))
-    dw_data_to_chart(fom_dw_df,fom_id)
-    # Parameter setzen
-    alles_ausgezählt <- (neue_fom_df$meldungen_max == neue_fom_df$meldungen_anz)
-    if (neue_fom_df$meldungen_anz == 0) {
-      quorum = 0
-      feldmann_str <- "Es liegen noch keine Auszählungsdaten des Bürgerentscheids vor."
-    } else {
-      quorum <- (neue_fom_df$ja / neue_fom_df$wahlberechtigt * 100)
-      if (quorum >= 30) {
-        if (alles_ausgezählt ) {
-          feldmann_str <- "Peter Feldmann ist als OB abgewählt."
-        } else {
-          feldmann_str <- "Nach dem derzeitigen Auszählungsstand wäre Peter Feldmann als OB abgewählt."
-        }
-      } else {
-        if (alles_ausgezählt ) {
-          feldmann_str <- "Peter Feldmann bleibt OB von Frankfurt."
-        } else {
-          feldmann_str <- "Nach dem derzeitigen Auszählungsstand bliebe Peter Feldmann OB von Frankfurt."
-        }
-      }
-    }
-    
-    # Breite des Balkens: Wenn das Quorum erreicht ist, hat er die volle Breite,
-    # wenn nicht, einen Anteil von 30%, um die Entfernung von der Markierung zu zeigen
-    
-    # Jetzt die Beschreibungstexte mit den Fake-Balkengrafiken generieren
-    beschreibung_str <- paste0(
-      "Die Abwahl ist beschlossen, wenn mindestens 30 Prozent aller Wahlberechtigten mit &quot;Ja&quot; stimmen.<br/><br>",
-      "<b style='font-weight:700;font-size:120%;'>",
-      # Erste dynamisch angepasste Textstelle: Bleibt Feldmann?
-      feldmann_str,
-      "</b><br/><br>",
-      generiere_balken(wb = neue_fom_df$wahlberechtigt,
-                       ja = neue_fom_df$ja,
-                       nein = neue_fom_df$nein,
-                       auszählung_beendet = alles_ausgezählt))
-    annotate_str <- generiere_auszählungsbalken(
-      ausgezählt = floor(neue_fom_df$wahlberechtigt / ffm_waehler * 100),
-      anz = neue_fom_df$meldungen_anz,
-      max = neue_fom_df$meldungen_max,
-      ts = neue_fom_df$zeitstempel)
-    briefwahl_anz <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-      pull(meldungen_anz) %>% sum()
-    briefwahl_max <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-      nrow()
-    annotate_str <- paste0("<strong>Derzeit sind ",
-                           briefwahl_anz,
-                           " von ",
-                           briefwahl_max, 
-                           " Briefwahl-Stimmbezirken ausgezählt.</strong><br/><br/>",
-                           annotate_str)
-    dw_edit_chart(fom_id,intro = beschreibung_str,annotate = annotate_str)
-    dw_publish_chart(fom_id)
-    return(TRUE)
-  }
-}
-
-
-#---- MAIN ----
-# Ruft aktualisiere_fom() auf
-# (die dann wieder aktualisiere_karten() aufruft)
-check = tryCatch(
-  { 
-    neue_daten <- aktualisiere_fom(stimmbezirke_url)
-  },
-  warning = function(w) {teams_warning(w,title="Feldmann: fom")},
-  error = function(e) {teams_warning(e,title="Feldmann: fom")})
-# Neue Daten? Dann aktualisiere die Karten
-if (neue_daten) {
-  check = tryCatch(
-    { 
-      neue_daten <- aktualisiere_karten(stimmbezirke_url)
-    },
-    warning = function(w) {teams_warning(w,title="Feldmann: Karten")},
-    error = function(e) {teams_warning(e,title="Feldmann: Karten")})
-  if (neue_daten) {
-    # Alles OK, letzte Daten nochmal holen und ausgeben
-    fom_df <- readRDS("daten/fom_df.rds") %>% 
-      arrange(zeitstempel) %>% 
-      tail(1)
-    if(fom_df$meldungen_anz > 0) {
-      stimmbezirke_df <- lies_gebiet(stimmbezirke_url)
-      briefwahl_anz <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-        pull(meldungen_anz) %>% sum()
-      briefwahl_max <- stimmbezirke_df %>% filter(str_detect(nr,"^9")) %>% 
-        nrow()
-      fom_update_str <- paste0(
-        "<strong>Update OK</strong><br/><br/>",
-        fom_df$meldungen_anz,
-        " von ",
-        fom_df$meldungen_max," Stimmbezirke ausgezählt.<br> ",
-                              "Derzeit sind ",
-                               briefwahl_anz,
-                               " von ",
-                               briefwahl_max, 
-                               " Briefwahl-Stimmbezirken ausgezählt.<br/>",
-        "<ul><li><strong>Quorum zur Abwahl ist derzeit",
-        ifelse(fom_df$ja / fom_df$wahlberechtigt < 0.3, " nicht ", " "),
-        "erreicht</strong></li>",
-        "<li><strong>Anteil der Ja-Stimmen an den Wahlberechtigten: ",
-        format(fom_df$ja / fom_df$wahlberechtigt * 100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"%",
-        "</li><li>Ja-Stimmen: ",
-        format(fom_df$ja,decimal.mark=",",big.mark="."),
-        "</li><li>Nein-Stimmen: ",
-        format(fom_df$nein,decimal.mark=",",big.mark="."),
-        "</li><li>Verhältnis Ja:Nein: ",
-        format(fom_df$ja / (fom_df$ja + fom_df$nein) * 100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"% : ",
-        format(fom_df$nein / (fom_df$ja + fom_df$nein) *100,decimal.mark=",",big.mark=".",nsmall=1, digits=3),"%</li></ul>"
-        
-      )
-      teams_meldung(fom_update_str,title="Feldmann-Referendum")
-
-    }
-  } else {
-    teams_warning("Neue Stimmbezirk-Daten, aber keine neuen Ortsdaten?")
-  }
-} 
-# Auch hier TRUE zurückbekommen;; alles OK?
\ No newline at end of file
diff --git a/README.md b/README.md
index b56eda9..f74ad16 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,25 @@
 # obwahlen PRE
 
-**DIES IST IM AUGENBLICK NUR EINE NOCH NICHT ANGEPASSTE KOPIE DES REFERENDUMS-CODES** - bitte nicht nutzen und wundern! Anpassung spätestens zur [1. Runde der OB-Wahl in Frankfurt am 5. März 2023](https://frankfurt.de/aktuelle-meldung/meldungen/direktwahl-oberbuergermeisterin-oberbuergermeister-frankfurt/). 
-
 R-Code, um den Auszählungsstand hessischer Bürgermeisterwahlen in Echtzeit abzurufen und mit Datawrapper darzustellen
 
 ## Ordnerstruktur
 
 - **R** enthält den Code
-- **index** enthält Index-, Konfigurations-, und Template-Dateien
+- **index** enthält die Konfigurationsdatei index.csv und Unterordner mit den Indexdateien: Kandidaten, Stadtteile, Stimmbezirke, Datawrapper-Zuordnungen. 
 - **daten** wird vom Code beschrieben und enthält den aktuellen Datenstand.
 
 ## Daten aufarbeiten
 
 ### Ziele
 
-Folgende Grafiken wären denkbar: 
-* Balkengrafik Ergebnis nach derzeitigem Auszählungsstand mit "Fortschrittsbalken"
-* Choropleth Stadtteil-Sieger
+Grafiken: 
+* Säulengrafik erste fünf; Ergebnis nach derzeitigem Auszählungsstand mit "Fortschrittsbalken"
+
+* Balkengrafik alle
+* Choropleth Stadtteil-Sieger (mit Switcher alle, die gewonnen haben)
 * Choropleth Ergebnis nach Kandidat
-* Choropleth Wahlbeteiligung
-* Choropleth Briefwahl
+* Tabelle nach Kandidaten (3 beste, 3 schlechteste Stadtteile)
 * Tabelle nach Stadtteil 
-* Tabelle nach Kandidaten (Erste drei? fünf?)
 
 ### Konfiguration
 
@@ -41,15 +39,20 @@ Aggregation auf Stadtebene
 - Fortschrittsbalken ausgezählte Stimmen (mit akt. Briefwahlstimmendaten)
 
 
-## Struktur des Codes
+## Struktur des Codes: Was tut was?
+
+(siehe ["Sitemap"](./sitemap.md) für den Code)
+
+
+# TODO
 
-### Hauptroutinen
 
-- **update_all.R** ist das Skript für den CRON-Job. Es pollt nach Daten, ruft die Abruf-, Aggregations- und Auswertungsfunktionen auf und gibt Meldungen aus. 
-- **lies_aktuellen_stand.R** enthält Funktionen, die die Daten lesen, aggregieren und archivieren
-- **aktualisiere_karten.R** enthält die Funktionen zur Datenausgabe
-- **messaging.R** enthält Funktionen, die Teams-Updates und -Fehlermeldungen generieren
+- Upload aufs Repository
 
-### Hilfsfunktionen
+## NTH
 
-- **generiere_testdaten.R** ist ein Skript, das zufällige, aber plausible CSV-Daten auf Stimmbezirks-Ebene zum Testen generiert
+- Umschalten Top5-Titel Ergebnis
+- Zusatzfeature: Briefwahlprognostik - wieviele Stimmen fehlen vermutlich noch?
+- Shapefiles KS, DA verbessern
+- Datensparsamere Alternativ-CURL-Poll-Datei (zB mit dem Gesamtergebnis)
+- Mehr Licht in den Choropleth-Karten farbabhängig
diff --git a/howto_shapefiles.md b/howto_shapefiles.md
new file mode 100644
index 0000000..b60bcfd
--- /dev/null
+++ b/howto_shapefiles.md
@@ -0,0 +1,26 @@
+1. Shapefile in QGIS importieren 
+
+2. GEOJSON im richtigen Koordinatensystem erstellen
+Dazu Rechtsklick auf den Layer; Koordinatensystem WGS84, exportieren
+
+3. Stadtteile generieren
+Menü "Vektor", "Geometrieverarbeitungswerkzeuge", "Auflösen" - und dann in der Dialogbox auswählen "Felder auflösen [optional]", und dann die Attribute hinzufügen, nach denen zusammengeführt werden soll. 
+
+In KS beispielsweise gab es die 
+
+
+- Rechtsklick auf den Layer; Exportieren als GEOJSON - nicht vergessen, das Bezugssystem auf WGS84 umzustellen!
+- Rechtsklick auf den Layer; Export als XLSX - ggf. Geo-Attribute abschalten
+
+4. Mittelpunkte der Stadtteile
+
+Menü "Vektor", "Geometrie-Werkzeuge", "Zentroide"
+
+Dann noch Geokoordinaten der Zentroidpunkte: Rechte Seite die Toolbox, dort "Vektortabelle" aufklappen, "X/Y-Felder zu Attributen hinzufügen"
+
+- Rechtsklick auf den neu erzeugten Layer, exportieren als XLSX bzw CSV
+
+5. CSV-/XLSX-Dateien putzen
+
+- Brauchen eine Stadtteil-Datei mit nr,name,lon,lat (erzeugt aus den Zentroiden)
+- Brauchen einen Wahlbezirks-Zuordnung
diff --git a/index/config.csv b/index/config.csv
index 0409078..45880e5 100644
--- a/index/config.csv
+++ b/index/config.csv
@@ -1,8 +1,23 @@
 name,value,comment
-stimmbezirke_url,https://votemanager-ffm.ekom21cdn.de/2022-11-06/06412000/praesentation/Open-Data-06412000-Buergerentscheid-zur-Abwahl-des-Oberbuergermeisters-der-Stadt-Frankfurt-am-Main_-Herrn-Peter-Feldmann-Stimmbezirk.csv?ts=1667662273015,URL Daten-CSV Stimmbezirke
-ffm_waehler,508182,Wahlamt
-fom_id,bIm87,Datawrapper-ID Feldmann-o-meter
-choropleth_id,UwKOO,Datawrapper-ID Stadtteile Choropleth-Karte
-symbol_id,RWqrf,Datawrapper-ID Stadtteile Symbole (absolute Stimmen)
-tabelle_id,hLqMi,Datawrapper-ID Tabelle Stadtteile
-startdatum,2022-11-06 18:00:00 CET,Beginn der Auszählung
+wahl_name,obwahl_ks_2023,Welche Wahl?
+stimmbezirke_url,https://votemanager-ks.ekom21cdn.de/2023-03-12/06611000/daten/opendata/Open-Data-06611000-Direktwahl-zur-Oberbuergermeisterin-zum-Oberbuergermeister-Wahlbezirk.csv?ts=1678486050153,URL Daten-CSV Stimmbezirke
+wahlberechtigt,147463,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,39092,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+top,6,Anzahl der Top-Kandidaten in den Darstellungen
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,wahlbezirke.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-03-12 16:00:00,Beginn der Auszählung
+top_id,Ts1oS,
+karte_sieger_id,O9wPT,
+karte_kand1_id,hM9SE,Schöller
+karte_kand2_id,07CR4,Carqueville
+karte_kand3_id,whgzp,Kühne-Hörmann
+karte_kand4_id,5CpYu,Bock
+karte_kand5_id,pc6vH,Käufler
+karte_kand6_id,sEJhl,Geselle
+tabelle_alle_id,EQ4dd,
+hochburgen_id,GMTSJ,
+tabelle_stadtteile_id,q7yjs,
+social1_id,Ts1oS,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/config_test.csv b/index/config_test.csv
new file mode 100644
index 0000000..8c2e436
--- /dev/null
+++ b/index/config_test.csv
@@ -0,0 +1,23 @@
+name,value,comment
+wahl_name,obwahl_ks_2023,Welche Wahl?
+stimmbezirke_url,https://www.eggers-elektronik.de/files/test.csv,URL Daten-CSV Stimmbezirke
+wahlberechtigt,147463,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,39092,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,wahlbezirke.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,ks-stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-01-01 18:00:00 CET,Beginn der Auszählung
+top,6,
+top_id,028Fp,
+karte_sieger_id,7gscI,
+karte_kand1_id,hM9SE,Schöller
+karte_kand2_id,07CR4,Carqueville
+karte_kand3_id,whgzp,Kühne-Hörmann
+karte_kand4_id,5CpYu,Bock
+karte_kand5_id,pc6vH,Käufler
+karte_kand6_id,sEJhl,Geselle
+tabelle_alle_id,PLwHI,
+hochburgen_id,Im2PX,
+tabelle_stadtteile_id,BM8kD,
+social1_id,028Fp,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/ffm_config.csv b/index/ffm_config.csv
new file mode 100644
index 0000000..2f97d66
--- /dev/null
+++ b/index/ffm_config.csv
@@ -0,0 +1,37 @@
+name,value,comment
+wahl_name,obwahl_ffm_2023,Welche Wahl?
+stimmbezirke_url,https://votemanager-ffm.ekom21cdn.de/2023-03-05/06412000/daten/opendata/Open-Data-06412000-OB-Wahl-Wahlbezirk.csv?ts=1677904123448,URL Daten-CSV Stimmbezirke
+wahlberechtigt,508182,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,250000,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+datawrapper_fname,datawrapper.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,zuordnung_wahllokale.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-03-05 16:00:00,Beginn der Auszählung
+top5_id,2DYBQ,
+karte_sieger_id,ANKmx,
+karte_kand1_id,RcvQp,Rottmann (Grüne)
+karte_kand2_id,jrm2v,Becker (CDU)
+karte_kand3_id,bKR8r,Josef (SPD)
+karte_kand4_id,etN3J,Mehler-Würzbach (Linke)
+karte_kand5_id,3mydT,Pürsün (FDP)
+karte_kand6_id,K3aCw,Lobenstein (AfD)
+karte_kand7_id,vtG4Y,Pfeiffer (BFF)
+karte_kand8_id,tRHeI,Tanczos (PARTEI)
+karte_kand9_id,v4Y5m,Schwichtenberg (Gartenpartei)
+karte_kand10_id,g3iBN,Wirth (unabh.)
+karte_kand11_id,4LxcN,Camara (FPF)
+karte_kand12_id,RZDF7,Pauli (unabh.)
+karte_kand13_id,F86gf,Junghans (unabh.)
+karte_kand14_id,bLPXL,Xu (unabh.)
+karte_kand15_id,Ktufa,Wolff (unabh.)
+karte_kand16_id,MO41j,Akhtar (Todenhöfer)
+karte_kand17_id,ccrfL,Großenbach (Basis)
+karte_kand18_id,q2S6m,Pawelski (unabh.)
+karte_kand19_id,697CL,Schulte (unabh.)
+karte_kand20_id,3lMmu,Eulig (unabh.)
+tabelle_alle_id,7kRPR,
+hochburgen_id,oB3KH,
+tabelle_stadtteile_id,LiXnz,
+social1_id,2DYBQ,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/ffm_config_test.csv b/index/ffm_config_test.csv
new file mode 100644
index 0000000..07d5d1a
--- /dev/null
+++ b/index/ffm_config_test.csv
@@ -0,0 +1,37 @@
+name,value,comment
+wahl_name,obwahl_ffm_2023,Welche Wahl?
+stimmbezirke_url,testdaten/dummy.csv,URL Daten-CSV Stimmbezirke
+wahlberechtigt,508182,Anzahl Wahlberechtigte lt. Wahlamt (kommt Sonntag)
+briefwahl,250000,Anzahl Briefwahlstimmen lt. Wahlamt (kommt Sonntag)
+kandidaten_fname,kandidaten.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+datawrapper_fname,datawrapper.xlsx,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+zuordnung_fname,zuordnung_wahllokale.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+stadtteile_fname,stadtteile.csv,"XLSX oder CSV, wird im Ordner <wahl_name> erwartet"
+startdatum,2023-01-01 18:00:00 CET,Beginn der Auszählung
+top5_id,028Fp,
+karte_sieger_id,7gscI,
+karte_kand1_id,hM9SE,Rottmann (Grüne)
+karte_kand2_id,hM9SE,Becker (CDU)
+karte_kand3_id,07CR4,Josef (SPD)
+karte_kand4_id,07CR4,Mehler-Würzbach (Linke)
+karte_kand5_id,07CR4,Pürsün (FDP)
+karte_kand6_id,07CR4,Lobenstein (AfD)
+karte_kand7_id,07CR4,Pfeiffer (BFF)
+karte_kand8_id,07CR4,Tanczos (PARTEI)
+karte_kand9_id,07CR4,Schwichtenberg (Gartenpartei)
+karte_kand10_id,07CR4,Wirth (unabh.)
+karte_kand11_id,07CR4,Camara (FPF)
+karte_kand12_id,07CR4,Pauli (unabh.)
+karte_kand13_id,07CR4,Junghans (unabh.)
+karte_kand14_id,07CR4,Xu (unabh.)
+karte_kand15_id,07CR4,Wolff (unabh.)
+karte_kand16_id,07CR4,Akhtar (Todenhöfer)
+karte_kand17_id,07CR4,Großenbach (Basis)
+karte_kand18_id,07CR4,Pawelski (unabh.)
+karte_kand19_id,07CR4,Schulte (unabh.)
+karte_kand20_id,07CR4,Eulig (unabh.)
+tabelle_alle_id,PLwHI,
+hochburgen_id,Im2PX,
+tabelle_stadtteile_id,BM8kD,
+social1_id,028Fp,5 stärkste
+social2_id,S9BbQ,Alle Stimmen angepasst
diff --git a/index/obwahl_ffm_2023/kandidaten.xlsx b/index/obwahl_ffm_2023/kandidaten.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..11bdb590d825bbe4cd94aab1bb12e0e987c7a954
GIT binary patch
literal 6920
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

literal 0
HcmV?d00001

diff --git a/index/obwahl_ffm_2023/stadtteile.csv b/index/obwahl_ffm_2023/stadtteile.csv
new file mode 100644
index 0000000..b07902a
--- /dev/null
+++ b/index/obwahl_ffm_2023/stadtteile.csv
@@ -0,0 +1,45 @@
+nr,name,lon,lat
+1,Altstadt,8.682385346400634,50.11059669873516
+2,Innenstadt,8.682664888207869,50.113790989177375
+4,Westend-Süd,8.6594393465439925,50.11682467903111
+5,Westend-Nord,8.666488952498467,50.128769620533795
+6,Nordend-West,8.684596043386934,50.13022858244322
+7,Nordend-Ost,8.69761974358955,50.127318654892264
+8,Ostend,8.719218276147204,50.11554639352114
+9,Bornheim,8.712407551447747,50.13090801018288
+10,Gutleut- und Bahnhofsviertel,8.652137942960298,50.099479845695974
+11,Gallus,8.636377745265355,50.10300477630784
+12,Bockenheim,8.632922516089874,50.12128753657858
+13,Sachsenhausen-Nord,8.684579993638577,50.10051804775371
+14,Sachsenhausen-Süd und Flughafen,8.629663957345496,50.0607635296547
+16,Oberrad,8.727138168461476,50.09922899999497
+17,Niederrad,8.636199605275262,50.081631798202295
+18,Schwanheim,8.572652070944704,50.081760019105545
+19,Griesheim,8.600109548586545,50.09781734654457
+20,Rödelheim,8.603076601631098,50.127692431637506
+21,Hausen,8.626134516198546,50.13524298077671
+22,Praunheim,8.61444644716483,50.14547905112678
+24,Heddernheim,8.64020132453368,50.158128239125205
+25,Niederursel,8.616911198776547,50.16683966510584
+26,Ginnheim,8.648134546192246,50.14388748058928
+27,Dornbusch,8.670541998003081,50.14434313041997
+28,Eschersheim,8.659950213724542,50.16002839395001
+29,Eckenheim,8.683795236784233,50.148564823086005
+30,Preungesheim,8.697198159142667,50.15544843144313
+31,Bonames,8.665887880254154,50.18258113675648
+32,Berkersheim,8.702941786636124,50.17015956481773
+33,Riederwald,8.73274589886058,50.12667040584185
+34,Seckbach,8.726644066440096,50.147246840458955
+35,Fechenheim,8.762275115113775,50.12551773891441
+36,Höchst,8.539657322936813,50.098523172532
+37,Nied,8.57676379509509,50.103479453362766
+38,Sindlingen,8.51273746688725,50.07800492013246
+39,Zeilsheim,8.495768400332896,50.097784690964886
+40,Unterliederbach,8.525490184772172,50.10992510336304
+41,Sossenheim,8.574019745075416,50.12010605434964
+42,Nieder-Erlenbach,8.709219115856458,50.20871646737095
+43,Kalbach-Riedberg,8.639008245521376,50.1846309062546
+44,Harheim,8.689844680792895,50.18583895746724
+45,Nieder-Eschbach,8.668094243977997,50.20106152063871
+46,Bergen-Enkheim,8.766772308170399,50.15913246211432
+47,Frankfurter Berg,8.673427563622,50.169778678198924
diff --git a/index/zuordnung_wahllokale.csv b/index/obwahl_ffm_2023/zuordnung_wahllokale.csv
similarity index 100%
rename from index/zuordnung_wahllokale.csv
rename to index/obwahl_ffm_2023/zuordnung_wahllokale.csv
diff --git a/index/obwahl_ks_2023/kandidaten.xlsx b/index/obwahl_ks_2023/kandidaten.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..e10b674797487ee43e2033972e604fe8f5e8c83b
GIT binary patch
literal 6265
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
I>CN5$0Y}@)O#lD@

literal 0
HcmV?d00001

diff --git "a/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv" "b/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv"
new file mode 100644
index 0000000..55d7917
--- /dev/null
+++ "b/index/obwahl_ks_2023/ks-stadtteile-mit-d\303\266nchelandschaft.csv"
@@ -0,0 +1,25 @@
+nr,name,lon,lat
+1,Mitte,9.4894928677,51.3151211858
+2,Südstadt,9.4882009490,51.3005087449
+3,Vorderer Westen,9.4640787357,51.3163544480
+4,Wehlheiden,9.4654088136,51.3056032748
+5,Bad Wilhelmshöhe,9.4011506791,51.3116605680
+6,Brasselsberg,9.3972256665,51.2912775844
+7,Süsterfeld-Helleböhn,9.4469292484,51.2964342813
+8,Harleshausen,9.4151173612,51.3391059157
+9,Kirchditmold,9.4391198473,51.3249890808
+10,Rothenditmold,9.4771802265,51.3287075207
+11,Nord-Holland,9.4937754140,51.3323742823
+12,Philippinenhof-Warteberg,9.4941950806,51.3457423064
+13,Fasanenhof,9.5123139521,51.3347662999
+14,Wesertor,9.5148784085,51.3221887268
+15,Wolfsanger-Hasenhecke,9.5457989433,51.3396806416
+16,Bettenhausen,9.5384097499,51.3044330002
+17,Forstfeld,9.5418612035,51.2915878853
+18,Waldau,9.5100487565,51.2868080527
+19,Niederzwehren,9.4709628876,51.2783155707
+20,Oberzwehren,9.4443563894,51.2747255123
+21,Nordshausen,9.4245341169,51.2790202220
+22,Jungfernkopf,9.4607713668,51.3409314760
+23,Unterneustadt,9.5160276635,51.3119155114
+25,Dönchelandschaft (ortsbezirksfrei),9.4320779328,51.2918551834
diff --git a/index/obwahl_ks_2023/ks-stadtteile.csv b/index/obwahl_ks_2023/ks-stadtteile.csv
new file mode 100644
index 0000000..ddb583e
--- /dev/null
+++ b/index/obwahl_ks_2023/ks-stadtteile.csv
@@ -0,0 +1,24 @@
+nr,name,lon,lat
+1,Mitte,9.4894928677,51.3151211858
+2,Südstadt,9.4882009490,51.3005087449
+3,Vorderer Westen,9.4640787357,51.3163544480
+4,Wehlheiden,9.4654088136,51.3056032748
+5,Bad Wilhelmshöhe,9.4011506791,51.3116605680
+6,Brasselsberg,9.3972256665,51.2912775844
+7,Süsterfeld-Helleböhn,9.4469292484,51.2964342813
+8,Harleshausen,9.4151173612,51.3391059157
+9,Kirchditmold,9.4391198473,51.3249890808
+10,Rothenditmold,9.4771802265,51.3287075207
+11,Nord-Holland,9.4937754140,51.3323742823
+12,Philippinenhof-Warteberg,9.4941950806,51.3457423064
+13,Fasanenhof,9.5123139521,51.3347662999
+14,Wesertor,9.5148784085,51.3221887268
+15,Wolfsanger-Hasenhecke,9.5457989433,51.3396806416
+16,Bettenhausen,9.5384097499,51.3044330002
+17,Forstfeld,9.5418612035,51.2915878853
+18,Waldau,9.5100487565,51.2868080527
+19,Niederzwehren,9.4709628876,51.2783155707
+20,Oberzwehren,9.4443563894,51.2747255123
+21,Nordshausen,9.4245341169,51.2790202220
+22,Jungfernkopf,9.4607713668,51.3409314760
+23,Unterneustadt,9.5160276635,51.3119155114
diff --git a/index/obwahl_ks_2023/wahlbezirke.xlsx b/index/obwahl_ks_2023/wahlbezirke.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..83f1863a5b2b328129005c5cc33f5690644392c6
GIT binary patch
literal 10250
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
R4h|jqdInu#+#bTv{{yp(uV?@O

literal 0
HcmV?d00001

diff --git a/index/stadtteile.csv b/index/stadtteile.csv
deleted file mode 100644
index 66dba9e..0000000
--- a/index/stadtteile.csv
+++ /dev/null
@@ -1,45 +0,0 @@
-nr,name,lon,lat,wahlberechtigt_2018,waehler_2018,gueltig_2018,feldmann_2018
-1,Altstadt,8.682385346400634,50.11059669873516,2772,1035,1030,427
-2,Innenstadt,8.682664888207869,50.113790989177375,4343,1074,1066,435
-4,Westend-Süd,8.6594393465439925,50.11682467903111,13376,5835,5814,1814
-5,Westend-Nord,8.666488952498467,50.128769620533795,6887,2761,2751,1064
-6,Nordend-West,8.684596043386934,50.13022858244322,22988,10779,10743,4368
-7,Nordend-Ost,8.69761974358955,50.127318654892264,17390,7852,7817,3578
-8,Ostend,8.719218276147204,50.11554639352114,20946,8411,8358,3873
-9,Bornheim,8.712407551447747,50.13090801018288,22232,9682,9633,4954
-10,Gutleut- und Bahnhofsviertel,8.652137942960298,50.099479845695974,6802,2113,2103,831
-11,Gallus,8.636377745265355,50.10300477630784,23958,6529,6488,2926
-12,Bockenheim,8.632922516089874,50.12128753657858,27090,10377,10332,4546
-13,Sachsenhausen-Nord,8.684579993638577,50.10051804775371,23441,9940,9897,4128
-14,Sachsenhausen-Süd und Flughafen,8.629663957345496,50.0607635296547,20710,8739,8700,3702
-16,Oberrad,8.727138168461476,50.09922899999497,9206,3109,3093,1491
-17,Niederrad,8.636199605275262,50.081631798202295,16799,5412,5366,2712
-18,Schwanheim,8.572652070944704,50.081760019105545,13936,5261,5217,2524
-19,Griesheim,8.600109548586545,50.09781734654457,14910,3670,3632,1921
-20,Rödelheim,8.603076601631098,50.127692431637506,12446,4518,4487,2108
-21,Hausen,8.626134516198546,50.13524298077671,4518,1814,1796,889
-22,Praunheim,8.61444644716483,50.14547905112678,11093,4365,4337,2109
-24,Heddernheim,8.64020132453368,50.158128239125205,11867,4579,4546,2385
-25,Niederursel,8.616911198776547,50.16683966510584,10458,3843,3818,1797
-26,Ginnheim,8.648134546192246,50.14388748058928,11185,4310,4285,2101
-27,Dornbusch,8.670541998003081,50.14434313041997,13527,6140,6102,2671
-28,Eschersheim,8.659950213724542,50.16002839395001,11166,5005,4982,2159
-29,Eckenheim,8.683795236784233,50.148564823086005,9638,3457,3425,1764
-30,Preungesheim,8.697198159142667,50.15544843144313,10222,3983,3965,2025
-31,Bonames,8.665887880254154,50.18258113675648,4467,1566,1549,862
-32,Berkersheim,8.702941786636124,50.17015956481773,2607,1137,1129,515
-33,Riederwald,8.73274589886058,50.12667040584185,3209,1070,1063,669
-34,Seckbach,8.726644066440096,50.147246840458955,7419,2926,2909,1381
-35,Fechenheim,8.762275115113775,50.12551773891441,10381,2571,2551,1475
-36,Höchst,8.539657322936813,50.098523172532,9859,2527,2506,1251
-37,Nied,8.57676379509509,50.103479453362766,12659,3730,3708,1975
-38,Sindlingen,8.51273746688725,50.07800492013246,5763,1768,1748,1011
-39,Zeilsheim,8.495768400332896,50.097784690964886,8012,2328,2299,1199
-40,Unterliederbach,8.525490184772172,50.10992510336304,10485,3201,3176,1533
-41,Sossenheim,8.574019745075416,50.12010605434964,10126,2684,2652,1270
-42,Nieder-Erlenbach,8.709219115856458,50.20871646737095,3544,1808,1796,665
-43,Kalbach-Riedberg,8.639008245521376,50.1846309062546,12409,5359,5333,2349
-44,Harheim,8.689844680792895,50.18583895746724,3548,1846,1836,601
-45,Nieder-Eschbach,8.668094243977997,50.20106152063871,8041,2979,2950,1204
-46,Bergen-Enkheim,8.766772308170399,50.15913246211432,13431,5774,5742,2583
-47,Frankfurter Berg,8.673427563622,50.169778678198924,5409,2049,2030,978
diff --git a/sitemap.md b/sitemap.md
new file mode 100644
index 0000000..316479b
--- /dev/null
+++ b/sitemap.md
@@ -0,0 +1,41 @@
+# Was ist wo? Die Sitemap für das Projekt
+
+## Programme
+...liegen im Ordner "R":
+
+* **main.R**: Hauptprogramm, das alle Funktionen aufruft: Lies die Konfigurationsdateien ein. Schaue nach neuen Daten, lade sie herunter, verarbeite sie, gibt sie aus, versendet Teams-Messages, loggt jede Aktion in der Datei "obwahl.log"" mit. Die anderen R-Dateien sind über die "source()"-Funktion eingebunden - als Includes, gewissermaßen.
+  * **lies_konfiguration.R ** liest die Konfiguration für das Programm (Start der Wahl, Daten über Wahlberechtigte, Datawrapper-IDs) und liest die Index-Dateien ein: Kandidaten, Stadtteile, Wahllokale, Kommunalwahl-Ergebnisse. 
+  * **lies_aktuellen_stand.R**: Funktionen, um auf neue Daten zu überprüfen, sie herunterzuladen und zu archivieren. 
+  * **aktualisiere_karten.R**: Funktionen, um die Datawrapper-Karten zu aktualisieren. 
+  * **messaging.R**: Funktionen, die über Teams Status- und Fehlermeldungen ausgeben. 
+* **its_alive.R**: Programm, das über einen CRON-Job alle zwei Minuten aufgerufen wird - und nachschaut, wann das letzte mal etwas in die Logdatei "obwahl.log" geschrieben wurde. Wenn das länger als zwei Minuten her ist, ist das Skript vermutlich stehen geblieben - und das Skript versendet einen Alarm über Teams. 
+  
+Im Ordner "R" gibt es einen Unterordner "Vorbereitung", der diese Skripte enthält:
+
+* generiere_testdaten.R
+* teste-curl-polling.R - Testskript, um veränderte Daten über einen CURL-Aufruf so schnell wie möglich zu erkennen (sekundengenau)
+* prepare.R - generiert die Datawrapper-Skripte und -karten und Indexdaten. 
+
+## Wo man welche Funktionen findet (und was sie tun)
+### lies_aktuellen_stand.R
+
+- archiviere(dir) - Hilfsfunktion, schreibt geholte Stimmbezirks-Daten auf die Festplatte
+- hole_letztes_df(dir) - Hilfsfunktion, holt die zuletzt geschriebene Stimmbezirks-Datei aus dem Verzeichnis zurück  
+- check_for_timestamp(url) - liest das Änderungsdatum der Datei unter der URL aus
+- lies_stimmbezirke(url) - liest aus der Datei unter der URL die Stimmbezirke
+- aggregiere_stadtteildaten(stimmbezirke_df) - aggregiert auf Ortsteil-Ebene
+- berechne_führende
+
+
+
+### aktualisiere_karten.R
+## Wie das Programm arbeitet
+
+```main.R``` wird einmal aufgerufen und arbeitet dann, bis die Wahl vorbei ist
+oder ein Fehler auftritt: 
+
+- Lies zunächst die Konfigurationsdatei ein und hole Index-Dateien
+- Starte eine Schleife, solange nicht alle Stimmbezirke ausgezählt sind
+  - Checke, ob sich der Zeitstempel der Daten verändert hat (```check_timestamp()```)
+  - Lies sie ein (```lies_gebiet()```)
+
-- 
GitLab