inst/shiny/global.R

# ── Syrona Dashboard - Global ──────────────────────────────────────────────────
#
# This file is the single source of truth for the dashboard's startup logic.
# It is the canonical version inside the syrona R package, and is also copied
# verbatim to syrona-web/global.R by scripts/build_web.R for the omop-apps
# Shiny Server deployment. Both deploy modes (package and flat-shiny) use the
# exact same file, so do not split it into per-mode variants.
#
# Mode detection happens at runtime via exists("load_syrona_theme"):
#   1. Package mode (syrona::run_app()): the package is already attached, so
#      load_syrona_theme() is on the search path.
#   2. Flat-shiny mode (omop-apps Shiny Server): no syrona package install;
#      we source the helper R/ files directly from the app folder.
#
# Server install list (flat-shiny mode, run once on the omop-apps host as the
# user that owns the Shiny Server library):
#
#   install.packages(c(
#     "shiny", "dplyr", "DT", "ggplot2", "ggiraph", "ggtext",
#     "scales", "tidyr", "shinycssloaders"
#   ))
#
# Everything else used at runtime (cli, tibble, rlang, ...) is pulled in
# transitively as a hard dependency of dplyr.

library(shiny)
library(dplyr, warn.conflicts = FALSE)
library(DT)
library(ggplot2)
library(ggiraph)
library(ggtext)
library(scales)
library(tidyr)
library(shinycssloaders)

# NULL-coalescing operator. Built into base R since 4.4.0 and exported by
# rlang, but neither is on the search path of this Shiny global env unless we
# attach rlang explicitly. Defining it here is harmless because every
# implementation has the same semantics.
`%||%` <- function(a, b) if (is.null(a)) b else a

# Mode detection: if syrona is attached, use its exported helpers. Otherwise
# we are running flat-shiny on a server without the package installed, and
# need to source the R/ helpers from the app folder directly.
if (exists("load_syrona_theme", mode = "function")) {
  load_syrona_theme()
} else {
  for (helper in c("constants.R", "config.R", "extract.R", "compare.R",
                   "plot_heatmaps.R", "plot_forests.R", "plot_demography.R",
                   "plot_pr_distribution.R")) {
    source(file.path("R", helper))
  }
}

# ── Data directory ───────────────────────────────────────────────────────────
# Resolution order:
#   1. options(syrona.data_dir = ...) — set by run_app() in package mode.
#   2. SYRONA_DATA_DIR environment variable.
#   3. Working directory — under Shiny Server this is the app folder, which
#      already contains data/sources/ alongside this file.
#   4. Two levels up from getwd() — covers running shinyAppDir(inst/shiny/)
#      from a package project root.

DATA_DIR <- getOption("syrona.data_dir", NULL)
if (is.null(DATA_DIR)) {
  candidates <- c(
    getwd(),
    file.path(getwd(), "..", ".."),
    Sys.getenv("SYRONA_DATA_DIR", unset = "")
  )
  DATA_DIR <- "."
  for (cand in candidates) {
    if (nchar(cand) > 0 && dir.exists(file.path(cand, "data", "sources"))) {
      DATA_DIR <- normalizePath(cand)
      break
    }
  }
  # Propagate the discovered path so load_dataset() and load_comparison()
  # (which read getOption("syrona.data_dir", ".")) use the same base as
  # everything above. Without this, flat-shiny mode worked only by
  # coincidence of getwd() happening to equal DATA_DIR.
  options(syrona.data_dir = DATA_DIR)
}
message("[syrona] DATA_DIR = ", DATA_DIR)

# ── Shipped lookup resolution ────────────────────────────────────────────────
# Package/CRAN installs carry curated subsets in inst/extdata (found via
# system.file); dev and flat-shiny fall back to the full files under data/.
# Both the ICD-10 lookup and the procedure-vocabulary lookups are downsized to
# the bundled demo concepts for CRAN; the full files remain in data/ for dev.
syrona_extdata <- function(...) {
  rel <- file.path(...)
  p <- system.file("extdata", rel, package = "syrona")
  if (nzchar(p)) return(p)
  cand <- file.path("inst", "extdata", rel)   # flat-shiny / source tree
  if (file.exists(cand)) return(cand)
  file.path(DATA_DIR, "data", rel)            # dev fallback (full files)
}

