diff --git a/pokemon-dimensionality-reduction/DESCRIPTION b/pokemon-dimensionality-reduction/DESCRIPTION new file mode 100644 index 0000000..e82bc0c --- /dev/null +++ b/pokemon-dimensionality-reduction/DESCRIPTION @@ -0,0 +1,4 @@ +Title: Pokémon Dimensionality Reduction +Description: Explore how PCA, t-SNE, and UMAP arrange Pokémon using stats, types, and egg groups. +Categories: machine learning, visualization, dimensionality reduction +Runtime: server diff --git a/pokemon-dimensionality-reduction/app.R b/pokemon-dimensionality-reduction/app.R new file mode 100644 index 0000000..fabab12 --- /dev/null +++ b/pokemon-dimensionality-reduction/app.R @@ -0,0 +1,398 @@ +# packages ---------------------------------------------------------------- +library(shiny) +library(bslib) +library(dplyr) +library(purrr) +library(highcharter) +library(Rtsne) +library(uwot) + +# data -------------------------------------------------------------------- +source("prepare_data.R", local = TRUE) + +pokemon_bundle <- prepare_pokemon_data( + cache_file = file.path(tempdir(), "visual-data-lab-pokemon.rds") +) + +pokemon <- pokemon_bundle$data +pokemon_x <- pokemon_bundle$x + +# pokemon_x is already prepared for mixed data: +# - continuous variables are standardized; +# - binary variables stay 0/1; +# - nominal variables are one-hot encoded; +# - semantic blocks are normalized so dummy-heavy groups do not dominate. + +# theme ------------------------------------------------------------------- +apptheme <- bs_theme( + version = 5, + bg = "#f5f7fb", + fg = "#172033", + primary = "#2a75bb", + secondary = "#ffcb05" +) + +sidebar <- purrr::partial(bslib::sidebar, width = 305) +card <- purrr::partial(bslib::card, full_screen = TRUE) + +# helpers ----------------------------------------------------------------- +method_label <- function(method) { + switch( + method, + pca = "PCA", + tsne = "t-SNE", + umap = "UMAP", + method + ) +} + +run_projection <- function( + method, + x, + perplexity, + iterations, + n_neighbors, + min_dist, + seed +) { + set.seed(as.integer(seed)) + + if (identical(method, "pca")) { + # PCA still centers the final mixed feature matrix. We do not scale again: + # doing so would undo the deliberate 0/1 treatment and block weighting. + fit <- stats::prcomp(x, center = TRUE, scale. = FALSE) + return(fit$x[, 1:2, drop = FALSE]) + } + + if (identical(method, "tsne")) { + max_perplexity <- max(2, floor((nrow(x) - 1) / 3) - 1) + perplexity <- min(as.numeric(perplexity), max_perplexity) + + fit <- Rtsne::Rtsne( + x, + dims = 2, + perplexity = perplexity, + max_iter = as.integer(iterations), + check_duplicates = FALSE, + pca = TRUE, + verbose = FALSE + ) + + return(fit$Y) + } + + # UMAP and t-SNE both consume the same deliberately prepared Euclidean + # feature space, which makes their projections comparable at the input level. + uwot::umap( + x, + n_components = 2, + n_neighbors = min(as.integer(n_neighbors), nrow(x) - 1L), + min_dist = as.numeric(min_dist), + metric = "euclidean", + n_threads = 1, + verbose = FALSE + ) +} + +make_points <- function(data, xy) { + data |> + mutate( + x = xy[, 1], + y = xy[, 2], + pokemon_label = stringr::str_to_title(stringr::str_replace_all(pokemon, "-", " ")), + type_1_label = stringr::str_to_title(type_1), + type_2_label = if_else(type_2 == "none", "—", stringr::str_to_title(type_2)), + generation_label = stringr::str_replace(generation, "GENERATION-", "Gen "), + height_m = round(height / 10, 1), + weight_kg = round(weight / 10, 1), + growth_rate_label = stringr::str_to_title(stringr::str_replace_all(growth_rate, "-", " ")), + habitat_label = if_else( + habitat == "unknown", + "Unknown", + stringr::str_to_title(stringr::str_replace_all(habitat, "-", " ")) + ), + special_status = case_when( + is_mythical == 1 ~ "Mythical", + is_legendary == 1 ~ "Legendary", + is_baby == 1 ~ "Baby", + TRUE ~ "—" + ) + ) |> + select( + x, y, pokemon_label, type_1_label, type_2_label, + generation_label, type_color, height_m, weight_kg, + hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, + growth_rate_label, habitat_label, special_status, + sprite_url, artwork_url + ) |> + purrr::pmap(function( + x, y, pokemon_label, type_1_label, type_2_label, + generation_label, type_color, height_m, weight_kg, + hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, + growth_rate_label, habitat_label, special_status, + sprite_url, artwork_url + ) { + list( + x = x, + y = y, + name = pokemon_label, + pokemon = pokemon_label, + type_1 = type_1_label, + type_2 = type_2_label, + generation = generation_label, + type_color = type_color, + height_m = height_m, + weight_kg = weight_kg, + hp = hp, + attack = attack, + defense = defense, + special_attack = special_attack, + special_defense = special_defense, + speed = speed, + capture_rate = capture_rate, + base_happiness = base_happiness, + hatch_counter = hatch_counter, + growth_rate = growth_rate_label, + habitat = habitat_label, + special_status = special_status, + artwork_url = artwork_url, + color = type_color, + marker = list( + symbol = sprintf("url(%s)", sprite_url), + width = 26, + height = 26 + ) + ) + }) +} + +tooltip_html <- paste0( + '
', + '
', + '', + '
', + '
{point.pokemon}
', + '
{point.generation}
', + '
', + '{point.type_1}', + '{point.type_2}', + '
', + '
{point.height_m} m · {point.weight_kg} kg
', + '
', + '', + '', + '', + '', + '', + '', + '', + '
HP{point.hp}Attack{point.attack}
Defense{point.defense}Speed{point.speed}
Sp. Atk{point.special_attack}Sp. Def{point.special_defense}
Capture{point.capture_rate}Happiness{point.base_happiness}
Growth{point.growth_rate}Habitat{point.habitat}
Status{point.special_status}Hatch{point.hatch_counter}
', + '
' +) + +# ui ---------------------------------------------------------------------- +ui <- page_fillable( + title = "Pokémon Dimensionality Reduction", + theme = apptheme, + padding = 0, + tags$head(htmltools::includeCSS("styles.css")), + div( + class = "pokemon-topbar", + div( + class = "pokemon-brand", + span(class = "pokeball-mark"), + div( + div(class = "pokemon-brand-title", "Pokémon Lab"), + div(class = "pokemon-brand-subtitle", "Dimensionality Reduction") + ) + ), + div(class = "pokemon-topbar-caption", "Gotta project 'em all!") + ), + layout_sidebar( + fill = TRUE, + sidebar = sidebar( + title = "Projection", + selectInput( + "method", + "Method", + choices = c("PCA" = "pca", "t-SNE" = "tsne", "UMAP" = "umap"), + selected = "tsne" + ), + conditionalPanel( + "input.method === 'tsne'", + sliderInput( + "perplexity", + "Perplexity", + min = 5, + max = 100, + value = 40, + step = 5 + ), + sliderInput( + "iterations", + "Iterations", + min = 250, + max = 2000, + value = 750, + step = 250 + ) + ), + conditionalPanel( + "input.method === 'umap'", + sliderInput( + "n_neighbors", + "Neighbors", + min = 2, + max = 100, + value = 30, + step = 1 + ), + sliderInput( + "min_dist", + "Minimum distance", + min = 0, + max = 0.95, + value = 0.15, + step = 0.05 + ) + ), + numericInput("seed", "Seed", value = 13242, min = 1, step = 1), + actionButton( + "run", + "Run projection", + class = "btn btn-primary pokemon-run-button" + ), + div( + class = "pokemon-sidebar-note", + strong(format(nrow(pokemon), big.mark = ",")), + " Pokémon · stats + capture + species traits" + ), + accordion( + open = FALSE, + accordion_panel( + "How it works", + tags$small(htmltools::includeMarkdown("readme.md")) + ) + ), + tags$small(htmltools::includeMarkdown("credits.md")) + ), + card( + class = "pokemon-card", + card_header( + div( + class = "pokemon-chart-header", + div( + div(class = "pokemon-chart-kicker", "Projection map"), + div(class = "pokemon-chart-title", textOutput("chart_title", inline = TRUE)) + ), + div(class = "pokemon-chart-meta", textOutput("chart_meta", inline = TRUE)) + ) + ), + card_body( + class = "pokemon-chart-body", + highchartOutput("embedding", height = "calc(100vh - 154px)") + ) + ) + ) +) + +# server ------------------------------------------------------------------ +server <- function(input, output, session) { + projection <- eventReactive( + input$run, + { + withProgress(message = paste("Running", method_label(input$method)), value = 0.2, { + xy <- run_projection( + method = input$method, + x = pokemon_x, + perplexity = input$perplexity, + iterations = input$iterations, + n_neighbors = input$n_neighbors, + min_dist = input$min_dist, + seed = input$seed + ) + incProgress(0.8) + xy + }) + }, + ignoreNULL = FALSE + ) + + output$chart_title <- renderText({ + paste(method_label(input$method), "Pokémon map") + }) + + output$chart_meta <- renderText({ + if (identical(input$method, "pca")) { + return("Linear baseline") + } + + if (identical(input$method, "tsne")) { + return(paste0("Perplexity ", input$perplexity, " · ", input$iterations, " iterations")) + } + + paste0("", input$n_neighbors, " neighbors · min dist ", input$min_dist) + }) + + output$embedding <- renderHighchart({ + xy <- projection() + points <- make_points(pokemon, xy) + + highchart() |> + hc_chart( + type = "scatter", + zooming = list(type = "xy"), + backgroundColor = "transparent", + animation = FALSE, + spacing = list(18, 18, 18, 18) + ) |> + hc_title(text = NULL) |> + hc_xAxis( + title = list(text = NULL), + labels = list(enabled = FALSE), + tickLength = 0, + gridLineWidth = 0, + lineWidth = 0 + ) |> + hc_yAxis( + title = list(text = NULL), + labels = list(enabled = FALSE), + tickLength = 0, + gridLineWidth = 0, + lineWidth = 0 + ) |> + hc_add_series( + data = points, + name = "Pokémon", + turboThreshold = 0, + showInLegend = FALSE + ) |> + hc_tooltip( + useHTML = TRUE, + outside = TRUE, + borderWidth = 0, + borderRadius = 14, + shadow = TRUE, + padding = 0, + headerFormat = "", + pointFormat = tooltip_html + ) |> + hc_plotOptions( + series = list( + animation = FALSE, + cursor = "pointer", + states = list( + hover = list( + halo = list(size = 34, opacity = 0.24), + brightness = 0.08 + ) + ) + ) + ) |> + hc_credits(enabled = FALSE) + }) +} + +shinyApp(ui, server) diff --git a/pokemon-dimensionality-reduction/credits.md b/pokemon-dimensionality-reduction/credits.md new file mode 100644 index 0000000..d2e18a3 --- /dev/null +++ b/pokemon-dimensionality-reduction/credits.md @@ -0,0 +1 @@ +App made by [Joshua Kunst](https://jkunst.com) with ❤️ and ☕ using Shiny for R ✨. Code [here](https://github.com/jbkunst/visual-data-lab). diff --git a/pokemon-dimensionality-reduction/prepare_data.R b/pokemon-dimensionality-reduction/prepare_data.R new file mode 100644 index 0000000..85653bc --- /dev/null +++ b/pokemon-dimensionality-reduction/prepare_data.R @@ -0,0 +1,307 @@ +# Pokémon data ------------------------------------------------------------- + +pokemon_type_colors <- c( + normal = "#A8A77A", fire = "#EE8130", water = "#6390F0", + electric = "#F7D02C", grass = "#7AC74C", ice = "#96D9D6", + fighting = "#C22E28", poison = "#A33EA1", ground = "#E2BF65", + flying = "#A98FF3", psychic = "#F95587", bug = "#A6B91A", + rock = "#B6A136", ghost = "#735797", dragon = "#6F35FC", + dark = "#705746", steel = "#B7B7CE", fairy = "#D685AD" +) + +pokeapi_csv <- function(file, ref = "master") { + url <- paste0( + "https://raw.githubusercontent.com/PokeAPI/pokeapi/", + ref, + "/data/v2/csv/", + file + ) + + readr::read_csv(url, show_col_types = FALSE) +} + +# Continuous variables live on very different scales (kg, stats, capture rate, +# etc.). Median-impute missing values, then standardize only these variables. +# Binary indicators are deliberately NOT standardized: a rare 1 should not +# acquire a huge z-score just because its prevalence is low. +scale_continuous_features <- function(data) { + data <- as.data.frame(data) + + data[] <- lapply(data, function(x) { + x <- as.numeric(x) + replacement <- stats::median(x, na.rm = TRUE) + + if (!is.finite(replacement)) { + replacement <- 0 + } + + x[is.na(x)] <- replacement + x + }) + + x <- as.matrix(data) + center <- colMeans(x) + spread <- apply(x, 2, stats::sd) + spread[!is.finite(spread) | spread == 0] <- 1 + + x <- sweep(x, 2, center, FUN = "-") + sweep(x, 2, spread, FUN = "/") +} + +# Equalizing by sqrt(number of columns) stops a block from dominating the +# Euclidean distance merely because it expands into many dummy columns. +# The named weights are intentionally explicit so they are easy to review or +# tune later. A weight of 1 is the neutral starting point for every block. +weight_feature_block <- function(x, weight = 1) { + x <- as.matrix(x) + + if (!ncol(x)) { + return(x) + } + + x * (as.numeric(weight) / sqrt(ncol(x))) +} + +pokemon_feature_matrix <- function( + data, + block_weights = c( + continuous = 1, + binary = 1, + types = 1, + egg_groups = 1, + species_traits = 1 + ) +) { + required_weights <- c( + "continuous", "binary", "types", "egg_groups", "species_traits" + ) + + if (!all(required_weights %in% names(block_weights))) { + stop("block_weights must define all feature blocks.", call. = FALSE) + } + + # gender_rate uses -1 for genderless Pokémon and otherwise stores the female + # proportion in eighths. Split that into a bounded continuous proportion plus + # a separate binary genderless flag rather than treating -1 as a real ratio. + feature_data <- data |> + dplyr::mutate( + female_ratio = dplyr::if_else( + gender_rate < 0, + NA_real_, + as.numeric(gender_rate) / 8 + ), + genderless = as.numeric(gender_rate < 0) + ) + + continuous_features <- feature_data |> + dplyr::select( + height, weight, base_experience, + hp, attack, defense, special_attack, special_defense, speed, + capture_rate, base_happiness, hatch_counter, female_ratio + ) |> + scale_continuous_features() |> + weight_feature_block(block_weights[["continuous"]]) + + # Keep binary variables as 0/1. Standardizing a very rare flag such as + # is_mythical would over-amplify it in t-SNE/UMAP distance calculations. + binary_features <- feature_data |> + dplyr::transmute( + is_baby = as.numeric(tidyr::replace_na(is_baby, 0)), + is_legendary = as.numeric(tidyr::replace_na(is_legendary, 0)), + is_mythical = as.numeric(tidyr::replace_na(is_mythical, 0)), + has_gender_differences = as.numeric( + tidyr::replace_na(has_gender_differences, 0) + ), + forms_switchable = as.numeric(tidyr::replace_na(forms_switchable, 0)), + genderless = as.numeric(tidyr::replace_na(genderless, 0)) + ) |> + as.matrix() |> + weight_feature_block(block_weights[["binary"]]) + + # One-hot encode nominal variables. Keep the semantic groups separate so a + # high-cardinality group does not automatically receive more total weight. + type_features <- feature_data |> + dplyr::transmute( + type_1 = factor(type_1), + type_2 = factor(type_2) + ) |> + stats::model.matrix(~ type_1 + type_2 - 1, data = _) |> + weight_feature_block(block_weights[["types"]]) + + egg_group_features <- feature_data |> + dplyr::transmute( + egg_group_1 = factor(egg_group_1), + egg_group_2 = factor(egg_group_2) + ) |> + stats::model.matrix(~ egg_group_1 + egg_group_2 - 1, data = _) |> + weight_feature_block(block_weights[["egg_groups"]]) + + species_trait_features <- feature_data |> + dplyr::transmute( + growth_rate = factor(growth_rate), + body_color = factor(body_color), + body_shape = factor(body_shape), + habitat = factor(habitat) + ) |> + stats::model.matrix( + ~ growth_rate + body_color + body_shape + habitat - 1, + data = _ + ) |> + weight_feature_block(block_weights[["species_traits"]]) + + features <- cbind( + continuous_features, + binary_features, + type_features, + egg_group_features, + species_trait_features + ) + + storage.mode(features) <- "double" + + attr(features, "block_weights") <- block_weights[required_weights] + attr(features, "preprocessing") <- paste( + "continuous=z-score; binary=0/1; categorical=one-hot;", + "each semantic block weighted by 1/sqrt(p)" + ) + + features +} + +prepare_pokemon_data <- function(cache_file = NULL, refresh = FALSE) { + if ( + !is.null(cache_file) && + file.exists(cache_file) && + !isTRUE(refresh) + ) { + return(readRDS(cache_file)) + } + + pokemon <- pokeapi_csv("pokemon.csv") |> + dplyr::filter(is_default == 1) |> + dplyr::transmute( + id, + species_id, + pokemon = identifier, + height, + weight, + base_experience + ) + + stats <- pokeapi_csv("stats.csv") |> + dplyr::transmute( + stat_id = id, + stat = stringr::str_replace_all(identifier, "-", "_") + ) |> + dplyr::right_join( + pokeapi_csv("pokemon_stats.csv") |> + dplyr::select(pokemon_id, stat_id, base_stat), + by = "stat_id" + ) |> + dplyr::select(pokemon_id, stat, base_stat) |> + tidyr::pivot_wider(names_from = stat, values_from = base_stat) |> + dplyr::rename(id = pokemon_id) + + types <- pokeapi_csv("types.csv") |> + dplyr::transmute(type_id = id, type = identifier) |> + dplyr::right_join( + pokeapi_csv("pokemon_types.csv") |> + dplyr::select(pokemon_id, type_id, slot), + by = "type_id" + ) |> + dplyr::mutate(slot = paste0("type_", slot)) |> + dplyr::select(pokemon_id, slot, type) |> + tidyr::pivot_wider(names_from = slot, values_from = type) |> + dplyr::rename(id = pokemon_id) + + egg_groups <- pokeapi_csv("egg_groups.csv") |> + dplyr::transmute(egg_group_id = id, egg_group = identifier) |> + dplyr::right_join( + pokeapi_csv("pokemon_egg_groups.csv") |> + dplyr::select(species_id, egg_group_id), + by = "egg_group_id" + ) |> + dplyr::group_by(species_id) |> + dplyr::mutate(slot = paste0("egg_group_", dplyr::row_number())) |> + dplyr::ungroup() |> + dplyr::select(species_id, slot, egg_group) |> + tidyr::pivot_wider(names_from = slot, values_from = egg_group) + + generations <- pokeapi_csv("generations.csv") |> + dplyr::transmute(generation_id = id, generation = identifier) + + growth_rates <- pokeapi_csv("growth_rates.csv") |> + dplyr::transmute(growth_rate_id = id, growth_rate = identifier) + + colors <- pokeapi_csv("pokemon_colors.csv") |> + dplyr::transmute(color_id = id, body_color = identifier) + + shapes <- pokeapi_csv("pokemon_shapes.csv") |> + dplyr::transmute(shape_id = id, body_shape = identifier) + + habitats <- pokeapi_csv("pokemon_habitats.csv") |> + dplyr::transmute(habitat_id = id, habitat = identifier) + + species <- pokeapi_csv("pokemon_species.csv") |> + dplyr::select( + id, generation_id, evolves_from_species_id, evolution_chain_id, + color_id, shape_id, habitat_id, gender_rate, capture_rate, + base_happiness, is_baby, hatch_counter, has_gender_differences, + growth_rate_id, forms_switchable, is_legendary, is_mythical + ) |> + dplyr::rename(species_id = id) |> + dplyr::left_join(generations, by = "generation_id") |> + dplyr::left_join(growth_rates, by = "growth_rate_id") |> + dplyr::left_join(colors, by = "color_id") |> + dplyr::left_join(shapes, by = "shape_id") |> + dplyr::left_join(habitats, by = "habitat_id") + + data <- pokemon |> + dplyr::left_join(types, by = "id") |> + dplyr::left_join(stats, by = "id") |> + dplyr::left_join(egg_groups, by = "species_id") |> + dplyr::left_join(species, by = "species_id") |> + dplyr::mutate( + type_2 = tidyr::replace_na(type_2, "none"), + egg_group_1 = tidyr::replace_na(egg_group_1, "none"), + egg_group_2 = tidyr::replace_na(egg_group_2, "none"), + growth_rate = tidyr::replace_na(growth_rate, "unknown"), + body_color = tidyr::replace_na(body_color, "unknown"), + body_shape = tidyr::replace_na(body_shape, "unknown"), + habitat = tidyr::replace_na(habitat, "unknown"), + generation = stringr::str_to_upper(generation), + type_color = unname(pokemon_type_colors[type_1]), + sprite_url = paste0( + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/", + id, + ".png" + ), + artwork_url = paste0( + "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/", + id, + ".png" + ) + ) |> + dplyr::arrange(id) + + # Build once so the exact same matrix is used by PCA, t-SNE and UMAP. + # Centering inside PCA does not change the binary/categorical pairwise + # differences used by the nonlinear methods. + feature_matrix <- pokemon_feature_matrix(data) + + result <- list( + data = data, + x = feature_matrix, + feature_names = colnames(feature_matrix), + block_weights = attr(feature_matrix, "block_weights"), + preprocessing = attr(feature_matrix, "preprocessing"), + source = "PokeAPI/pokeapi + PokeAPI/sprites" + ) + + if (!is.null(cache_file)) { + dir.create(dirname(cache_file), recursive = TRUE, showWarnings = FALSE) + try(saveRDS(result, cache_file), silent = TRUE) + } + + result +} diff --git a/pokemon-dimensionality-reduction/readme.md b/pokemon-dimensionality-reduction/readme.md new file mode 100644 index 0000000..8371dd4 --- /dev/null +++ b/pokemon-dimensionality-reduction/readme.md @@ -0,0 +1,31 @@ +## How it works + +The app builds a mixed-data feature matrix from the maintained PokeAPI tables. It combines morphology, battle stats, capture and breeding traits, binary species flags, Pokémon types, egg groups, growth rate, body color, body shape, and habitat. + +### Preprocessing + +The feature space is prepared deliberately for PCA, t-SNE, and UMAP: + +- **Continuous variables** such as height, weight, battle stats, capture rate, happiness, hatch counter, and female ratio are median-imputed and standardized. +- **Binary variables** such as legendary, mythical, baby, genderless, and form/gender flags stay as **0/1**. Rare flags are not standardized, because doing so could give a rare `1` an artificially huge z-score. +- **Nominal variables** such as type, egg group, growth rate, color, shape, and habitat are one-hot encoded as **0/1** indicators. +- Feature blocks are divided by `sqrt(number of columns in the block)`. This prevents a categorical block from dominating Euclidean distance simply because it expands into many dummy columns. + +The block weights are explicit in `prepare_data.R` and all default to `1`, so the preprocessing is easy to inspect or tune later. + +Choose a projection method and change its main parameters: + +- **PCA** gives a fast linear baseline. +- **t-SNE** exposes perplexity and iteration count. +- **UMAP** exposes neighborhood size and minimum distance. + +Click **Run projection** to recompute the two-dimensional coordinates. The chart uses the regular PokeAPI sprite as the point marker. Hover a Pokémon to see its official artwork, types, generation, size, battle stats, capture rate, happiness, growth rate, habitat, hatch counter, and legendary/mythical status. + +The coordinates are exploratory rather than a distance metric to interpret literally. They are useful for spotting groups, evolution families, unusual type combinations, differences in capture difficulty and species traits, and differences between projection methods. + +### Data + +There is no invented rarity score. The source data provides **capture rate** directly, plus explicit **legendary** and **mythical** flags that can be used to explore rarity-like structure without imposing an arbitrary classification. + +Data: [PokeAPI/pokeapi](https://github.com/PokeAPI/pokeapi) +Sprites and artwork: [PokeAPI/sprites](https://github.com/PokeAPI/sprites) diff --git a/pokemon-dimensionality-reduction/styles.css b/pokemon-dimensionality-reduction/styles.css new file mode 100644 index 0000000..acf07a6 --- /dev/null +++ b/pokemon-dimensionality-reduction/styles.css @@ -0,0 +1,293 @@ +:root { + --pokemon-blue: #2a75bb; + --pokemon-blue-dark: #1d4f91; + --pokemon-yellow: #ffcb05; + --pokemon-red: #ef5350; + --pokemon-ink: #172033; + --pokemon-muted: #6b7280; + --pokemon-card: #ffffff; +} + +html, +body { + background: #eef2f8; +} + +.pokemon-topbar { + min-height: 66px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 22px; + color: #ffffff; + background: linear-gradient(115deg, var(--pokemon-blue-dark), var(--pokemon-blue)); + border-bottom: 5px solid var(--pokemon-yellow); + box-shadow: 0 2px 12px rgba(23, 32, 51, 0.18); + z-index: 2; +} + +.pokemon-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.pokeball-mark { + position: relative; + display: inline-block; + width: 38px; + height: 38px; + flex: 0 0 38px; + border: 3px solid #172033; + border-radius: 50%; + background: linear-gradient( + to bottom, + var(--pokemon-red) 0%, + var(--pokemon-red) 44%, + #172033 44%, + #172033 56%, + #ffffff 56%, + #ffffff 100% + ); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.22); +} + +.pokeball-mark::after { + content: ""; + position: absolute; + width: 11px; + height: 11px; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border: 3px solid #172033; + border-radius: 50%; + background: #ffffff; +} + +.pokemon-brand-title { + font-size: 1.1rem; + font-weight: 800; + letter-spacing: 0.02em; +} + +.pokemon-brand-subtitle { + margin-top: -1px; + color: rgba(255, 255, 255, 0.75); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.09em; +} + +.pokemon-topbar-caption { + color: var(--pokemon-yellow); + font-size: 0.9rem; + font-weight: 700; +} + +.bslib-sidebar-layout > .sidebar { + background: #ffffff; + border-right: 1px solid #dce3ee; +} + +.bslib-sidebar-layout > .main { + padding: 14px !important; + background: #eef2f8; +} + +.pokemon-run-button { + width: 100%; + margin: 4px 0 12px; + border: 0; + color: #ffffff; + font-weight: 700; + box-shadow: 0 4px 10px rgba(42, 117, 187, 0.22); +} + +.pokemon-run-button:hover, +.pokemon-run-button:focus { + background: var(--pokemon-blue-dark); +} + +.pokemon-sidebar-note { + margin: 3px 0 14px; + padding: 9px 10px; + border-left: 4px solid var(--pokemon-yellow); + border-radius: 5px; + background: #fff9d9; + color: #4a4a3f; + font-size: 0.78rem; + line-height: 1.35; +} + +.pokemon-card { + height: 100%; + border: 0 !important; + border-radius: 14px !important; + overflow: hidden; + background: var(--pokemon-card); + box-shadow: 0 8px 28px rgba(25, 44, 78, 0.12); +} + +.pokemon-card .card-header { + padding: 12px 17px; + background: #ffffff; + border-bottom: 1px solid #edf0f5; +} + +.pokemon-chart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +} + +.pokemon-chart-kicker { + color: var(--pokemon-blue); + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.pokemon-chart-title { + margin-top: 1px; + color: var(--pokemon-ink); + font-size: 1.13rem; + font-weight: 800; +} + +.pokemon-chart-meta { + padding: 5px 9px; + color: #7a6510; + background: #fff5bd; + border: 1px solid #f9e680; + border-radius: 999px; + font-size: 0.76rem; + white-space: nowrap; +} + +.pokemon-chart-body { + padding: 0 !important; + background: + radial-gradient(circle at 20% 15%, rgba(42, 117, 187, 0.045), transparent 28%), + radial-gradient(circle at 80% 80%, rgba(255, 203, 5, 0.08), transparent 30%), + #ffffff; +} + +.highcharts-container, +.highcharts-root { + overflow: visible !important; +} + +.pokemon-tooltip { + width: 285px; + padding: 13px; + color: var(--pokemon-ink); + background: #ffffff; + border-top: 5px solid var(--pokemon-yellow); + border-radius: 13px; + box-shadow: 0 14px 35px rgba(23, 32, 51, 0.22); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +.pokemon-tooltip-top { + display: flex; + align-items: center; + gap: 12px; +} + +.pokemon-artwork { + width: 104px; + height: 104px; + flex: 0 0 104px; + object-fit: contain; +} + +.pokemon-tooltip-title { + min-width: 0; +} + +.pokemon-name { + color: var(--pokemon-ink); + font-size: 1.08rem; + font-weight: 800; + line-height: 1.15; +} + +.pokemon-generation { + margin: 2px 0 7px; + color: var(--pokemon-muted); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.07em; +} + +.pokemon-types { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 7px; +} + +.pokemon-type, +.pokemon-type-secondary { + display: inline-block; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; +} + +.pokemon-type { + color: #ffffff; +} + +.pokemon-type-secondary { + color: #4b5563; + background: #edf0f5; +} + +.pokemon-size { + color: #4b5563; + font-size: 0.76rem; +} + +.pokemon-stats { + width: 100%; + margin-top: 10px; + border-collapse: collapse; + font-size: 0.72rem; +} + +.pokemon-stats td { + padding: 4px 5px; + border-top: 1px solid #edf0f5; +} + +.pokemon-stats td:nth-child(odd) { + color: #6b7280; +} + +.pokemon-stats td:nth-child(even) { + text-align: right; + color: #172033; +} + +@media (max-width: 760px) { + .pokemon-topbar-caption { + display: none; + } + + .pokemon-topbar { + padding-left: 14px; + padding-right: 14px; + } + + .pokemon-chart-meta { + display: none; + } +}