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

760 lines
27 KiB
R
Raw Permalink 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)
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_vviq.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
# Deskriptive Kennwerte (M, SD) der Validierungsstichprobe (N=300) aus dem
# elektronischen Supplement zur deutschen Adaptationsstudie (Jungmann, Becker
# & Witthoeft). KEINE klinische Norm, KEIN Cutoff - siehe VVIQ_DISCLAIMER.
# Je Subskala 4 Items, Score = Mittelwert. Kein Gesamtscore ueber alle
# Subskalen, das ist in der Quelle nicht definiert.
VVIQ_SUBSKALEN = data.frame(
key = c("person", "sonne", "geschaeft", "landschaft"),
label = c("Person", "Sonne", "Geschäft", "Landschaft"),
item_start = c(1, 5, 9, 13),
item_ende = c(4, 8, 12, 16),
ref_m = c(3.75, 3.60, 3.41, 3.72),
ref_sd = c(0.71, 0.92, 0.80, 0.63),
stringsAsFactors = FALSE
)
VVIQ_VERGLEICHSHINWEIS = "Vergleichswert aus Validierungsstichprobe (N=300), keine klinische Norm"
VVIQ_DISCLAIMER = paste0(
"Diese Auswertung stellt die individuellen Antworten den Kennwerten einer wissenschaftlichen ",
"Validierungsstichprobe (N = 300) gegenueber. Es handelt sich nicht um eine klinische Norm und ",
"nicht um eine Klassifikation. Die Interpretation obliegt der behandelnden Person."
)
VVIQ_FORMAT_WARNUNG = paste0(
"Datenformat weicht von der Erwartung ab, Werte werden als Rohzahlen interpretiert."
)
# Badge-Farben angelehnt an die Ampelskala aus pg13r/app.R, aber mit
# umgekehrter Bedeutung: dort steht rot fuer hohe Symptomschwere (schlecht),
# hier stehen hohe VVIQ-Werte fuer lebhafte Vorstellungsbilder (gut) - daher
# 1 (kaum vorstellbar) rot/dunkelrot, 5 (absolut klar und lebhaft) gruen.
VVIQ_BADGE_FARBEN = c(
"1" = "#4A0000",
"2" = "#B71C1C",
"3" = "#EF5350",
"4" = "#F48FB1",
"5" = "#4CAF50"
)
VVIQ_BADGE_TEXT_FARBEN = c(
"1" = "white",
"2" = "white",
"3" = "white",
"4" = "#333333",
"5" = "white"
)
# 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)
# Helper ####
# Spaltenname in daten_vviq per Muster suchen (case-insensitive). Kein
# stiller Fallback: bei keinem Treffer wird ein klarer Fehler geworfen, da
# der tatsaechliche Spaltenname von daten_vviq nicht verifiziert ist.
vviq_finde_spalte = function(daten, muster, beschreibung) {
namen = names(daten)
treffer = namen[grepl(muster, namen, ignore.case = TRUE)]
if (length(treffer) == 0) {
stop(paste0(
"In daten_vviq wurde keine Spalte gefunden, die zu '", beschreibung,
"' passt (Suchmuster: '", muster, "'). Bitte Datenstruktur des ",
"Download-Skripts pruefen."
))
}
treffer[1]
}
# Entfernt formr-Markdown-Sternchen, escapte Satzzeichen (z.B. "1\. ") und
# Nummerierungsartefakte aus label-Texten.
clean_item_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
txt = gsub("\\*", "", as.character(text[1]))
txt = gsub("\\\\([.)])", "\\1", txt)
txt = trimws(txt)
txt = sub("^\\d+[.)]\\s*", "", txt)
txt = sub("^\\.\\s*", "", txt)
trimws(txt)
}
# Hintergrund-/Textfarbe fuer die Antwort-Badges je Itemwert (1-5).
vviq_badge_stil = function(wert) {
idx = if (is.na(wert)) NA_character_ else as.character(round(wert))
if (is.na(idx) || !(idx %in% names(VVIQ_BADGE_FARBEN))) {
return(list(bg = "#EEEEEE", text = "#555555"))
}
list(bg = unname(VVIQ_BADGE_FARBEN[[idx]]), text = unname(VVIQ_BADGE_TEXT_FARBEN[[idx]]))
}
# Balkenfarbe im Profil-Plot: KEIN Bezug auf den Rohwert, sondern auf die
# SD-Distanz zum Vergleichswert der Validierungsstichprobe (z = (Mittelwert -
# ref_m) / ref_sd), da VVIQ keine T-Werte/Norm hat. Rein deskriptive
# Positionierung, keine Klassifikation: neutral (hellgrau) bei z=0, Endfarben
# aus derselben Ampelskala wie die Item-Badges (VVIQ_BADGE_FARBEN "1"/"5"),
# ab |z| >= 2 SD gekappt.
vviq_farbverlauf_sd = grDevices::colorRampPalette(c(
unname(VVIQ_BADGE_FARBEN[["1"]]), "#F5F5F5", unname(VVIQ_BADGE_FARBEN[["5"]])
))
vviq_bar_farbe = function(mittelwert, ref_m, ref_sd) {
if (is.na(mittelwert) || is.na(ref_m) || is.na(ref_sd) || ref_sd == 0) return("#CCCCCC")
z = (mittelwert - ref_m) / ref_sd
z_clamp = min(2, max(-2, z))
idx = round((z_clamp + 2) / 4 * 100) + 1
vviq_farbverlauf_sd(101)[idx]
}
# Nicht verifiziert, ob formr fuer mc-Items tatsaechlich dbl+lbl exportiert.
# Defensiv: numerischer Wert kommt in beiden Faellen unveraendert aus der
# Spalte, das labels-Attribut wird nur fuer die Anzeige des Antworttexts
# benoetigt (siehe vviq_item_text).
vviq_item_wert = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_real_)
if (haven::is.labelled(original_col)) {
as.numeric(haven::zap_labels(wert[1]))
} else {
suppressWarnings(as.numeric(wert[1]))
}
}
# Liest den Antworttext (z.B. "absolut klar und lebhaft") aus dem
# labels-Attribut der Original-Spalte. Ohne is.labelled()/labels-Attribut
# gibt es keinen verifizierten Antworttext - dann NA, die Anzeige faellt in
# diesem Fall auf die Rohzahl zurueck (siehe VVIQ_FORMAT_WARNUNG).
vviq_item_text = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
if (haven::is.labelled(original_col)) {
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(names(lbl_attr)[pos[1]])
}
}
NA_character_
}
vviq_item_subskala_label = function(item_nr) {
zeile = VVIQ_SUBSKALEN[VVIQ_SUBSKALEN$item_start <= item_nr & VVIQ_SUBSKALEN$item_ende >= item_nr, ]
if (nrow(zeile) == 0) return(NA_character_)
zeile$label[1]
}
# Rein deskriptive Einordnung relativ zur Validierungsstichprobe, KEINE
# Klassifikation und KEIN klinischer Schwellenwert.
# Der VVIQ_VERGLEICHSHINWEIS-Text wird bewusst NICHT hier angehaengt, um die
# Wiederholung desselben Hinweises in jeder Subskalen-Karte zu vermeiden - er
# steht einmalig unterhalb der Karten (siehe .referenz-hinweis in der UI bzw.
# der Vergleichswert-Zeile im Word-Export).
vviq_einordnung = function(wert, ref_m, ref_sd) {
if (is.na(wert)) return("nicht auswertbar (fehlender Wert)")
diff = wert - ref_m
if (abs(diff) <= ref_sd) return("im Bereich M ± 1 SD")
if (diff > ref_sd) return("mehr als 1 SD ueber dem Vergleichswert")
"mehr als 1 SD unter dem Vergleichswert"
}
make_profil_vviq = function(profil_df) {
profil_df$label = factor(profil_df$label, levels = rev(profil_df$label))
profil_df$farbe = mapply(vviq_bar_farbe, profil_df$mittelwert, profil_df$ref_m, profil_df$ref_sd)
ggplot(profil_df, aes(x = label, y = mittelwert)) +
geom_col(aes(fill = farbe), width = 0.45) +
scale_fill_identity() +
geom_errorbar(
aes(ymin = pmax(1, ref_m - ref_sd), ymax = pmin(5, ref_m + ref_sd)),
width = 0.3, color = "#555555", linewidth = 0.6
) +
geom_point(aes(y = ref_m), color = "#333333", size = 2.6, shape = 18) +
coord_flip(ylim = c(1, 5)) +
scale_y_continuous(breaks = 1:5) +
theme_minimal(base_size = 14) +
labs(
x = NULL, y = "Wert (1-5)",
caption = paste0(
"Balken = individueller Mittelwert je Subskala, Balkenfarbe = SD-Distanz zum ",
"Vergleichswert (rot < M, gruen > M). Punkt/Fehlerbalken = M ± 1 SD der ",
"Validierungsstichprobe (N=300). ", VVIQ_VERGLEICHSHINWEIS, "."
)
) +
theme(
panel.grid.minor = element_blank(),
axis.text = element_text(size = 12),
axis.title.x = element_text(size = 12),
plot.caption = element_text(size = 8, color = "#777777", hjust = 0),
plot.margin = margin(t = 5, r = 15, b = 5, l = 5)
)
}
# 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; }
.subskalen-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 12px; margin-bottom: 6px;
}
.subskala-karte {
background: #FAFAFA; border: 1px solid #EEEEEE; border-radius: 6px;
padding: 12px 14px;
}
.subskala-titel { font-weight: 700; color: #333; margin-bottom: 4px; }
.subskala-wert { font-size: 1.7rem; font-weight: 800; color: #8B2635; }
.subskala-vergleich { font-size: 0.82em; color: #666; margin-top: 2px; }
.subskala-einordnung { font-size: 0.82em; color: #444; margin-top: 6px; line-height: 1.4; }
.referenz-hinweis {
font-size: 0.85em; color: #666; font-style: italic;
margin-top: 6px; padding: 0 4px;
}
.item-gruppe-titel {
font-weight: 700; color: #8B2635; margin: 14px 0 4px; font-size: 0.98em;
}
.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; }
.item-antwort { flex-shrink: 0; text-align: right; max-width: 220px; }
.antwort-badge {
border-radius: 4px; padding: 3px 10px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block;
}
"
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("VVIQ Vividness of Visual Imagery Questionnaire"),
tags$p("Deutsche Adaptation nach Jungmann, Becker & Witthoeft")
),
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_vviq_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_wert = fp_text(bold = TRUE, font.size = 12, color = AKZENT_FARBE)
fp_vergleich = fp_text(font.size = 9, italic = TRUE, color = "#666666")
doc = body_add_fpar(doc, fpar(
ftext("VVIQ 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)
))
for (w in erg$warnungen) {
doc = body_add_fpar(doc, fpar(
ftext(w, fp_text(font.size = 10, italic = TRUE, color = "#BF360C"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Subskalen", fp_abschnitt)))
for (i in seq_len(nrow(erg$subskalen))) {
sub = erg$subskalen[i, ]
doc = body_add_fpar(doc, fpar(
ftext(paste0(sub$label, ": "), fp_label),
ftext(paste0(sub$mittelwert_str, " / 5"), fp_wert)
))
doc = body_add_fpar(doc, fpar(
ftext(paste0(
"Vergleichswert (Validierungsstichprobe, N=300): M = ", sub$ref_m,
", SD = ", sub$ref_sd, " ", sub$einordnung
), fp_vergleich)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
for (i in seq_len(nrow(erg$items))) {
it = erg$items[i, ]
badge_stil = vviq_badge_stil(it$wert)
fp_badge = fp_text(
bold = TRUE, font.size = 10,
color = badge_stil$text, shading.color = badge_stil$bg
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", it$antwort_str, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(VVIQ_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))
pseudonym_wert = trimws(input$pseudonym)
if (nchar(pseudonym_wert) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(pseudonym_wert) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", meldung = paste0(
"Ungültige Chiffre. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123)."
)))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Download-Skript nicht gefunden unter:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden unter:\n", PFAD_PSEUDONYM_SKRIPT)))
}
ok_download = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_download$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript: ", ok_download$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))
setwd(wd_ziel)
on.exit(setwd(alter_wd), add = TRUE)
ok_pseudo = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_pseudo$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Pseudonym-Skript: ", ok_pseudo$msg)))
}
if (!exists("daten_vviq", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'daten_vviq' wurde nach dem Sourcen des Download-Skripts nicht gefunden."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'pseudo' wurde nach dem Sourcen des Pseudonym-Skripts nicht gefunden."))
}
daten = get("daten_vviq", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
# Spaltennamen fuer Sitzungskennung und Ausfuelldatum sind in daten_vviq
# nicht verifiziert - defensiv per Musterabgleich ermitteln.
spalten_ok = tryCatch({
list(
session_spalte = vviq_finde_spalte(daten, "session|pseudonym", "Sitzungskennung"),
datum_spalte = vviq_finde_spalte(daten, "created|ausfuell|datum", "Ausfülldatum"),
ok = TRUE
)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!isTRUE(spalten_ok$ok)) {
return(list(typ = "skript_fehler", meldung = spalten_ok$msg))
}
session_spalte = spalten_ok$session_spalte
datum_spalte = spalten_ok$datum_spalte
item_vars = sprintf("vviq_%02d", 1:16)
if (!all(item_vars %in% names(daten))) {
return(list(typ = "skript_fehler", meldung = paste0(
"Erwartete Item-Spalten (vviq_01 bis vviq_16) sind in daten_vviq nicht vollständig ",
"vorhanden. Bitte Download-Skript pruefen."
)))
}
warnungen = c()
# Erwartetes Exportformat ist dbl+lbl (haven::is.labelled). Nicht
# verifiziert, ob formr bei mc-Items tatsaechlich so exportiert - falls
# nicht, werden Werte als Rohzahlen interpretiert (siehe vviq_item_wert)
# und eine dezente Warnung angezeigt.
labelled_ok = all(sapply(item_vars, function(v) haven::is.labelled(daten[[v]])))
if (!labelled_ok) {
warnungen = c(warnungen, VVIQ_FORMAT_WARNUNG)
}
if (nchar(pseudonym_wert) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == pseudonym_wert, ]
if (nrow(pw_treffer) == 0) {
return(list(typ = "pseudonym_unbekannt", meldung = paste0(
"Pseudonym '", pseudonym_wert, "' wurde in der Pseudonym-Datenbank nicht gefunden."
)))
}
chiffre_anzeige = toupper(trimws(pw_treffer$chiffre[1]))
alle_session_ids = pseudonym_wert
} else {
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(typ = "chiffre_unbekannt", meldung = paste0(
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."
)))
}
chiffre_anzeige = chiffre
alle_session_ids = unique(treffer_ps$pseudonym)
}
treffer_dat = daten[daten[[session_spalte]] %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0) {
return(list(typ = "kein_datensatz", meldung = paste0(
"Kein VVIQ-Datensatz für ", if (nchar(pseudonym_wert) > 0) "Pseudonym" else "Chiffre",
" '", if (nchar(pseudonym_wert) > 0) pseudonym_wert else chiffre_anzeige,
"' gefunden. (", length(alle_session_ids), " Sitzungskennung(en) geprüft)"
)))
}
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
sortier_wert = tryCatch(as.POSIXct(treffer_dat[[datum_spalte]]),
error = function(e) treffer_dat[[datum_spalte]])
treffer_dat = treffer_dat[order(sortier_wert, decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat[[datum_spalte]][1]), "%d.%m.%Y %H:%M"),
error = function(e) as.character(treffer_dat[[datum_spalte]][1])
)
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge). Angezeigt wird die neueste vom ", datum_neu, "."
))
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
datum_str = tryCatch(
format(as.POSIXct(zeile[[datum_spalte]][1]), "%d.%m.%Y"),
error = function(e) as.character(zeile[[datum_spalte]][1])
)
werte = sapply(item_vars, function(v) vviq_item_wert(daten[[v]], zeile[[v]]))
antwort_texte = sapply(item_vars, function(v) vviq_item_text(daten[[v]], zeile[[v]]))
ausserhalb = which(!is.na(werte) & (werte < 1 | werte > 5))
if (length(ausserhalb) > 0) {
warnungen = c(warnungen, paste0(
"Wert(e) ausserhalb des gültigen Bereichs 1-5 bei Item(s): ",
paste(ausserhalb, collapse = ", "), ". Werte werden unveraendert angezeigt."
))
}
fehlend = which(is.na(werte))
if (length(fehlend) > 0) {
warnungen = c(warnungen, paste0(
"Kein Wert (NA) bei Item(s): ", paste(fehlend, collapse = ", "), "."
))
}
items = data.frame(
nr = 1:16,
var = item_vars,
text = sapply(1:16, function(i) {
txt = clean_item_label(attr(daten[[item_vars[i]]], "label"))
if (is.na(txt) || nchar(txt) == 0)
paste0("Item ", i, " (", vviq_item_subskala_label(i), ")")
else txt
}),
wert = as.numeric(werte),
antwort_text = as.character(antwort_texte),
stringsAsFactors = FALSE
)
items$antwort_str = ifelse(
is.na(items$wert), "k. A.",
ifelse(is.na(items$antwort_text),
paste0("Wert: ", items$wert),
paste0(items$antwort_text, " (", items$wert, ")"))
)
subskalen = VVIQ_SUBSKALEN
subskalen$mittelwert = sapply(seq_len(nrow(subskalen)), function(i) {
idx = subskalen$item_start[i]:subskalen$item_ende[i]
mean(werte[idx], na.rm = TRUE)
})
subskalen$mittelwert_str = ifelse(
is.nan(subskalen$mittelwert), "k. A.", as.character(round(subskalen$mittelwert, 2))
)
subskalen$einordnung = sapply(seq_len(nrow(subskalen)), function(i) {
wert_i = if (is.nan(subskalen$mittelwert[i])) NA_real_ else subskalen$mittelwert[i]
vviq_einordnung(wert_i, subskalen$ref_m[i], subskalen$ref_sd[i])
})
list(
typ = "ok",
chiffre = chiffre_anzeige,
datum_str = datum_str,
warnungen = warnungen,
items = items,
subskalen = subskalen
)
})
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)
div(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)
subskalen_ui = lapply(seq_len(nrow(d$subskalen)), function(i) {
sub = d$subskalen[i, ]
div(class = "subskala-karte",
div(class = "subskala-titel", sub$label),
div(class = "subskala-wert", paste0(sub$mittelwert_str, " / 5")),
div(class = "subskala-vergleich",
paste0("Vergleichswert: M = ", sub$ref_m, ", SD = ", sub$ref_sd)),
div(class = "subskala-einordnung", sub$einordnung)
)
})
item_gruppen_ui = lapply(seq_len(nrow(VVIQ_SUBSKALEN)), function(g) {
sub = VVIQ_SUBSKALEN[g, ]
idx = sub$item_start:sub$item_ende
zeilen = lapply(idx, function(i) {
it = d$items[d$items$nr == i, ]
badge_stil = vviq_badge_stil(it$wert)
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
div(class = "item-antwort",
tags$span(
class = "antwort-badge",
style = paste0("background:", badge_stil$bg, "; color:", badge_stil$text, ";"),
it$antwort_str
)
)
)
})
tagList(
div(class = "item-gruppe-titel", sub$label),
div(zeilen)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "VVIQ Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$datum_str
),
tags$hr(),
tags$h5("Subskalen (Mittelwert je Szene, 1-5)"),
div(class = "subskalen-grid", subskalen_ui),
div(class = "referenz-hinweis", paste0(
"Alle Vergleichswerte: ", VVIQ_VERGLEICHSHINWEIS, "."
)),
tags$hr(),
tags$h5("Profil"),
plotOutput("profil_plot", height = "300px"),
tags$hr(),
tags$h5("Einzelitems"),
div(item_gruppen_ui)
)
})
output$profil_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(identical(d$typ, "ok"))
profil_df = d$subskalen
profil_df$mittelwert[is.nan(profil_df$mittelwert)] = NA_real_
make_profil_vviq(profil_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")) 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("VVIQ_", 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_vviq_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)