# ── ICD-10 Lookup (SNOMED -> ICD-10 code mapping) ────────────────────────────
# Built by scripts/build_icd10_lookup.R from OMOP vocabulary tables.

icd10_path <- syrona_extdata("icd10_lookup.csv")
icd10_lookup <- if (file.exists(icd10_path)) {
  read.csv(icd10_path, stringsAsFactors = FALSE) |> as_tibble()
} else {
  tibble(concept_id = integer(), icd10_code = character(),
         icd10_name = character())
}

# ── Vocabulary Lookups (procedure enrichment) ────────────────────────────────
# Adds device, indirect site, and morphology attributes for procedure filtering.

vocab_device <- if (file.exists(syrona_extdata("vocabulary", "procedure_device_attributes.csv"))) {
  read.csv(syrona_extdata("vocabulary", "procedure_device_attributes.csv"), stringsAsFactors = FALSE) |> as_tibble()
} else NULL

vocab_site_extra <- if (file.exists(syrona_extdata("vocabulary", "procedure_site_extra.csv"))) {
  read.csv(syrona_extdata("vocabulary", "procedure_site_extra.csv"), stringsAsFactors = FALSE) |> as_tibble()
} else NULL

vocab_morphology <- if (file.exists(syrona_extdata("vocabulary", "procedure_morphology.csv"))) {
  read.csv(syrona_extdata("vocabulary", "procedure_morphology.csv"), stringsAsFactors = FALSE) |> as_tibble()
} else NULL

# ── Auto-detect Comparisons ──────────────────────────────────────────────────

comp_dir <- file.path(DATA_DIR, "data", "comparisons")
available_comparisons <- if (dir.exists(comp_dir)) {
  list.dirs(comp_dir, recursive = FALSE, full.names = FALSE)
} else {
  character(0)
}
message("[syrona] comp_dir = ", comp_dir, " | exists: ", dir.exists(comp_dir),
        " | comparisons found: ", length(available_comparisons),
        if (length(available_comparisons) > 0) paste0(" (", paste(available_comparisons, collapse = ", "), ")") else "")
comparison_choices <- setNames(
  available_comparisons,
  gsub("_vs_", " vs ", available_comparisons)
)

# ── Lazy source dataset cache ────────────────────────────────────────────────

source_cache <- new.env(parent = emptyenv())

get_source_dataset <- function(name) {
  if (!exists(name, envir = source_cache)) {
    source_cache[[name]] <- load_dataset(name)
  }
  source_cache[[name]]
}

# ── Domain Configuration ────────────────────────────────────────────────────

