============================================================

Vietnam Job Market – R Shiny Interactive Dashboard

BANA4010 | VinUniversity

============================================================

library(shiny) library(shinyjs) # for show/hide Back button library(shinydashboard) library(tidyverse) library(scales) library(viridis) library(DT) library(plotly)

── 1. DATA LOAD & CLEAN ─────────────────────────────────────

df_raw <- read_csv(“jobs.csv”, show_col_types = FALSE)

df <- df_raw %>% filter(!is.na(job_title)) %>% mutate( emp_type = case_when( str_detect(tolower(job_type), “part|bán thời”) ~ “Part-time”, str_detect(tolower(job_type), “thực tập|intern”) ~ “Internship”, str_detect(tolower(job_type), “full|toàn thời|chính thức”) ~ “Full-time”, TRUE ~ “Other” ), level_clean = case_when( str_detect(position_level, “thực tập|sinh viên|mới tốt nghiệp”) ~ “Intern / Fresh Grad”, str_detect(position_level, “nhân viên|chuyên viên”) ~ “Staff / Specialist”, str_detect(position_level, “trưởng nhóm|giám sát|quản lý nhóm”) ~ “Team Lead / Supervisor”, str_detect(position_level, “trưởng phòng|quản lý”) ~ “Manager”, str_detect(position_level, “giám đốc|phó giám đốc|cấp cao|tổng”)~ “Director / C-Suite”, TRUE ~ “Other” ), exp_clean = case_when( str_detect(experience, “không yêu cầu|chưa”) ~ “No requirement”, str_detect(experience, “dưới 1|0 -”) ~ “< 1 year”, str_detect(experience, “1 - 2|trên 1”) ~ “1–2 years”, str_detect(experience, “2 -|trên 2”) ~ “2–3 years”, str_detect(experience, “3 -|trên 3”) ~ “3–5 years”, str_detect(experience, “5 -|trên 5|6 -|7 -|8 -|trên 10”) ~ “5+ years”, TRUE ~ “Other” ), city_clean = case_when( str_detect(tolower(city), “hà nội|ha noi”) ~ “Hà Nội”, str_detect(tolower(city), “hồ chí minh|hcm|ho chi minh”) ~ “Hồ Chí Minh”, str_detect(tolower(city), “đà nẵng|da nang”) ~ “Đà Nẵng”, str_detect(tolower(city), “hải phòng|hai phong”) ~ “Hải Phòng”, str_detect(tolower(city), “bình dương|binh duong”) ~ “Bình Dương”, str_detect(tolower(city), “đồng nai|dong nai”) ~ “Đồng Nai”, str_detect(tolower(city), “long an”) ~ “Long An”, str_detect(tolower(city), “bắc ninh|bac ninh”) ~ “Bắc Ninh”, str_detect(tolower(city), “hưng yên|hung yen”) ~ “Hưng Yên”, str_detect(tolower(city), “tây ninh|tay ninh”) ~ “Tây Ninh”, TRUE ~ “Other” ), salary_mid = if_else( unit == “vnd” & salary_max > 0 & salary_max <= 200, (salary_min + salary_max) / 2, NA_real_ ) )

fields_long <- df %>% filter(!is.na(job_fields)) %>% separate_rows(job_fields, sep = “,”) %>% mutate(job_fields = str_trim(job_fields)) %>% filter(nchar(job_fields) > 2, !job_fields %in% c(“bán sỉ”, “bán lẻ”, “bán hàng”))

city_choices <- sort(unique(df\(city_clean[df\)city_clean != “Other” & !is.na(df$city_clean)])) level_choices <- c(“Intern / Fresh Grad”,“Staff / Specialist”, “Team Lead / Supervisor”,“Manager”,“Director / C-Suite”) exp_order <- c(“No requirement”,“< 1 year”,“1–2 years”, “2–3 years”,“3–5 years”,“5+ years”) emp_choices <- c(“Full-time”,“Part-time”,“Internship”,“Other”)

