library(shiny) library(bslib) library(htmltools) library(fontawesome) library(bsicons) library(gt) library(colourpicker) library(glue) library(ggplot2) library(readr) library(dplyr) library(mapgl) library(duckdbfs) duckdbfs::load_spatial() css <- HTML(paste0("")) # Define the UI ui <- page_sidebar( fillable = FALSE, # do not squeeze to vertical screen space tags$head(css), titlePanel("Demo App"), " This is a proof-of-principle for a simple chat-driven interface to dynamically explore geospatial data. ", card( layout_columns( textInput("chat", label = NULL, "Which four counties in California have the highest average social vulnerability?", width = "100%"), div( actionButton("user_msg", "", icon = icon("paper-plane"), class = "btn-primary btn-sm align-bottom"), class = "align-text-bottom"), col_widths = c(11, 1)), fill = FALSE ), textOutput("agent"), layout_columns( card(maplibreOutput("map")), card(includeMarkdown("## Plot"), plotOutput("chart1"), plotOutput("chart2"), ), col_widths = c(8, 4), row_heights = c("500px"), max_height = "600px" ), gt_output("table"), card(fill = TRUE, card_header(fa("robot"), textOutput("model", inline = TRUE)), accordion( open = FALSE, accordion_panel( title = "show sql", icon = fa("terminal"), verbatimTextOutput("sql_code"), ), accordion_panel( title = "explain", icon = fa("user", prefer_type="solid"), textOutput("explanation"), ) ), ), card( card_header("Errata"), shiny::markdown(readr::read_file("footer.md")), ), sidebar = sidebar( selectInput( "select", "Select an LLM:", list("LLama3" = "llama3", #"OLMO2 (AllenAI)" = "olmo", "Gorilla (UC Berkeley)" = "gorilla" ) ), input_switch("redlines", "Redlined Areas", value = FALSE), input_switch("svi", "Social Vulnerability", value = TRUE), input_switch("richness", "Biodiversity Richness", value = FALSE), input_switch("rsr", "Biodiversity Range Size Rarity", value = FALSE), card( card_header(bs_icon("github"), "Source code:"), a(href = "https://github.com/boettiger-lab/geo-llm-r", "https://github.com/boettiger-lab/geo-llm-r")) ), theme = bs_theme(version = "5") ) repo <- "https://data.source.coop/cboettig/social-vulnerability" pmtiles <- glue("{repo}/2022/SVI2022_US_tract.pmtiles") parquet <- glue("{repo}/2022/SVI2022_US_tract.parquet") con <- duckdbfs::cached_connection() svi <- open_dataset(parquet, tblname = "svi") |> filter(RPL_THEMES > 0) safe_parse <- function(txt) { gsub("[\r\n]", " ", txt) |> gsub("\\s+", " ", x = _) } # helper utilities # faster/more scalable to pass maplibre the ids to refilter pmtiles, # than to pass it the full geospatial/sf object filter_column <- function(full_data, filtered_data, id_col = "FIPS") { if (nrow(filtered_data) < 1) return(NULL) values <- full_data |> inner_join(filtered_data, copy = TRUE) |> pull(id_col) # maplibre syntax for the filter of PMTiles list("in", list("get", id_col), list("literal", values)) } # Define the server server <- function(input, output, session) { chart1_data <- svi |> group_by(COUNTY) |> summarise(mean_svi = mean(RPL_THEMES)) |> collect() chart1 <- chart1_data |> ggplot(aes(mean_svi)) + geom_density(fill="darkred") + ggtitle("County-level vulnerability nation-wide") data <- reactiveValues(df = tibble()) output$chart1 <- renderPlot(chart1) model <- reactive(input$select) output$model <- renderText(input$select) observe({ schema <- read_file("schema.yml") system_prompt <- glue::glue(readr::read_file("system-prompt.md"), .open = "<", .close = ">") chat <- ellmer::chat_vllm( base_url = "https://llm.nrp-nautilus.io/", model = model(), api_key = Sys.getenv("NRP_API_KEY"), system_prompt = system_prompt, api_args = list(temperature = 0) ) observeEvent(input$user_msg, { stream <- chat$chat(input$chat) # Parse response response <- jsonlite::fromJSON(safe_parse(stream)) #response <- jsonlite::fromJSON(stream) if ("query" %in% names(response)) { output$sql_code <- renderText(stringr::str_wrap(response$query, width = 60)) output$explanation <- renderText(response$explanation) # Actually execute the SQL query generated: df <- DBI::dbGetQuery(con, response$query) # don't display shape column in render df <- df |> select(-any_of("Shape")) output$table <- render_gt(df, height = 300) y_axis <- colnames(df)[!colnames(df) %in% colnames(svi)] chart2 <- df |> rename(social_vulnerability = y_axis) |> ggplot(aes(social_vulnerability)) + geom_density(fill = "darkred") + xlim(c(0, 1)) + ggtitle("Vulnerability of selected areas") output$chart2 <- renderPlot(chart2) # We need to somehow trigger this df to update the map. data$df <- df # Note: ellmer will preserve full chat history automatically. # this can confuse the agent and mess up behavior, so we reset: chat$set_turns(NULL) } else { output$agent <- renderText(response$agent) } }) }) output$map <- renderMaplibre({ m <- maplibre(center = c(-104.9, 40.3), zoom = 3, height = "400") if (input$redlines) { m <- m |> add_fill_layer( id = "redlines", source = list(type = "vector", url = paste0("pmtiles://", "https://data.source.coop/cboettig/us-boundaries/mappinginequality.pmtiles")), source_layer = "mappinginequality", fill_color = list("get", "fill") ) } if (input$richness) { m <- m |> add_raster_source(id = "richness", tiles = "https://data.source.coop/cboettig/mobi/tiles/red/species-richness-all/{z}/{x}/{y}.png", maxzoom = 11 ) |> add_raster_layer(id = "richness-layer", source = "richness") } if (input$rsr) { m <- m |> add_raster_source(id = "rsr", tiles = "https://data.source.coop/cboettig/mobi/tiles/green/range-size-rarity-all/{z}/{x}/{y}.png", maxzoom = 11 ) |> add_raster_layer(id = "richness-layer", source = "rsr") } if (input$svi) { m <- m |> add_fill_layer( id = "svi_layer", source = list(type = "vector", url = paste0("pmtiles://", pmtiles)), source_layer = "svi", tooltip = "RPL_THEMES", filter = filter_column(svi, data$df, "FIPS"), fill_opacity = 0.5, fill_color = interpolate(column = "RPL_THEMES", values = c(0, 1), stops = c("lightpink", "darkred"), na_color = "lightgrey") ) } m |> add_draw_control() |> add_geocoder_control() }) } # Run the app shinyApp(ui = ui, server = server)