DOMAIN_CONFIG <- list(
  condition = list(
    label = "Conditions",
    prev_table   = "condition_prevalence",
    info_table   = "condition_info",
    chapter_table = "condition_chapters",
    attr_table   = "condition_attributes",
    comp_prefix  = "condition_",
    class_types  = c(
      "ICD-10 Chapter"    = "icd10_chapter",
      "Body system"       = "body_system",
      "Disease category"  = "disease_category"
    ),
    attr_types = c("finding_site", "morphology", "clinical_course"),
    attr_labels = list(
      finding_site    = "Finding site",
      morphology      = "Morphology",
      clinical_course = "Clinical course"
    ),
    attr_tips = list(
      finding_site    = "SNOMED 'Has finding site' relationship. Anatomical location where the condition manifests.",
      morphology      = "SNOMED 'Has associated morphology' relationship. Structural change (e.g. inflammation, neoplasm).",
      clinical_course = "SNOMED 'Has clinical course' relationship. Temporal pattern (e.g. acute, chronic)."
    ),
    detail_tab_label = "Concept Detail",
    search_placeholder = "Search by name or code...",
    chapter_facet_label = "Per chapter",
    chapter_tip = "Chapters from the selected classification system. A concept may appear in more than one chapter: via SNOMED CT poly-hierarchy (Body system, Disease category) or via multiple ICD-10 mappings.",
    subchapter_tip = "Sub-chapters within the selected chapter. Narrow down to a specific sub-group."
  ),
  procedure = list(
    label = "Procedures",
    prev_table   = "procedure_prevalence",
    info_table   = "procedure_info",
    chapter_table = "procedure_chapters",
    attr_table   = "procedure_attributes",
    comp_prefix  = "procedure_",
    class_types  = c(
      "By method" = "by_method",
      "By site"   = "by_site"
    ),
    attr_types = c("procedure_site", "method", "device", "morphology"),
    attr_labels = list(
      procedure_site = "Body site",
      method         = "Method",
      device         = "Device",
      morphology     = "Morphology"
    ),
    attr_tips = list(
      procedure_site = "SNOMED procedure site relationships (direct + indirect + generic). Anatomical location of the procedure.",
      method         = "SNOMED 'Has method' relationship. Type of action (e.g. excision, repair, imaging).",
      device         = "SNOMED device relationships (Using device, Has dir/indir/proc device, Using acc device). Device used in the procedure.",
      morphology     = "SNOMED morphology relationships (Has dir/indir/proc morph). Structural change targeted by the procedure."
    ),
    detail_tab_label = "Concept Detail",
    search_placeholder = "Search procedure by name or code...",
    chapter_facet_label = "Per chapter",
    chapter_tip = "Chapters from the selected procedure classification. SNOMED CT poly-hierarchy means a concept may appear in more than one chapter.",
    subchapter_tip = "Sub-chapters within the selected chapter. Narrow down to a specific procedure group."
  ),
  drug = list(
    label = "Drugs",
    prev_table   = "drug_prevalence",
    info_table   = "drug_info",
    chapter_table = "drug_chapters",
    attr_table   = "drug_attributes",
    comp_prefix  = "drug_",
    class_types  = c(
      "ATC 1st level" = "atc_1st"
    ),
    attr_types = character(0),
    attr_labels = list(),
    attr_tips = list(),
    detail_tab_label = "Concept Detail",
    search_placeholder = "Search drug ingredient by name or code...",
    chapter_facet_label = "Per ATC chapter",
    chapter_tip = "ATC 1st level anatomical/therapeutic classification."
  )
)

MAX_ATTR_SLOTS <- 4
DEFAULT_DOMAIN <- "condition"
DEFAULT_CLASS_TYPES <- DOMAIN_CONFIG[["condition"]]$class_types
DEFAULT_ATTR_TYPES  <- DOMAIN_CONFIG[["condition"]]$attr_types

#' Detect which domains are available in a comparison.
detect_domains <- function(comp) {
  domains <- character(0)
  for (d in names(DOMAIN_CONFIG)) {
    key <- paste0(DOMAIN_CONFIG[[d]]$comp_prefix, "yearly")
    if (!is.null(comp[[key]]) && nrow(comp[[key]]) > 0) {
      domains <- c(domains, d)
    }
  }
  domains
}

#' Count unique concepts per domain in a comparison.
get_domain_counts <- function(comp) {
  counts <- list()
  for (d in names(DOMAIN_CONFIG)) {
    key <- paste0(DOMAIN_CONFIG[[d]]$comp_prefix, "yearly")
    tbl <- comp[[key]]
    counts[[d]] <- if (!is.null(tbl) && nrow(tbl) > 0) length(unique(tbl$concept_id)) else 0L
  }
  counts
}