city_coords <- tibble( city_clean = c(“Hà Nội”,“Hồ Chí Minh”,“Đà Nẵng”,“Hải Phòng”, “Bình Dương”,“Đồng Nai”,“Long An”,“Bắc Ninh”,“Hưng Yên”,“Tây Ninh”), lat = c(21.028,10.823,16.047,20.865,11.068,10.957,10.535,21.186,20.646,11.310), lon = c(105.854,106.630,108.206,106.684,106.653,106.820,106.414,106.076,106.051,106.099), region = c(“Bắc Bộ”,“Nam Bộ”,“Trung Bộ”,“Bắc Bộ”,“Nam Bộ”, “Nam Bộ”,“Nam Bộ”,“Bắc Bộ”,“Bắc Bộ”,“Nam Bộ”) )

── 2. UI ────────────────────────────────────────────────────

ui <- dashboardPage( skin = “blue”, dashboardHeader(title = tags$span(“🇻🇳 Vietnam Job Market”), titleWidth = 280),

dashboardSidebar( width = 280, sidebarMenu( menuItem(“Overview”, tabName = “overview”, icon = icon(“chart-bar”)), menuItem(“Salary Explorer”, tabName = “salary”, icon = icon(“money-bill-wave”)), menuItem(“Field Demand”, tabName = “fields”, icon = icon(“briefcase”)), menuItem(“Data Table”, tabName = “table”, icon = icon(“table”)) ), hr(), tags\(div(style = "padding:10px 15px;", tags\)b(“Global Filters”, style = “color:#ccc; font-size:13px;”), br(), checkboxGroupInput(“emp_filter”,“Employment Type”, choices = emp_choices, selected = emp_choices), selectInput(“city_filter”,“City”, choices = c(“All”, city_choices), selected = “All”), br(), tags$small(style=“color:#888;”,“Dataset: 85,470 Vietnamese job postings | BANA4010”) ) ),

dashboardBody( useShinyjs(), tags\(head(tags\)style(HTML(” .content-wrapper{background-color:#f5f7fa;} .box{border-radius:8px;} .small-box{border-radius:8px;} “))),

tabItems(

  # ── TAB 1: OVERVIEW ──────────────────────────────────
  tabItem(tabName = "overview",
    fluidRow(
      valueBoxOutput("vb_total",  width=3),
      valueBoxOutput("vb_cities", width=3),
      valueBoxOutput("vb_median", width=3),
      valueBoxOutput("vb_ft",     width=3)
    ),
    fluidRow(
      box(title="Job Postings by City", width=6,
          solidHeader=TRUE, status="primary",
          plotlyOutput("plot_city", height="380px")),
      box(title="Employment Type Distribution", width=6,
          solidHeader=TRUE, status="info",
          plotlyOutput("plot_emp", height="380px"))
    ),
    fluidRow(
      box(title="Experience Requirements by City", width=12,
          solidHeader=TRUE, status="warning",
          plotlyOutput("plot_heatmap", height="380px"))
    )
  ),

  # ── TAB 2: SALARY EXPLORER ───────────────────────────
  tabItem(tabName = "salary",

    # Row 1: Controls + Salary Heatmap (NEW)
    fluidRow(
      box(
        title="Controls", width=3,
        solidHeader=TRUE, status="primary",
        sliderInput("sal_range","Salary Range (M VND/month)",
                    min=0, max=150, value=c(0,100), step=5),
        selectInput("sal_level","Filter by Seniority",
                    choices=c("All", level_choices), selected="All"),
        hr(),
        tags$p(style="font-size:12px;color:#666;",
          strong("Heatmap:"), " Median salary per cell. Cells with n < 5 shown as N/A.",
          " Reacts to slider and seniority filter.", br(), br(),
          strong("City bar:"), " Click a city bar to drill down by seniority or experience.", br(), br(),
          "VND only. Outliers > 150 M excluded."
        )
      ),
      # ── NEW: Salary Heatmap emp_type × seniority ─────
      box(
        title="Salary Heatmap: Employment Type × Seniority Level",
        width=9, solidHeader=TRUE, status="primary",
        plotlyOutput("plot_sal_heatmap", height="400px")
      )
    ),

    # Row 2: dot-range + city drill-down bar
    fluidRow(
      box(
        title="Salary Range by Seniority (Q1 – Median – Q3)",
        width=5, solidHeader=TRUE, status="info",
        plotlyOutput("plot_dotrange", height="360px")
      ),
      # ── NEW: City sorted bar with drill-down ─────────
      box(
        title=uiOutput("city_sal_title"),
        width=7, solidHeader=TRUE, status="success",
        div(
          style="margin-bottom:6px;",
          actionButton("btn_back_city", "← Back to Cities",
                       class="btn btn-xs btn-default",
                       style="display:none;", icon=icon("arrow-left"))
        ),
        plotlyOutput("plot_city_sal", height="320px")
      )
    )
  ),

  # ── TAB 3: FIELD DEMAND ──────────────────────────────
  tabItem(tabName = "fields",
    fluidRow(
      box(
        title="Controls", width=3,
        solidHeader=TRUE, status="primary",
        sliderInput("n_fields","Top N fields to show",
                    min=5, max=25, value=12, step=1),
        hr(),
        tags$p(style="font-size:12px;color:#666;",
          "Bar = number of listings (x-axis).", br(),
          "Label on each bar = median salary for that field.", br(), br(),
          "Heatmap below shows field dominance per city."
        )
      ),
      # ── UPDATED: single bar + salary annotation ───────
      box(
        title="Top Job Fields – Listing Count (Median Salary labelled on bar)",
        width=9, solidHeader=TRUE, status="primary",
        plotlyOutput("plot_fields_bar", height="480px")
      )
    ),
    fluidRow(
      box(
        title="Field Demand by City (% of city listings)",
        width=12, solidHeader=TRUE, status="warning",
        plotlyOutput("plot_city_fields", height="420px")
      )
    )
  ),

  # ── TAB 4: DATA TABLE ────────────────────────────────
  tabItem(tabName = "table",
    fluidRow(
      box(title="Filtered Job Listings", width=12,
          solidHeader=TRUE, status="primary",
          DTOutput("data_tbl"))
    )
  )
)

) )

── 3. SERVER ────────────────────────────────────────────────

server <- function(input, output, session) {

# ── Reactives ─────────────────────────────────────────── df_filt <- reactive({ d <- df %>% filter(emp_type %in% input\(emp_filter) if (input\)city_filter != “All”) d <- d %>% filter(city_clean == input$city_filter) d })

sal_base <- reactive({ d <- df_filt() %>% filter(unit==“vnd”, !is.na(salary_mid), salary_mid >= input\(sal_range[1], salary_mid <= input\)sal_range[2], level_clean %in% level_choices) if (input\(sal_level != "All") d <- d %>% filter(level_clean == input\)sal_level) d })

# State for city drill-down drilldown_city <- reactiveVal(NULL) # NULL = top-level view drilldown_dim <- reactiveVal(“level”) # “level” or “exp”

observeEvent(input$btn_back_city, { drilldown_city(NULL) shinyjs::hide(“btn_back_city”) # hide back button when at top level })

# ── Value Boxes ───────────────────────────────────────── output\(vb_total <- renderValueBox({ valueBox(format(nrow(df_filt()), big.mark=","), "Total Listings", icon=icon("list"), color="blue") }) output\)vb_cities <- renderValueBox({ n <- df_filt() %>% filter(city_clean != “Other”) %>% distinct(city_clean) %>% nrow() valueBox(n, “Cities Represented”, icon=icon(“map-marker-alt”), color=“green”) }) output\(vb_median <- renderValueBox({ med <- df_filt() %>% filter(unit=="vnd", salary_mid>0, salary_mid<=150) %>% pull(salary_mid) %>% median(na.rm=TRUE) valueBox(paste0(round(med,1),"M VND"), "Median Salary / Month", icon=icon("coins"), color="yellow") }) output\)vb_ft <- renderValueBox({ pct <- round(sum(df_filt()$emp_type==“Full-time”)/nrow(df_filt())*100,1) valueBox(paste0(pct,“%”), “Full-time Listings”, icon=icon(“user-tie”), color=“red”) })

# ── Overview: City Bar ────────────────────────────────── output$plot_city <- renderPlotly({ d <- df_filt() %>% filter(city_clean != “Other”, !is.na(city_clean)) %>% count(city_clean) %>% arrange(desc(n)) %>% mutate(city_clean = fct_reorder(city_clean, n)) p <- ggplot(d, aes(x=n, y=city_clean, text=paste0(city_clean,“:”,format(n,big.mark=“,”),” listings”))) + geom_col(fill=“#2980b9”, width=0.7) + scale_x_continuous(labels=label_comma()) + labs(x=“Listings”, y=NULL) + theme_minimal(base_size=11) + theme(panel.grid.major.y=element_blank()) ggplotly(p, tooltip=“text”) %>% layout(margin=list(l=10)) %>% config(displayModeBar=FALSE) })

# ── Overview: Employment Donut ────────────────────────── output$plot_emp <- renderPlotly({ d <- df_filt() %>% filter(emp_type != “Other”) %>% count(emp_type) %>% mutate(pct=round(n/sum(n)*100,1)) plot_ly(d, labels=~emp_type, values=~n, textinfo=“label+percent”, hovertemplate=“%{label}: %{value:,}”, marker=list(colors=c(“#2ecc71”,“#3498db”,“#e74c3c”,“#f39c12”), line=list(color=“white”,width=2))) %>% add_pie(hole=0.4) %>% layout(showlegend=TRUE, legend=list(orientation=“h”,x=0.1,y=-0.1)) %>% config(displayModeBar=FALSE) })

# ── Overview: Experience Heatmap ──────────────────────── output$plot_heatmap <- renderPlotly({ d <- df_filt() %>% filter(city_clean!=“Other”, !is.na(city_clean), exp_clean %in% exp_order) %>% count(city_clean, exp_clean) %>% group_by(city_clean) %>% mutate(pct=round(n/sum(n)*100,1)) %>% ungroup() %>% mutate(exp_clean=factor(exp_clean, levels=exp_order)) p <- ggplot(d, aes(x=exp_clean, y=city_clean, fill=pct, text=paste0(city_clean,” | “,exp_clean,”: “,pct,”%“))) + geom_tile(colour=”white”, linewidth=0.4) + geom_text(aes(label=paste0(pct,“%”)), size=3, colour=“white”, fontface=“bold”) + scale_fill_viridis_c(option=“B”, direction=-1) + labs(x=“Experience Required”, y=NULL, fill=“% of”) + theme_minimal(base_size=11) + theme(panel.grid=element_blank(), axis.text.x=element_text(angle=20,hjust=1)) ggplotly(p, tooltip=“text”) %>% config(displayModeBar=FALSE) })

# ── Salary: Heatmap emp_type × seniority (NEW) ────────── output$plot_sal_heatmap <- renderPlotly({ d_full <- sal_base() %>% filter(emp_type %in% c(“Full-time”,“Part-time”,“Internship”))

# Build complete grid so every cell exists
grid <- expand.grid(
  level_clean = level_choices,
  emp_type    = c("Full-time","Part-time","Internship"),
  stringsAsFactors = FALSE
)

stats <- d_full %>%
  group_by(level_clean, emp_type) %>%
  summarise(
    med_sal = round(median(salary_mid, na.rm=TRUE), 1),
    min_sal = round(min(salary_mid,    na.rm=TRUE), 1),
    max_sal = round(max(salary_mid,    na.rm=TRUE), 1),
    n       = n(),
    .groups = "drop"
  )

d <- grid %>%
  left_join(stats, by=c("level_clean","emp_type")) %>%
  mutate(
    n       = replace_na(n, 0),
    valid   = n >= 5,
    # fill value: use med_sal for valid cells, NA otherwise (will colour grey)
    fill_val = if_else(valid, med_sal, NA_real_),
    # label shown in tile
    tile_label = case_when(
      n == 0    ~ "No data",
      !valid    ~ paste0("N/A\n(n=", n, ")"),
      TRUE      ~ paste0(med_sal, "M\n(n=", format(n, big.mark=","), ")")
    ),
    # hover tooltip
    hover = case_when(
      n == 0 ~ paste0("<b>", emp_type, " | ", level_clean, "</b><br>No data"),
      !valid ~ paste0("<b>", emp_type, " | ", level_clean, "</b><br>",
                      "Sample too small (n=", n, " < 5)<br>N/A"),
      TRUE   ~ paste0(
        "<b>", emp_type, " | ", level_clean, "</b><br>",
        "─────────────────<br>",
        "Median: <b>", med_sal, " M VND</b><br>",
        "Min: ",    min_sal, " M  |  Max: ", max_sal, " M<br>",
        "n = ", format(n, big.mark=",")
      )
    ),
    level_clean = factor(level_clean, levels=rev(level_choices)),
    emp_type    = factor(emp_type, levels=c("Full-time","Part-time","Internship"))
  )

# Build heatmap with ggplot then convert; grey for NA cells
p <- ggplot(d, aes(x=emp_type, y=level_clean, fill=fill_val,
                   text=hover)) +
  geom_tile(colour="white", linewidth=1) +
  geom_text(aes(label=tile_label),
            size=3.5, colour="white", fontface="bold", lineheight=1.2) +
  scale_fill_gradientn(
    colours  = c("#74b9ff","#0984e3","#fdcb6e","#e17055","#6c2327"),
    na.value = "#b2b2b2",
    name     = "Median\nSalary\n(M VND)"
  ) +
  scale_x_discrete(position="top") +
  labs(x=NULL, y=NULL) +
  theme_minimal(base_size=12) +
  theme(
    panel.grid     = element_blank(),
    axis.text.x    = element_text(size=12, face="bold", colour="#2c3e50"),
    axis.text.y    = element_text(size=11,              colour="#2c3e50"),
    legend.position = "right"
  )

ggplotly(p, tooltip="text") %>%
  layout(margin=list(t=20, b=10, l=10, r=10)) %>%
  config(displayModeBar=FALSE)

})

# ── Salary: City sorted bar with drill-down (NEW) ─────── output$city_sal_title <- renderUI({ city <- drilldown_city() if (is.null(city)) “Median Salary by City – sorted high to low (click to drill down)” else paste0(“Drill-down:”, city, ” – Median Salary by Seniority Level”) })

output$plot_city_sal <- renderPlotly({ city <- drilldown_city()

base <- df_filt() %>%
  filter(unit=="vnd", !is.na(salary_mid),
         salary_mid>0, salary_mid<=150)

if (is.null(city)) {
  # ── Top-level: sorted horizontal bars by city ────────
  d <- base %>%
    filter(city_clean != "Other", !is.na(city_clean)) %>%
    group_by(city_clean) %>%
    summarise(
      med_sal  = round(median(salary_mid, na.rm=TRUE), 1),
      mean_sal = round(mean(salary_mid,   na.rm=TRUE), 1),
      n        = n(), .groups="drop"
    ) %>%
    filter(n >= 30) %>%
    arrange(desc(med_sal)) %>%               # sort high → low
    mutate(city_clean = factor(city_clean, levels=rev(city_clean)))

  fig <- plot_ly(d,
    x           = ~med_sal,
    y           = ~city_clean,
    type        = "bar",
    orientation = "h",
    marker      = list(
      color     = ~med_sal,
      colorscale = list(c(0,"#74b9ff"), c(0.5,"#fdcb6e"), c(1,"#6c2327")),
      showscale  = FALSE,
      line       = list(color="white", width=0.5)
    ),
    text        = ~paste0(med_sal, " M"),
    textposition = "outside",
    cliponaxis  = FALSE,
    customdata  = ~city_clean,
    hovertemplate = paste0(
      "<b>%{y}</b><br>",
      "Median: %{x} M VND<br>",
      "Mean: ", d$mean_sal, " M VND<br>",
      "n = ", format(d$n, big.mark=","),
      "<extra></extra>"
    ),
    source = "city_sal_plot"
  ) %>%
    layout(
      xaxis  = list(title="Median Salary (M VND)",
                    range=c(0, max(d$med_sal)*1.2)),
      yaxis  = list(title=""),
      margin = list(l=10, r=60, t=10, b=40)
    ) %>%
    event_register("plotly_click") %>%
    config(displayModeBar=FALSE)

  fig

} else {
  # ── Drill-down: seniority breakdown for clicked city ──
  d <- base %>%
    filter(city_clean == city,
           level_clean %in% level_choices) %>%
    group_by(level_clean) %>%
    summarise(
      med_sal = round(median(salary_mid, na.rm=TRUE), 1),
      n       = n(), .groups="drop"
    ) %>%
    arrange(desc(med_sal)) %>%
    mutate(level_clean = factor(level_clean, levels=rev(level_clean)))

  plot_ly(d,
    x           = ~med_sal,
    y           = ~level_clean,
    type        = "bar",
    orientation = "h",
    marker      = list(
      color     = ~med_sal,
      colorscale = list(c(0,"#74b9ff"), c(0.5,"#fdcb6e"), c(1,"#6c2327")),
      showscale  = FALSE
    ),
    text        = ~paste0(med_sal, " M  (n=", n, ")"),
    textposition = "outside",
    cliponaxis  = FALSE,
    hovertemplate = paste0(
      "<b>%{y}</b><br>",
      "Median: %{x} M VND<br>",
      "n = %{customdata}<extra></extra>"
    ),
    customdata = ~n
  ) %>%
    layout(
      xaxis  = list(title="Median Salary (M VND)",
                    range=c(0, max(d$med_sal, na.rm=TRUE)*1.25)),
      yaxis  = list(title=""),
      margin = list(l=10, r=80, t=10, b=40)
    ) %>%
    config(displayModeBar=FALSE)
}

})

# Listen for click on city bar → drill down observeEvent(event_data(“plotly_click”, source=“city_sal_plot”), { click <- event_data(“plotly_click”, source=“city_sal_plot”) if (!is.null(click) && is.null(drilldown_city())) { clicked_city <- as.character(click$y) if (clicked_city %in% city_choices) { drilldown_city(clicked_city) # Show back button via JS (shinyjs not loaded, use Shiny tricks) runjs_safe <- function(expr) tryCatch(shinyjs::show(“btn_back_city”), error=function(e) NULL) runjs_safe() } } }) # Fallback: show back button when drill-down active observe({ if (!is.null(drilldown_city())) { shinyjs::show(“btn_back_city”) } else { shinyjs::hide(“btn_back_city”) } })

# ── Salary: Dot-range Q1–Median–Q3 by seniority ───────── output$plot_dotrange <- renderPlotly({ d <- sal_base() %>% group_by(level_clean) %>% summarise(q1=quantile(salary_mid,.25,na.rm=TRUE), med=median(salary_mid,na.rm=TRUE), q3=quantile(salary_mid,.75,na.rm=TRUE), n=n(), .groups=“drop”) %>% filter(n>=10) %>% mutate( level_clean = factor(level_clean, levels=level_choices), hover = paste0(“”,level_clean,“
”, “Q1:”,round(q1,1),” M
“,”Median: “,round(med,1),” M
“,”Q3: “,round(q3,1),” M
“,”n = “,format(n,big.mark=”,“)) ) pal <- viridis::viridis(max(nrow(d),1), option=”C”) plot_ly() %>% add_segments(data=d, x=~q1, xend=~q3, y=~level_clean, yend=~level_clean, line=list(width=8,color=“rgba(150,150,210,0.25)”), showlegend=FALSE, hoverinfo=“none”) %>% add_markers(data=d, x=~q1, y=~level_clean, marker=list(symbol=“line-ns”,size=14,line=list(color=pal,width=3)), showlegend=FALSE, hoverinfo=“none”) %>% add_markers(data=d, x=~q3, y=~level_clean, marker=list(symbol=“line-ns”,size=14,line=list(color=pal,width=3)), showlegend=FALSE, hoverinfo=“none”) %>% add_markers(data=d, x=~med, y=~level_clean, text=~hover, hoverinfo=“text”, marker=list(size=16,color=“white”, line=list(color=pal,width=3)), showlegend=FALSE) %>% layout(xaxis=list(title=“Salary (M VND)”, zeroline=FALSE), yaxis=list(title=““, autorange=”reversed”, tickfont=list(size=10)), margin=list(l=140,r=10)) %>% config(displayModeBar=FALSE) })

# ── Fields: Single bar + salary annotation (NEW) ──────── output\(plot_fields_bar <- renderPlotly({ base <- fields_long %>% filter(emp_type %in% input\)emp_filter) %>% { if (input\(city_filter != "All") filter(., city_clean==input\)city_filter) else . }

counts <- base %>%
  count(job_fields, name="n") %>%
  arrange(desc(n)) %>%
  slice_head(n=input$n_fields)

top_fields <- counts$job_fields

sal <- base %>%
  filter(job_fields %in% top_fields,
         unit=="vnd", !is.na(salary_mid),
         salary_mid>0, salary_mid<=150) %>%
  group_by(job_fields) %>%
  summarise(med_sal=round(median(salary_mid,na.rm=TRUE),1), .groups="drop")

d <- counts %>%
  left_join(sal, by="job_fields") %>%
  arrange(n) %>%
  mutate(
    job_fields  = factor(job_fields, levels=job_fields),
    sal_label   = if_else(is.na(med_sal), "N/A", paste0(med_sal," M")),
    bar_colour  = "#2980b9",
    hover       = paste0(
      "<b>", job_fields, "</b><br>",
      "Listings: <b>", format(n, big.mark=","), "</b><br>",
      "Median Salary: <b>",
      if_else(is.na(med_sal),"N/A",paste0(med_sal," M VND")),
      "</b>"
    )
  )

plot_ly(d,
  x           = ~n,
  y           = ~job_fields,
  type        = "bar",
  orientation = "h",
  marker      = list(
    color     = ~n,
    colorscale = list(c(0,"#85c1e9"), c(1,"#1a5276")),
    showscale  = FALSE,
    line       = list(color="white", width=0.3)
  ),
  text        = ~sal_label,           # salary label INSIDE / end of bar
  textposition = "inside",
  insidetextanchor = "end",
  textfont    = list(color="white", size=11, family="Arial Bold"),
  customdata  = ~hover,
  hovertemplate = "%{customdata}<extra></extra>"
) %>%
  layout(
    xaxis  = list(title="Number of Listings", tickformat=","),
    yaxis  = list(title="", tickfont=list(size=11)),
    margin = list(l=10, r=20, t=10, b=50),
    annotations = list(list(
      text      = "Label on bar = Median Salary (M VND)",
      x         = 0.5, y = -0.11,
      xref      = "paper", yref="paper",
      showarrow = FALSE,
      font      = list(size=11, color="#888888"),
      xanchor   = "center"
    ))
  ) %>%
  config(displayModeBar=FALSE)

})

# ── Fields: City × Field heatmap ──────────────────────── output\(plot_city_fields <- renderPlotly({ global_top <- fields_long %>% filter(emp_type %in% input\)emp_filter) %>% count(job_fields) %>% arrange(desc(n)) %>% slice_head(n=input$n_fields) %>% pull(job_fields)

d <- fields_long %>%
  filter(emp_type %in% input$emp_filter,
         city_clean != "Other", !is.na(city_clean),
         job_fields %in% global_top) %>%
  count(city_clean, job_fields) %>%
  group_by(city_clean) %>%
  mutate(pct=round(n/sum(n)*100,1)) %>% ungroup() %>%
  mutate(job_fields=factor(job_fields, levels=rev(global_top)))

p <- ggplot(d, aes(x=city_clean, y=job_fields, fill=pct,
                   text=paste0("<b>",city_clean,"</b> | <b>",job_fields,"</b><br>",
                               pct,"% of city listings (n=",
                               format(n,big.mark=","),")"))) +
  geom_tile(colour="white", linewidth=0.5) +
  geom_text(aes(label=paste0(pct,"%")), size=2.8, colour="white", fontface="bold") +
  scale_fill_viridis_c(option="D", direction=-1, name="% of city\nlistings") +
  labs(x=NULL, y=NULL) +
  theme_minimal(base_size=11) +
  theme(panel.grid=element_blank(),
        axis.text.x=element_text(angle=30,hjust=1,size=10),
        axis.text.y=element_text(size=10))
ggplotly(p, tooltip="text") %>%
  layout(margin=list(b=80)) %>% config(displayModeBar=FALSE)

})

# ── Data Table ─────────────────────────────────────────── output$data_tbl <- renderDT({ df_filt() %>% select(job_title, emp_type, level_clean, city_clean, exp_clean, salary, unit) %>% rename(Job Title=job_title, Employment Type=emp_type, Level=level_clean, City=city_clean, Experience=exp_clean, Salary=salary, Currency=unit) %>% datatable(filter=“top”, options=list(pageLength=15,scrollX=TRUE,dom=“lrtip”), rownames=FALSE, class=“stripe hover compact”) })

} # end server

shinyApp(ui, server)