library(shiny) library(shinyjs) # for show/hide Back button library(shinydashboard) library(tidyverse) library(scales) library(viridis) library(DT) library(plotly)
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ộ”) )
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"))
)
)
)
) )
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:,}
# ── 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)