#' Build derived data for a comparison + domain.
build_comparison_data <- function(comp_name, domain = "condition") {
  parts <- strsplit(comp_name, "_vs_")[[1]]
  d1_name <- parts[1]; d2_name <- parts[2]
  d1 <- get_source_dataset(d1_name)
  d2 <- get_source_dataset(d2_name)
  comp <- load_comparison(d1_name, d2_name)

  cfg <- DOMAIN_CONFIG[[domain]]
  prefix <- cfg$comp_prefix

  yearly_df      <- comp[[paste0(prefix, "yearly")]]
  meta_ag_df     <- comp[[paste0(prefix, "meta_agegroups")]]
  meta_by_sex_df <- comp[[paste0(prefix, "meta_by_sex")]]
  meta_summary_df <- comp[[paste0(prefix, "meta_summary")]]

  if (is.null(yearly_df) || nrow(yearly_df) == 0) {
    return(list(
      available = FALSE, domain = domain, d1_name = d1_name, d2_name = d2_name,
      d1 = d1, d2 = d2
    ))
  }

  compared_concept_ids <- unique(yearly_df$concept_id)

  info1 <- d1[[cfg$info_table]]; info2 <- d2[[cfg$info_table]]
  chap1 <- d1[[cfg$chapter_table]]; chap2 <- d2[[cfg$chapter_table]]
  attr1 <- d1[[cfg$attr_table]]; attr2 <- d2[[cfg$attr_table]]

  concept_info <- bind_rows(
    info1 |> select(concept_id, concept_name, concept_code),
    info2 |> select(concept_id, concept_name, concept_code)
  ) |>
    filter(concept_id %in% compared_concept_ids) |>
    distinct(concept_id, .keep_all = TRUE) |>
    arrange(concept_name)

  chapters_df <- bind_rows(chap1, chap2) |>
    filter(concept_id %in% compared_concept_ids) |>
    distinct()

  attrs_df <- if (!is.null(attr1) && !is.null(attr2)) {
    bind_rows(attr1, attr2) |>
      filter(concept_id %in% compared_concept_ids) |>
      distinct()
  } else {
    tibble(concept_id = integer(), relationship = character(),
           target_concept_id = integer(), target_concept_name = character())
  }

  # Merge vocabulary lookups for procedures
  if (domain == "procedure") {
    vocab_cols <- c("concept_id", "relationship", "target_concept_id", "target_concept_name")
    if (!is.null(vocab_device)) {
      attrs_df <- bind_rows(attrs_df,
        vocab_device |>
          filter(concept_id %in% compared_concept_ids) |>
          select(all_of(vocab_cols))
      ) |> distinct()
    }
    if (!is.null(vocab_site_extra)) {
      attrs_df <- bind_rows(attrs_df,
        vocab_site_extra |>
          filter(concept_id %in% compared_concept_ids) |>
          select(all_of(vocab_cols))
      ) |> distinct()
    }
    if (!is.null(vocab_morphology)) {
      attrs_df <- bind_rows(attrs_df,
        vocab_morphology |>
          filter(concept_id %in% compared_concept_ids) |>
          select(all_of(vocab_cols))
      ) |> distinct()
    }
  }

  total_persons_d1 <- sum(d1$demographics$patient_count)
  total_persons_d2 <- sum(d2$demographics$patient_count)

  counts_df <- info1 |>
    select(concept_id, concept_name, concept_code, patients_d1 = n_patients_total) |>
    full_join(
      info2 |> select(concept_id, patients_d2 = n_patients_total),
      by = "concept_id"
    ) |>
    filter(concept_id %in% compared_concept_ids) |>
    mutate(
      pct_d1 = patients_d1 / total_persons_d1 * 100,
      pct_d2 = patients_d2 / total_persons_d2 * 100
    ) |>
    arrange(concept_name)

  pop_weight_df <- counts_df |>
    transmute(concept_id,
              pop_weight = pmax(
                replace_na(pct_d1, 0),
                replace_na(pct_d2, 0)))

  concept_info <- concept_info |>
    left_join(pop_weight_df, by = "concept_id") |>
    mutate(pop_weight = replace_na(pop_weight, 0))

  list(
    available = TRUE, domain = domain,
    d1 = d1, d2 = d2, d1_name = d1_name, d2_name = d2_name,
    yearly_df = yearly_df, meta_ag_df = meta_ag_df,
    meta_by_sex_df = meta_by_sex_df, meta_summary_df = meta_summary_df,
    compared_concept_ids = compared_concept_ids,
    concept_info = concept_info, chapters_df = chapters_df,
    attrs_df = attrs_df, counts_df = counts_df,
    available_domains = detect_domains(comp),
    domain_counts = get_domain_counts(comp)
  )
}

