DiagnostikApps/IIP-C/app.R
2026-09-22 18:35:43 +02:00

831 lines
31 KiB
R
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Präambel ####
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_iipc.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
IIPC_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
)
IIPC_MISSING_WARNUNG = paste0(
"Achtung: Aufgrund der hohen Anzahl an nicht beantworteten Items empfielt das Manual ",
"auf die Auswertung des Tests zu verzichten."
)
IIPC_ANOMALIE_FUSSNOTE = paste0(
"* Diese Normtabellenspalte enthält an dieser Stelle einen von den Nachbarwerten stark ",
"abweichenden Grenzwert (vermutlich Übertragungsfehler im Originalmaterial). Der Wert wird ",
"unverändert aus der Quelle übernommen; die Stanine-Einstufung dieser Skala sollte bei der ",
"Interpretation mit Vorsicht behandelt werden."
)
# Anker: formr-Antwortstufen "nicht/wenig/mittelmäßig/ziemlich/sehr" -> 0-4.
IIPC_ANKER_KODIERUNG = c("nicht" = 0L, "wenig" = 1L, "mittelmäßig" = 2L, "ziemlich" = 3L, "sehr" = 4L)
IIPC_SKALEN_ITEMS = list(
PA = c(17, 31, 44, 45, 50, 52, 57, 59),
BC = c(1, 22, 24, 29, 32, 40, 56, 64),
DE = c(11, 15, 16, 20, 23, 27, 36, 60),
FG = c(3, 7, 14, 18, 33, 35, 55, 62),
HI = c(5, 6, 8, 9, 12, 13, 19, 39),
JK = c(2, 10, 25, 34, 38, 42, 53, 61),
LM = c(21, 28, 37, 46, 49, 51, 54, 63),
NO = c(4, 26, 30, 41, 43, 47, 48, 58)
)
IIPC_SKALEN_NAMEN = c(
PA = "autokratisch/dominant",
BC = "streitsüchtig/konkurrierend",
DE = "abweisend/kalt",
FG = "introvertiert/sozial vermeidend",
HI = "selbstunsicher/unterwürfig",
JK = "ausnutzbar/nachgiebig",
LM = "fürsorglich/freundlich",
NO = "expressiv/aufdringlich"
)
# Klassische IIP-Circumplex-Anordnung, im Uhrzeigersinn ab 12 Uhr.
IIPC_SKALEN_REIHENFOLGE = c("PA", "BC", "DE", "FG", "HI", "JK", "LM", "NO")
# Zwei dokumentierte Anomalien in den Quell-Normtabellen (siehe Datenreferenz):
# unverändert übernommen, aber markiert.
IIPC_ANOMALIE_REGELN = list(
list(skala = "NO", spalte = "maenner_25_35"),
list(skala = "JK", spalte = "frauen_35_50")
)
IIPC_STANINE_FARBEN = c(niedrig = "#4CAF50", mittel = "#9E9E9E", hoch = "#B71C1C")
# Infrastruktur ####
APP_VERZEICHNIS = normalizePath(getwd())
absPath = function(pfad) {
if (grepl("^([A-Za-z]:[/\\\\]|/)", pfad)) return(pfad)
file.path(APP_VERZEICHNIS, pfad)
}
PFAD_DOWNLOAD_SKRIPT = normalizePath(absPath(PFAD_DOWNLOAD_SKRIPT), mustWork = FALSE)
PFAD_PSEUDONYM_SKRIPT = normalizePath(absPath(PFAD_PSEUDONYM_SKRIPT), mustWork = FALSE)
PFAD_NORMTABELLEN_ORDNER = normalizePath(absPath("normtabellen"), mustWork = FALSE)
# Helper ####
iipc_bereinige_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
t = as.character(text[1])
t = gsub("\\*\\*", "", t)
# Entfernt Markdown-Escapes vor Satzzeichen (z.B. "17\." -> "17."), bekannte formr-Exportfalle.
t = gsub("\\\\([[:punct:]])", "\\1", t, perl = TRUE)
trimws(t)
}
# Itemstufe (0-4) AUSSCHLIESSLICH ueber das labels-Attribut der ORIGINAL-Spalte ableiten
# (Textabgleich gegen den Anker-Vektor), niemals ueber den rohen haven-Zahlenwert minus 1 -
# die formr-interne Kodierung ist nicht garantiert stabil.
iipc_get_level = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_integer_)
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) return(NA_integer_)
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) == 0) return(NA_integer_)
label_text = iipc_bereinige_label(names(lbl_attr)[pos[1]])
if (is.na(label_text) || !label_text %in% names(IIPC_ANKER_KODIERUNG)) return(NA_integer_)
as.integer(IIPC_ANKER_KODIERUNG[[label_text]])
}
# Ankertext bevorzugt aus dem labels-Attribut (Original-Antworttext), sonst aus der
# festen Anker-Kodierung anhand der bereits abgeleiteten Stufe.
iipc_get_anker = function(original_col, wert, stufe) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) > 0) return(iipc_bereinige_label(names(lbl_attr)[pos[1]]))
}
if (!is.na(stufe) && stufe >= 0L && stufe <= 4L) return(names(IIPC_ANKER_KODIERUNG)[stufe + 1L])
NA_character_
}
# Entfernt formr-Nummerierungsartefakte am Anfang des Itemtexts (z.B. "17. ").
iipc_item_text = function(daten, var) {
txt = iipc_bereinige_label(attr(daten[[var]], "label", exact = TRUE))
if (is.na(txt) || nchar(txt) == 0) {
return(paste0("Item ", suppressWarnings(as.integer(sub("^iipc_", "", var)))))
}
sub("^\\d+[.)]\\s*", "", txt)
}
# Geschlecht ausschliesslich ueber das labels-Attribut auslesen (Text "männlich"/"weiblich"
# abgleichen), NICHT ueber hartkodierte Zahlenwerte 1/2.
iipc_get_geschlecht = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) return(NA_character_)
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) == 0) return(NA_character_)
txt = trimws(names(lbl_attr)[pos[1]])
if (txt == "männlich") return("maenner")
if (txt == "weiblich") return("frauen")
NA_character_
}
iipc_alterskategorie = function(alter) {
if (is.null(alter) || length(alter) == 0 || is.na(alter)) return(NA_character_)
if (alter < 25) return("17_25")
if (alter < 35) return("25_35")
if (alter < 50) return("35_50")
"ab50"
}
iipc_norm_spalte = function(geschlecht_key, alterskategorie) {
if (is.na(geschlecht_key) || is.na(alterskategorie)) return(NA_character_)
paste0(geschlecht_key, "_", alterskategorie)
}
# Stanine = kleinste Stufe n (1-9), fuer die gilt Wert < stanine_n; sonst 9.
# Sucht die stanine_1..stanine_9-Zeilen ueber die 'zeile'-Spalte (robust gegen Zeilenreihenfolge).
iipc_stanine = function(tabelle, spalte, wert) {
stanine_zeilen = tabelle[grepl("^stanine_", tabelle$zeile), ]
stanine_zeilen = stanine_zeilen[order(as.integer(sub("stanine_", "", stanine_zeilen$zeile))), ]
grenzwerte = stanine_zeilen[[spalte]]
treffer = which(wert < grenzwerte)
if (length(treffer) > 0) return(as.integer(min(treffer)))
9L
}
iipc_stanine_kategorie = function(stanine) {
if (is.null(stanine) || is.na(stanine)) return("mittel")
if (stanine <= 3) return("niedrig")
if (stanine <= 6) return("mittel")
"hoch"
}
iipc_ist_anomalie = function(skala, spalte) {
if (is.na(spalte)) return(FALSE)
any(vapply(IIPC_ANOMALIE_REGELN, function(r) r$skala == skala && r$spalte == spalte, logical(1)))
}
# Circumplex-Kreisdiagramm: 8 Achsen radial (45° Abstand), im Uhrzeigersinn ab 12 Uhr,
# Radialachse = Oktanten-Rohwert (0-32). Manuelle Trigonometrie statt coord_polar(),
# da coord_polar() bei kategorialen Achsen nicht zuverlaessig exakt bei 12 Uhr beginnt.
make_circumplex_plot = function(oktanten_df) {
n = nrow(oktanten_df)
winkel = pi / 2 - (seq_len(n) - 1) * (2 * pi / n)
punkte = data.frame(
skala = oktanten_df$skala,
r = oktanten_df$rohwert,
kategorie = oktanten_df$kategorie,
stringsAsFactors = FALSE
)
punkte$x = punkte$r * cos(winkel)
punkte$y = punkte$r * sin(winkel)
polygon_df = rbind(punkte, punkte[1, ])
ring_werte = c(0, 8, 16, 24, 32)
ring_df = do.call(rbind, lapply(ring_werte, function(rw) {
d = data.frame(x = rw * cos(winkel), y = rw * sin(winkel), ring = rw)
rbind(d, d[1, ])
}))
achsen_df = data.frame(x0 = 0, y0 = 0, x1 = 32 * cos(winkel), y1 = 32 * sin(winkel))
label_r = 32 + 7
label_df = data.frame(
x = label_r * cos(winkel),
y = label_r * sin(winkel),
label = paste0(
oktanten_df$skala, ": ", round(oktanten_df$rohwert, 1),
" (Stanine ", oktanten_df$stanine, ifelse(oktanten_df$anomalie, "*", ""), ")"
)
)
ggplot() +
geom_path(data = ring_df, aes(x = x, y = y, group = ring), color = "#DDDDDD") +
geom_segment(data = achsen_df, aes(x = x0, y = y0, xend = x1, yend = y1), color = "#DDDDDD") +
geom_polygon(data = polygon_df, aes(x = x, y = y),
fill = AKZENT_FARBE, alpha = 0.3, color = AKZENT_FARBE, linewidth = 1) +
geom_point(data = punkte, aes(x = x, y = y, color = kategorie), size = 3) +
geom_text(data = label_df, aes(x = x, y = y, label = label), size = 3.3, lineheight = 0.9) +
scale_color_manual(values = IIPC_STANINE_FARBEN, guide = "none") +
coord_fixed(xlim = c(-48, 48), ylim = c(-48, 48), clip = "off") +
theme_void()
}
# Datenaufbereitung ####
IIPC_NORMTABELLEN_DATEIEN = c(
PA = "iipc_pa.csv", BC = "iipc_bc.csv", DE = "iipc_de.csv", FG = "iipc_fg.csv",
HI = "iipc_hi.csv", JK = "iipc_jk.csv", LM = "iipc_lm.csv", NO = "iipc_no.csv",
IIPges = "iipc_iipges.csv"
)
if (!dir.exists(PFAD_NORMTABELLEN_ORDNER)) {
stop(sprintf(
"Normtabellen-Ordner nicht gefunden. Erwarteter Pfad: '%s'. Ohne diese 9 CSV-Dateien ist keine Stanine-Bestimmung möglich.",
PFAD_NORMTABELLEN_ORDNER
))
}
IIPC_NORMTABELLEN = list()
for (schluessel in names(IIPC_NORMTABELLEN_DATEIEN)) {
dateipfad = file.path(PFAD_NORMTABELLEN_ORDNER, IIPC_NORMTABELLEN_DATEIEN[[schluessel]])
if (!file.exists(dateipfad)) {
stop(sprintf(
"Normtabelle fehlt: '%s'. Erwarteter Pfad: '%s'.",
IIPC_NORMTABELLEN_DATEIEN[[schluessel]], dateipfad
))
}
IIPC_NORMTABELLEN[[schluessel]] = read.csv(dateipfad, stringsAsFactors = FALSE)
}
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.app-header {
background: #8B2635; color: white; padding: 18px 24px 14px;
margin-bottom: 20px; border-radius: 0 0 6px 6px;
}
.app-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.app-header p { margin: 4px 0 0; opacity: 0.85; font-size: 0.9rem; }
.input-panel {
background: white; border-radius: 6px; padding: 16px 20px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
}
.input-panel .form-group { margin-bottom: 0; }
.input-panel label { font-weight: 600; color: #333; }
.btn-laden {
background: #8B2635 !important; color: white !important;
border: none !important; border-radius: 4px !important;
padding: 8px 20px !important; font-weight: 600 !important; cursor: pointer;
}
.btn-laden:hover { background: #6d1e29 !important; }
#download_word {
background: #8B2635; color: white; border: none;
font-weight: 600; padding: 8px 20px; border-radius: 4px;
}
#download_word:hover { background: #6d1e29; color: white; }
.alert-fehler {
background: #FFEBEE; border-left: 5px solid #C62828;
padding: 12px 16px; border-radius: 4px; color: #B71C1C;
margin-bottom: 12px; font-weight: 500;
}
.alert-warnung {
background: #FFF3E0; border-left: 5px solid #E65100;
padding: 10px 16px; border-radius: 4px; color: #BF360C;
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.tabelle-skalen { width: 100%; border-collapse: collapse; margin-top: 4px; }
.tabelle-skalen th, .tabelle-skalen td {
padding: 7px 10px; border-bottom: 1px solid #eee; text-align: left; font-size: 0.93em;
}
.tabelle-skalen th { background: #8B2635; color: white; font-weight: 600; }
.stanine-badge {
border-radius: 12px; padding: 2px 12px; font-weight: 700;
font-size: 0.85em; white-space: nowrap; display: inline-block;
}
.stanine-badge-niedrig { background: #4CAF50; color: white; }
.stanine-badge-mittel { background: #9E9E9E; color: white; }
.stanine-badge-hoch { background: #B71C1C; color: white; }
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 6px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 30px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.stufe-badge {
border-radius: 4px; padding: 2px 9px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
}
.stufe-badge-0 { background: #4CAF50; color: white; }
.stufe-badge-1 { background: #F48FB1; color: #333333; }
.stufe-badge-2 { background: #EF5350; color: white; }
.stufe-badge-3 { background: #B71C1C; color: white; }
.stufe-badge-4 { background: #4A0000; color: white; }
.stufe-badge-fehlend { background: #E0E0E0; color: #444444; }
details summary {
cursor: pointer; font-weight: 600; color: #8B2635; padding: 6px 0;
}
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("IIP-C Inventar zur Erfassung Interpersonaler Probleme (Kurzform)"),
tags$p("Auswertung nach Oktantenskalen (PANO) und Gesamtwert (IIPges), ipsativ, mit Stanine-Normierung")
),
div(class = "container-fluid",
div(class = "input-panel",
div(style = "min-width: 360px; white-space: nowrap;",
textInput("pseudonym",
label = tagList(
"Pseudonym",
tags$span(style = "font-weight: normal; font-style: italic; font-size: 0.78em; color: #888; margin-left: 4px; white-space: nowrap;",
"optional, hat Vorrang vor Chiffre")
),
placeholder = "optional", width = "340px")
),
div(style = "min-width: 200px;",
textInput("chiffre", label = "Patientenchiffre",
placeholder = "z.B. P000123", width = "100%")
),
actionButton("btn_suchen", "Auswerten", class = "btn btn-primary btn-laden"),
div(style = "margin-left: auto;",
downloadButton("download_word", "Word-Export (.docx)")
)
),
uiOutput("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_iipc_docx = function(erg) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_abschnitt = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 13)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_normal = fp_text(font.size = 11)
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_warnung = fp_text(font.size = 10, italic = TRUE, color = "#BF360C")
geschlecht_anzeige = if (erg$geschlecht_key == "maenner") "männlich" else "weiblich"
doc = body_add_fpar(doc, fpar(ftext("IIP-C Auswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label), ftext(erg$datum_str, fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("Geschlecht: ", fp_label), ftext(geschlecht_anzeige, fp_normal),
ftext(" Alter: ", fp_label), ftext(as.character(erg$alter), fp_normal)
))
for (w in erg$warnungen) {
doc = body_add_fpar(doc, fpar(ftext(w, fp_warnung)))
}
doc = body_add_par(doc, "", style = "Normal")
bild_pfad = tempfile(fileext = ".png")
ggsave(bild_pfad, plot = make_circumplex_plot(erg$oktanten_df),
width = 6.5, height = 6.5, dpi = 150, bg = "white")
doc = body_add_img(doc, src = bild_pfad, width = 5.5, height = 5.5)
doc = body_add_fpar(doc, fpar(ftext("IIPges", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext(paste0(round(erg$iipges, 2), " / 32 Stanine: ", erg$iipges_stanine),
fp_text(bold = TRUE, font.size = 12, color = AKZENT_FARBE))
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Skalenübersicht", fp_abschnitt)))
for (i in seq_len(nrow(erg$oktanten_df))) {
z = erg$oktanten_df[i, ]
stanine_txt = paste0(z$stanine, if (z$anomalie) "*" else "")
doc = body_add_fpar(doc, fpar(
ftext(paste0(z$skala, " (", z$bezeichnung, "): "), fp_label),
ftext(paste0("Rohwert ", round(z$rohwert, 2), ", ipsativ ", round(z$ipsativ, 2),
", Stanine ", stanine_txt), fp_normal)
))
}
if (erg$anomalie_aktiv) {
doc = body_add_fpar(doc, fpar(ftext(IIPC_ANOMALIE_FUSSNOTE,
fp_text(font.size = 9, italic = TRUE, color = "#666666"))))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(IIPC_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick auf "Auswerten".
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", chiffre = chiffre, meldung = paste0(
"Ungültige Chiffre '", chiffre, "'. Erwartetes Format: ein Großbuchstabe gefolgt von 6 Ziffern (z. B. P000123)."
)))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "pfad_fehler",
meldung = paste0("Download-Skript nicht gefunden unter:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "pfad_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden unter:\n", PFAD_PSEUDONYM_SKRIPT)))
}
ok = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok$ok) return(list(typ = "skript_fehler", meldung = paste0("Fehler im Download-Skript: ", ok$msg)))
db_ordner = local({
ordner = dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
gefunden = NULL
for (i in 1:5) {
if (file.exists(file.path(ordner, "pseudonyme.db"))) { gefunden = ordner; break }
elternteil = dirname(ordner)
if (elternteil == ordner) break
ordner = elternteil
}
gefunden
})
alter_wd = getwd()
wd_ziel = if (!is.null(db_ordner)) db_ordner else
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
on.exit(setwd(alter_wd), add = TRUE)
setwd(wd_ziel)
ok2 = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok2$ok) return(list(typ = "skript_fehler", meldung = paste0("Fehler im Pseudonym-Skript: ", ok2$msg)))
if (!exists("daten_iipc", envir = .GlobalEnv)) {
return(list(typ = "objekt_fehler",
meldung = "Objekt 'daten_iipc' wurde nach dem Sourcen des Download-Skripts nicht gefunden."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "objekt_fehler",
meldung = "Objekt 'pseudo' wurde nach dem Sourcen des Pseudonym-Skripts nicht gefunden."))
}
daten = get("daten_iipc", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
treffer_pseudo = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_pseudo) == 0) {
return(list(typ = "chiffre_unbekannt",
meldung = paste0("Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
}
alle_session_ids = unique(treffer_pseudo$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_daten = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_daten) == 0) {
return(list(typ = "kein_datensatz", meldung = paste0(
"Kein IIP-C-Datensatz für Chiffre '", chiffre, "' gefunden. (",
length(alle_session_ids), " Pseudonym(e) geprüft)"
)))
}
warnungen = c()
if (nrow(treffer_daten) > 1) {
zeitspalte = if ("created" %in% colnames(treffer_daten)) "created"
else if ("ended" %in% colnames(treffer_daten)) "ended"
else NA_character_
if (!is.na(zeitspalte)) {
treffer_daten = treffer_daten[order(treffer_daten[[zeitspalte]], decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_daten[[zeitspalte]][1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", nrow(treffer_daten), " Einträge). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
))
} else {
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", nrow(treffer_daten), " Einträge), keine Zeitstempelspalte ",
"zur Auswahl der neuesten gefunden. Der erste Treffer wird angezeigt."
))
}
treffer_daten = treffer_daten[1, , drop = FALSE]
}
zeile = treffer_daten[1, , drop = FALSE]
# Ausfuelldatum defensiv: 'created' -> 'ended' -> Sys.Date() mit sichtbarer Warnung.
ausfuelldatum = NULL
if ("created" %in% colnames(zeile) && !is.na(zeile[["created"]][1])) {
ausfuelldatum = tryCatch(as.POSIXct(zeile[["created"]][1]), error = function(e) NULL)
}
if (is.null(ausfuelldatum) && "ended" %in% colnames(zeile) && !is.na(zeile[["ended"]][1])) {
ausfuelldatum = tryCatch(as.POSIXct(zeile[["ended"]][1]), error = function(e) NULL)
}
if (is.null(ausfuelldatum)) {
ausfuelldatum = Sys.time()
warnungen = c(warnungen, paste0(
"Ausfülldatum konnte nicht aus den Exportdaten ermittelt werden ('created'/'ended' fehlen ",
"oder sind leer) - Downloaddatum wird stattdessen verwendet."
))
}
datum_str = format(as.POSIXct(ausfuelldatum), "%d.%m.%Y")
geschlecht_key = iipc_get_geschlecht(daten[["iipc_geschlecht"]], zeile[["iipc_geschlecht"]])
alter = suppressWarnings(as.numeric(zeile[["iipc_alter"]][1]))
if (is.na(geschlecht_key)) {
return(list(typ = "daten_fehler", meldung = paste0(
"Geschlecht konnte nicht aus 'iipc_geschlecht' ermittelt werden (labels-Attribut fehlt oder ",
"enthält keinen der erwarteten Werte 'männlich'/'weiblich')."
)))
}
if (is.na(alter)) {
return(list(typ = "daten_fehler", meldung = "Alter konnte nicht aus 'iipc_alter' ermittelt werden."))
}
alterskategorie = iipc_alterskategorie(alter)
norm_spalte = iipc_norm_spalte(geschlecht_key, alterskategorie)
item_vars = paste0("iipc_", sprintf("%02d", 1:64))
item_werte = sapply(item_vars, function(v) iipc_get_level(daten[[v]], zeile[[v]]))
names(item_werte) = as.character(1:64)
n_fehlend = sum(is.na(item_werte))
if (n_fehlend >= 4) warnungen = c(IIPC_MISSING_WARNUNG, warnungen)
oktanten_rohwerte = sapply(IIPC_SKALEN_REIHENFOLGE, function(sk) {
sum(item_werte[as.character(IIPC_SKALEN_ITEMS[[sk]])], na.rm = TRUE)
})
iipges = sum(oktanten_rohwerte) / 8
oktanten_df = data.frame(
skala = IIPC_SKALEN_REIHENFOLGE,
bezeichnung = unname(IIPC_SKALEN_NAMEN[IIPC_SKALEN_REIHENFOLGE]),
rohwert = unname(oktanten_rohwerte),
stringsAsFactors = FALSE
)
oktanten_df$ipsativ = oktanten_df$rohwert - iipges
oktanten_df$stanine = vapply(seq_len(nrow(oktanten_df)), function(i) {
tabelle = IIPC_NORMTABELLEN[[oktanten_df$skala[i]]]
iipc_stanine(tabelle, norm_spalte, oktanten_df$ipsativ[i])
}, integer(1))
oktanten_df$anomalie = vapply(seq_len(nrow(oktanten_df)), function(i) {
iipc_ist_anomalie(oktanten_df$skala[i], norm_spalte)
}, logical(1))
oktanten_df$kategorie = vapply(oktanten_df$stanine, iipc_stanine_kategorie, character(1))
iipges_tabelle = IIPC_NORMTABELLEN[["IIPges"]]
iipges_stanine = iipc_stanine(iipges_tabelle, norm_spalte, iipges)
iipges_kategorie = iipc_stanine_kategorie(iipges_stanine)
anomalie_aktiv = any(oktanten_df$anomalie)
item_info = data.frame(
item_nr = 1:64,
var = item_vars,
stringsAsFactors = FALSE
)
item_info$skala = vapply(item_info$item_nr, function(i) {
treffer = names(IIPC_SKALEN_ITEMS)[vapply(IIPC_SKALEN_ITEMS, function(v) i %in% v, logical(1))]
if (length(treffer) == 0) NA_character_ else treffer[1]
}, character(1))
item_info$text = vapply(item_info$var, function(v) iipc_item_text(daten, v), character(1))
item_info$wert = unname(item_werte[as.character(item_info$item_nr)])
item_info$anker = vapply(seq_len(nrow(item_info)), function(i) {
iipc_get_anker(daten[[item_info$var[i]]], zeile[[item_info$var[i]]], item_info$wert[i])
}, character(1))
list(
typ = "ok",
chiffre = chiffre,
datum_str = datum_str,
warnungen = warnungen,
geschlecht_key = geschlecht_key,
alter = alter,
norm_spalte = norm_spalte,
item_werte = item_werte,
n_fehlend = n_fehlend,
oktanten_df = oktanten_df,
iipges = iipges,
iipges_stanine = iipges_stanine,
iipges_kategorie = iipges_kategorie,
anomalie_aktiv = anomalie_aktiv,
item_info = item_info
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") div(class = "alert-fehler", d$meldung) else NULL
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok" || length(d$warnungen) == 0) return(NULL)
tagList(lapply(d$warnungen, function(w) div(class = "alert-warnung", w)))
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") return(NULL)
geschlecht_anzeige = if (d$geschlecht_key == "maenner") "männlich" else "weiblich"
tabelle_zeilen = lapply(seq_len(nrow(d$oktanten_df)), function(i) {
z = d$oktanten_df[i, ]
tags$tr(
tags$td(z$skala),
tags$td(z$bezeichnung),
tags$td(round(z$rohwert, 2)),
tags$td(round(z$ipsativ, 2)),
tags$td(
span(class = paste0("stanine-badge stanine-badge-", z$kategorie),
paste0(z$stanine, if (z$anomalie) "*" else ""))
)
)
})
tabelle_zeilen = c(tabelle_zeilen, list(
tags$tr(
tags$td(tags$strong("IIPges")),
tags$td(""),
tags$td(round(d$iipges, 2)),
tags$td(""),
tags$td(
span(class = paste0("stanine-badge stanine-badge-", d$iipges_kategorie), d$iipges_stanine)
)
)
))
items_sektionen = lapply(IIPC_SKALEN_REIHENFOLGE, function(sk) {
items_sk = d$item_info[d$item_info$skala == sk, ]
tags$details(
tags$summary(paste0(sk, " ", IIPC_SKALEN_NAMEN[[sk]])),
lapply(seq_len(nrow(items_sk)), function(i) {
it = items_sk[i, ]
sk_wert = if (!is.na(it$wert) && it$wert >= 0 && it$wert <= 4) as.character(it$wert) else "fehlend"
badge_txt = if (sk_wert == "fehlend") "fehlend" else paste0(it$wert, " ", it$anker)
div(class = "item-zeile",
div(class = "item-nr", paste0(it$item_nr, ".")),
div(class = "item-text", it$text),
span(class = paste0("stufe-badge stufe-badge-", sk_wert), badge_txt)
)
})
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "IIP-C Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$datum_str,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Geschlecht: "), geschlecht_anzeige,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Alter: "), d$alter
),
tags$hr(),
fluidRow(
column(3,
div(class = "score-zahl", round(d$iipges, 2)),
div("IIPges (032)", style = "color:#555;"),
div(style = "margin-top: 6px;",
span(class = paste0("stanine-badge stanine-badge-", d$iipges_kategorie),
paste0("Stanine ", d$iipges_stanine))
)
),
column(9, plotOutput("circumplex_plot", height = "460px"))
),
tags$hr(),
div(class = "abschnitt-titel", "Skalenübersicht"),
tags$table(class = "tabelle-skalen",
tags$thead(tags$tr(
tags$th("Kürzel"), tags$th("Bezeichnung"), tags$th("Rohwert"),
tags$th("Ipsativer Wert"), tags$th("Stanine")
)),
tags$tbody(tabelle_zeilen)
),
if (d$anomalie_aktiv) div(class = "alert-warnung", style = "margin-top: 12px;", IIPC_ANOMALIE_FUSSNOTE),
tags$hr(),
div(class = "abschnitt-titel", "Einzelitems nach Skala"),
items_sektionen
)
})
output$circumplex_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(identical(d$typ, "ok"))
make_circumplex_plot(d$oktanten_df)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && identical(d$typ, "ok")) gsub("[^A-Za-z0-9]", "", d$chiffre) else "export"
datum = if (is.list(d) && identical(d$typ, "ok") && !is.null(d$datum_str))
tryCatch(format(as.Date(d$datum_str, "%d.%m.%Y"), "%Y%m%d"), error = function(e) format(Sys.Date(), "%Y%m%d"))
else format(Sys.Date(), "%Y%m%d")
paste0("IIPC_", chiffre_esc, "_", datum, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && identical(d$typ, "ok")
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre oder Pseudonym eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_iipc_docx(d),
error = function(e) {
err_doc = read_docx()
body_add_par(err_doc,
paste0("Fehler beim Erstellen des Word-Dokuments: ", e$message),
style = "Normal")
}
)
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui, server)