# ── Constants ────────────────────────────────────────────────────────────────

age_group_levels <- c("0-9", "10-19", "20-29", "30-39", "40-49",
                      "50-59", "60-69", "70-79", "80+")

HEATMAP_MAX_CONCEPTS <- 180

# ── Filter Helper Functions ──────────────────────────────────────────────────

get_attribute_choices <- function(df, rel_type, concept_ids = NULL) {
  d <- df |> filter(relationship == rel_type)
  if (!is.null(concept_ids)) d <- d |> filter(concept_id %in% concept_ids)
  choices <- d |>
    group_by(target_concept_id, target_concept_name) |>
    summarise(n = n_distinct(concept_id), .groups = "drop") |>
    arrange(desc(n), target_concept_name)
  if (nrow(choices) == 0) return(character(0))
  setNames(
    as.character(choices$target_concept_id),
    paste0(choices$target_concept_name, "  (", choices$n, ")")
  )
}

get_chapter_choices <- function(df, chapter_type_val, concept_ids = NULL) {
  d <- df |> filter(chapter_type == chapter_type_val, chapter_level == 1L)
  if (!is.null(concept_ids)) d <- d |> filter(concept_id %in% concept_ids)
  choices <- d |>
    group_by(chapter_id, chapter_name) |>
    summarise(n = n_distinct(concept_id), .groups = "drop") |>
    filter(n >= 1L) |>
    arrange(desc(n), chapter_name)
  if (nrow(choices) == 0) return(character(0))
  setNames(
    as.character(choices$chapter_id),
    paste0(choices$chapter_name, "  (", choices$n, ")")
  )
}

get_subchapter_choices <- function(df, chapter_type_val, sel_L1_ids = NULL,
                                   concept_ids = NULL) {
  d <- df |> filter(chapter_type == chapter_type_val, chapter_level == 2L)
  if (!is.null(sel_L1_ids) && length(sel_L1_ids) > 0) {
    d <- d |> filter(parent_chapter_id %in% as.integer(sel_L1_ids))
  }
  if (!is.null(concept_ids)) d <- d |> filter(concept_id %in% concept_ids)
  choices <- d |>
    group_by(chapter_id, chapter_name) |>
    summarise(n = n_distinct(concept_id), .groups = "drop") |>
    filter(n >= 1L) |>
    arrange(desc(n), chapter_name)
  if (nrow(choices) == 0) return(character(0))
  setNames(
    as.character(choices$chapter_id),
    paste0(choices$chapter_name, "  (", choices$n, ")")
  )
}

has_subchapters <- function(df, chapter_type_val) {
  any(df$chapter_type == chapter_type_val & df$chapter_level == 2L)
}

get_chapter_concept_ids <- function(df, chapter_type_val, selected) {
  if (length(selected) == 0) return(NULL)
  df |>
    filter(chapter_type == chapter_type_val,
           chapter_id %in% as.integer(selected)) |>
    pull(concept_id) |>
    unique()
}

get_chapter_scope <- function(chapters_df, class_type, sel_L1, sel_L2 = NULL) {
  l1_ids <- if (length(sel_L1) > 0) {
    get_chapter_concept_ids(chapters_df, class_type, sel_L1)
  } else {
    NULL
  }
  l2_ids <- if (length(sel_L2) > 0) {
    get_chapter_concept_ids(chapters_df, class_type, sel_L2)
  } else {
    NULL
  }
  if (is.null(l1_ids) && is.null(l2_ids)) return(NULL)
  if (!is.null(l2_ids)) return(l2_ids)
  l1_ids
}

# ── UI Helper Functions ──────────────────────────────────────────────────────

info_icon <- function(tooltip) {
  tags$span(class = "filter-info", `data-tooltip` = tooltip, "i")
}

filter_label <- function(text, tooltip, counter_id) {
  tags$span(
    text, " ",
    info_icon(tooltip),
    " ",
    uiOutput(counter_id, inline = TRUE)
  )
}

Try the syrona package in your browser

Any scripts or data that you put into this service are public.

syrona documentation built on Sept. 5, 2026, 1:06 a.m.