feat(know-your-world): add filter tabs for size, importance, and population

Add tabbed region selector with three filter criteria:
- Size: Filter by geographic size (huge → tiny)
- Importance: Filter by geopolitical importance (G7/UNSC superpowers → minor territories)
- Population: Filter by population (100M+ → <1M)

Includes:
- New category data for ~200 countries by importance and population
- Utility functions for all filter criteria types
- Compact tab UI with Radix tooltips
- Fixed 205px panel width with proper alignment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-27 19:00:10 -06:00
parent fc87808b40
commit 6c3c0ac70e
4 changed files with 1258 additions and 85 deletions

View File

@ -1,6 +1,7 @@
'use client'
import { useState, useCallback, useMemo, useRef, useEffect } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useSpring } from '@react-spring/web'
import { css } from '@styled/css'
import {
@ -25,15 +26,30 @@ import {
getFilteredMapDataBySizesSync,
REGION_SIZE_CONFIG,
ALL_REGION_SIZES,
IMPORTANCE_LEVEL_CONFIG,
ALL_IMPORTANCE_LEVELS,
POPULATION_LEVEL_CONFIG,
ALL_POPULATION_LEVELS,
FILTER_CRITERIA_CONFIG,
filterRegionsByImportance,
filterRegionsByPopulation,
filterRegionsBySizes,
} from '../maps'
import type { RegionSize } from '../maps'
import type { RegionSize, ImportanceLevel, PopulationLevel, FilterCriteria } from '../maps'
import {
CONTINENTS,
getContinentForCountry,
COUNTRY_TO_CONTINENT,
type ContinentId,
} from '../continents'
import { sizesToRange, rangeToSizes } from '../utils/regionSizeUtils'
import {
sizesToRange,
rangeToSizes,
importanceToRange,
rangeToImportance,
populationToRange,
rangeToPopulation,
} from '../utils/regionSizeUtils'
import { preventFlexExpansion } from '../utils/responsiveStyles'
/**
@ -46,6 +62,35 @@ const SIZE_OPTIONS: ThermometerOption<RegionSize>[] = ALL_REGION_SIZES.map((size
emoji: REGION_SIZE_CONFIG[size].emoji,
}))
/**
* Importance options for the range thermometer, ordered from most to least important
*/
const IMPORTANCE_OPTIONS: ThermometerOption<ImportanceLevel>[] = ALL_IMPORTANCE_LEVELS.map(
(level) => ({
value: level,
label: IMPORTANCE_LEVEL_CONFIG[level].label,
shortLabel: IMPORTANCE_LEVEL_CONFIG[level].label,
emoji: IMPORTANCE_LEVEL_CONFIG[level].emoji,
})
)
/**
* Population options for the range thermometer, ordered from largest to smallest
*/
const POPULATION_OPTIONS: ThermometerOption<PopulationLevel>[] = ALL_POPULATION_LEVELS.map(
(level) => ({
value: level,
label: POPULATION_LEVEL_CONFIG[level].label,
shortLabel: POPULATION_LEVEL_CONFIG[level].label,
emoji: POPULATION_LEVEL_CONFIG[level].emoji,
})
)
/**
* All filter criteria tabs
*/
const ALL_FILTER_CRITERIA: FilterCriteria[] = ['size', 'importance', 'population']
/**
* Selection path for drill-down navigation:
* - [] = World level
@ -150,6 +195,27 @@ export function DrillDownMapSelector({
// Track which region name is being hovered in the popover (for zoom preview)
const [previewRegionName, setPreviewRegionName] = useState<string | null>(null)
// Filter criteria tab state
const [activeFilterCriteria, setActiveFilterCriteria] = useState<FilterCriteria>('size')
// Local state for importance and population filters (not persisted, just for display filtering)
const [includeImportance, setIncludeImportance] = useState<ImportanceLevel[]>([
'superpower',
'major',
'regional',
])
const [includePopulation, setIncludePopulation] = useState<PopulationLevel[]>([
'huge',
'large',
'medium',
])
// Preview states for importance and population thermometers
const [importanceRangePreview, setImportanceRangePreview] =
useState<RangePreviewState<ImportanceLevel> | null>(null)
const [populationRangePreview, setPopulationRangePreview] =
useState<RangePreviewState<PopulationLevel> | null>(null)
// Sync local path state when props change from external sources (e.g., other players)
useEffect(() => {
const expectedPath = getInitialPath()
@ -290,29 +356,52 @@ export function DrillDownMapSelector({
return groups
}, [currentLevel])
// Calculate excluded regions based on includeSizes
// These are regions that exist but are filtered out by size settings
const excludedRegions = useMemo(() => {
// Determine which map we're looking at
// Get base regions for the current view (continent filtered but no criteria filtering)
const baseRegions = useMemo(() => {
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
// Get all regions (unfiltered by size)
const allRegionsMapData = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
['huge', 'large', 'medium', 'small', 'tiny'] // All sizes
)
const allRegionIds = new Set(allRegionsMapData.regions.map((r) => r.id))
return allRegionsMapData.regions
}, [currentLevel, path, selectedContinent])
// Get filtered regions (based on current includeSizes)
const filteredMapData = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
includeSizes
)
const filteredRegionIds = new Set(filteredMapData.regions.map((r) => r.id))
// Get the active filter levels based on current tab
const activeFilterLevels = useMemo(() => {
switch (activeFilterCriteria) {
case 'size':
return includeSizes
case 'importance':
return includeImportance
case 'population':
return includePopulation
default:
return includeSizes
}
}, [activeFilterCriteria, includeSizes, includeImportance, includePopulation])
// Calculate excluded regions based on active filter criteria
const excludedRegions = useMemo(() => {
const allRegionIds = new Set(baseRegions.map((r) => r.id))
// Filter based on active criteria
let filteredRegions = baseRegions
switch (activeFilterCriteria) {
case 'size':
filteredRegions = filterRegionsBySizes(baseRegions, includeSizes, 'world')
break
case 'importance':
filteredRegions = filterRegionsByImportance(baseRegions, includeImportance)
break
case 'population':
filteredRegions = filterRegionsByPopulation(baseRegions, includePopulation)
break
}
const filteredRegionIds = new Set(filteredRegions.map((r) => r.id))
// Excluded = all regions minus filtered regions
const excluded: string[] = []
@ -322,35 +411,74 @@ export function DrillDownMapSelector({
}
}
return excluded
}, [currentLevel, path, selectedContinent, includeSizes])
}, [baseRegions, activeFilterCriteria, includeSizes, includeImportance, includePopulation])
// Compute region names by size category (for tooltip display)
const regionNamesBySize = useMemo(() => {
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
const result: Partial<Record<RegionSize, string[]>> = {}
for (const size of ALL_REGION_SIZES) {
const filtered = getFilteredMapDataBySizesSync(mapId as 'world' | 'usa', continentId, [size])
result[size] = filtered.regions.map((r) => r.name).sort((a, b) => a.localeCompare(b))
const filtered = filterRegionsBySizes(baseRegions, [size], 'world')
result[size] = filtered.map((r) => r.name).sort((a, b) => a.localeCompare(b))
}
return result
}, [currentLevel, path, selectedContinent])
}, [baseRegions])
// Compute all selected region names (for popover display)
// Compute region names by importance category
const regionNamesByImportance = useMemo(() => {
const result: Partial<Record<ImportanceLevel, string[]>> = {}
for (const level of ALL_IMPORTANCE_LEVELS) {
const filtered = filterRegionsByImportance(baseRegions, [level])
result[level] = filtered.map((r) => r.name).sort((a, b) => a.localeCompare(b))
}
return result
}, [baseRegions])
// Compute region names by population category
const regionNamesByPopulation = useMemo(() => {
const result: Partial<Record<PopulationLevel, string[]>> = {}
for (const level of ALL_POPULATION_LEVELS) {
const filtered = filterRegionsByPopulation(baseRegions, [level])
result[level] = filtered.map((r) => r.name).sort((a, b) => a.localeCompare(b))
}
return result
}, [baseRegions])
// Compute region counts by category for the active filter criteria
const regionCountsByCriteria = useMemo(() => {
switch (activeFilterCriteria) {
case 'size':
return Object.fromEntries(
Object.entries(regionNamesBySize).map(([k, v]) => [k, v?.length ?? 0])
)
case 'importance':
return Object.fromEntries(
Object.entries(regionNamesByImportance).map(([k, v]) => [k, v?.length ?? 0])
)
case 'population':
return Object.fromEntries(
Object.entries(regionNamesByPopulation).map(([k, v]) => [k, v?.length ?? 0])
)
default:
return {}
}
}, [activeFilterCriteria, regionNamesBySize, regionNamesByImportance, regionNamesByPopulation])
// Compute all selected region names (for popover display) based on active filter
const selectedRegionNames = useMemo(() => {
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
const filtered = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
includeSizes
)
return filtered.regions.map((r) => r.name)
}, [currentLevel, path, selectedContinent, includeSizes])
let filteredRegions = baseRegions
switch (activeFilterCriteria) {
case 'size':
filteredRegions = filterRegionsBySizes(baseRegions, includeSizes, 'world')
break
case 'importance':
filteredRegions = filterRegionsByImportance(baseRegions, includeImportance)
break
case 'population':
filteredRegions = filterRegionsByPopulation(baseRegions, includePopulation)
break
}
return filteredRegions.map((r) => r.name)
}, [baseRegions, activeFilterCriteria, includeSizes, includeImportance, includePopulation])
// Create lookup map from region name → region data (for zoom preview)
const regionsByName = useMemo(() => {
@ -437,34 +565,68 @@ export function DrillDownMapSelector({
return region?.id ?? null
}, [previewRegionName, regionsByName])
// Calculate preview regions based on hovering over the size thermometer
// Calculate preview regions based on hovering over the thermometer
// Shows what regions would be added or removed if the user clicks
const { previewAddRegions, previewRemoveRegions } = useMemo(() => {
if (!sizeRangePreview) {
// Determine which preview state to use based on active filter criteria
const activePreview =
activeFilterCriteria === 'size'
? sizeRangePreview
: activeFilterCriteria === 'importance'
? importanceRangePreview
: populationRangePreview
if (!activePreview) {
return { previewAddRegions: [], previewRemoveRegions: [] }
}
// Determine which map we're looking at
const mapId = currentLevel === 2 && path[1] ? 'usa' : 'world'
const continentId: ContinentId | 'all' =
currentLevel >= 1 && path[0] ? path[0] : selectedContinent
// Get current included region IDs
const currentIncluded = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
includeSizes
)
const currentIncludedIds = new Set(currentIncluded.regions.map((r) => r.id))
// Get current included region IDs based on active criteria
let currentFiltered = baseRegions
switch (activeFilterCriteria) {
case 'size':
currentFiltered = filterRegionsBySizes(baseRegions, includeSizes, 'world')
break
case 'importance':
currentFiltered = filterRegionsByImportance(baseRegions, includeImportance)
break
case 'population':
currentFiltered = filterRegionsByPopulation(baseRegions, includePopulation)
break
}
const currentIncludedIds = new Set(currentFiltered.map((r) => r.id))
// Get preview included region IDs (if user clicked)
const previewSizes = rangeToSizes(sizeRangePreview.previewMin, sizeRangePreview.previewMax)
const previewIncluded = getFilteredMapDataBySizesSync(
mapId as 'world' | 'usa',
continentId,
previewSizes
)
const previewIncludedIds = new Set(previewIncluded.regions.map((r) => r.id))
let previewFiltered = baseRegions
switch (activeFilterCriteria) {
case 'size':
if (sizeRangePreview) {
const previewSizes = rangeToSizes(
sizeRangePreview.previewMin,
sizeRangePreview.previewMax
)
previewFiltered = filterRegionsBySizes(baseRegions, previewSizes, 'world')
}
break
case 'importance':
if (importanceRangePreview) {
const previewLevels = rangeToImportance(
importanceRangePreview.previewMin,
importanceRangePreview.previewMax
)
previewFiltered = filterRegionsByImportance(baseRegions, previewLevels)
}
break
case 'population':
if (populationRangePreview) {
const previewLevels = rangeToPopulation(
populationRangePreview.previewMin,
populationRangePreview.previewMax
)
previewFiltered = filterRegionsByPopulation(baseRegions, previewLevels)
}
break
}
const previewIncludedIds = new Set(previewFiltered.map((r) => r.id))
// Regions that would be ADDED (in preview but not currently included)
const addRegions: string[] = []
@ -486,7 +648,16 @@ export function DrillDownMapSelector({
previewAddRegions: addRegions,
previewRemoveRegions: removeRegions,
}
}, [sizeRangePreview, currentLevel, path, selectedContinent, includeSizes])
}, [
activeFilterCriteria,
sizeRangePreview,
importanceRangePreview,
populationRangePreview,
baseRegions,
includeSizes,
includeImportance,
includePopulation,
])
// Compute the label to display for the hovered region
// Shows the next drill-down level name, not the individual region name
@ -1036,7 +1207,7 @@ export function DrillDownMapSelector({
)
})()}
{/* Right-side controls container - region size selector with inline list on desktop */}
{/* Right-side controls container - region filter selector with tabs */}
<div
data-element="right-controls"
className={css({
@ -1048,35 +1219,176 @@ export function DrillDownMapSelector({
transformOrigin: 'top right',
})}
>
{/* Region Size Range Selector with inline list expansion on desktop */}
{/* Region Filter Selector with tabs */}
<div
data-element="region-size-filters"
data-element="region-filters"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
padding: '3',
bg: isDark ? 'gray.800' : 'gray.100',
rounded: 'xl',
shadow: 'lg',
maxHeight: { base: 'none', md: fillContainer ? '400px' : 'none' },
width: '205px',
maxHeight: { base: 'none', md: fillContainer ? '450px' : 'none' },
overflow: 'hidden',
})}
>
<RangeThermometer
options={SIZE_OPTIONS}
minValue={sizesToRange(includeSizes)[0]}
maxValue={sizesToRange(includeSizes)[1]}
onChange={(min, max) => onRegionSizesChange(rangeToSizes(min, max))}
orientation="vertical"
isDark={isDark}
counts={regionCountsBySize as Partial<Record<RegionSize, number>>}
showTotalCount
onHoverPreview={setSizeRangePreview}
regionNamesByCategory={regionNamesBySize}
selectedRegionNames={selectedRegionNames}
onRegionNameHover={setPreviewRegionName}
hideCountOnMd={fillContainer && selectedRegionNames.length > 0}
/>
{/* Filter Criteria Tabs */}
<Tooltip.Provider delayDuration={200}>
<div
data-element="filter-tabs"
className={css({
display: 'flex',
gap: '1px',
marginBottom: '2',
padding: '2px',
bg: isDark ? 'gray.700' : 'gray.200',
rounded: 'md',
width: '100%',
})}
>
{ALL_FILTER_CRITERIA.map((criteria) => {
const isActive = activeFilterCriteria === criteria
const buttonContent = (
<button
data-action={`select-filter-${criteria}`}
onClick={() => setActiveFilterCriteria(criteria)}
className={css({
flex: isActive ? 1 : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1',
padding: '1',
paddingX: isActive ? '2' : '1.5',
fontSize: 'xs',
fontWeight: isActive ? 'bold' : 'normal',
color: isActive
? isDark
? 'white'
: 'gray.900'
: isDark
? 'gray.400'
: 'gray.600',
bg: isActive ? (isDark ? 'gray.600' : 'white') : 'transparent',
rounded: 'sm',
cursor: 'pointer',
border: 'none',
transition: 'all 0.15s',
whiteSpace: 'nowrap',
_hover: {
bg: isActive
? isDark
? 'gray.600'
: 'white'
: isDark
? 'gray.600'
: 'gray.100',
},
})}
>
<span>{FILTER_CRITERIA_CONFIG[criteria].emoji}</span>
{isActive && <span>{FILTER_CRITERIA_CONFIG[criteria].label}</span>}
</button>
)
// Wrap inactive tabs with tooltips
if (!isActive) {
return (
<Tooltip.Root key={criteria}>
<Tooltip.Trigger asChild>{buttonContent}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
sideOffset={5}
className={css({
bg: isDark ? 'gray.800' : 'gray.900',
color: 'white',
paddingX: '3',
paddingY: '1.5',
rounded: 'md',
fontSize: 'xs',
fontWeight: 'medium',
boxShadow: 'lg',
zIndex: 10001,
animation: 'fadeIn 0.15s ease-out',
})}
>
{FILTER_CRITERIA_CONFIG[criteria].label}
<Tooltip.Arrow
className={css({
fill: isDark ? 'gray.800' : 'gray.900',
})}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
)
}
return <span key={criteria}>{buttonContent}</span>
})}
</div>
</Tooltip.Provider>
{/* Size Filter (when size tab is active) */}
{activeFilterCriteria === 'size' && (
<RangeThermometer
options={SIZE_OPTIONS}
minValue={sizesToRange(includeSizes)[0]}
maxValue={sizesToRange(includeSizes)[1]}
onChange={(min, max) => onRegionSizesChange(rangeToSizes(min, max))}
orientation="vertical"
isDark={isDark}
counts={regionCountsByCriteria as Partial<Record<RegionSize, number>>}
showTotalCount
onHoverPreview={setSizeRangePreview}
regionNamesByCategory={regionNamesBySize}
selectedRegionNames={selectedRegionNames}
onRegionNameHover={setPreviewRegionName}
hideCountOnMd={fillContainer && selectedRegionNames.length > 0}
/>
)}
{/* Importance Filter (when importance tab is active) */}
{activeFilterCriteria === 'importance' && (
<RangeThermometer
options={IMPORTANCE_OPTIONS}
minValue={importanceToRange(includeImportance)[0]}
maxValue={importanceToRange(includeImportance)[1]}
onChange={(min, max) => setIncludeImportance(rangeToImportance(min, max))}
orientation="vertical"
isDark={isDark}
counts={regionCountsByCriteria as Partial<Record<ImportanceLevel, number>>}
showTotalCount
onHoverPreview={setImportanceRangePreview}
regionNamesByCategory={regionNamesByImportance}
selectedRegionNames={selectedRegionNames}
onRegionNameHover={setPreviewRegionName}
hideCountOnMd={fillContainer && selectedRegionNames.length > 0}
/>
)}
{/* Population Filter (when population tab is active) */}
{activeFilterCriteria === 'population' && (
<RangeThermometer
options={POPULATION_OPTIONS}
minValue={populationToRange(includePopulation)[0]}
maxValue={populationToRange(includePopulation)[1]}
onChange={(min, max) => setIncludePopulation(rangeToPopulation(min, max))}
orientation="vertical"
isDark={isDark}
counts={regionCountsByCriteria as Partial<Record<PopulationLevel, number>>}
showTotalCount
onHoverPreview={setPopulationRangePreview}
regionNamesByCategory={regionNamesByPopulation}
selectedRegionNames={selectedRegionNames}
onRegionNameHover={setPreviewRegionName}
hideCountOnMd={fillContainer && selectedRegionNames.length > 0}
/>
)}
{/* Inline region list - visible on larger screens only, expands below thermometer */}
{fillContainer && selectedRegionNames.length > 0 && (
@ -1089,8 +1401,8 @@ export function DrillDownMapSelector({
borderColor: isDark ? 'gray.700' : 'gray.300',
marginTop: '2',
paddingTop: '2',
/* Prevent this element from expanding the parent */
...preventFlexExpansion,
width: '100%',
alignSelf: 'stretch',
})}
>
<RegionListPanel

View File

@ -142,7 +142,7 @@ export interface MapDifficultyConfig {
* Assistance level configuration - controls gameplay features separate from region filtering
*/
export interface AssistanceLevelConfig {
id: 'guided' | 'helpful' | 'standard' | 'none'
id: 'learning' | 'guided' | 'helpful' | 'standard' | 'none'
label: string
emoji: string
description: string
@ -818,6 +818,797 @@ const REGION_SIZE_CATEGORIES: Record<RegionSize, Set<string>> = {
]),
}
/**
* Geopolitical importance category for filtering
* Based on international influence (G7/G20/UNSC membership, regional power, etc.)
*/
export type ImportanceLevel = 'superpower' | 'major' | 'regional' | 'standard' | 'minor'
/**
* Geopolitical importance categories for world map
* Curated based on international influence and diplomatic standing
* ISO 3166-1 alpha-2 codes (lowercase)
*/
const REGION_IMPORTANCE_CATEGORIES: Record<ImportanceLevel, Set<string>> = {
// Superpower: UNSC P5 + G7 (~9 countries)
superpower: new Set([
'us', // United States - superpower
'cn', // China - superpower
'ru', // Russia - UNSC P5
'gb', // United Kingdom - UNSC P5, G7
'fr', // France - UNSC P5, G7
'de', // Germany - G7
'jp', // Japan - G7
'it', // Italy - G7
'ca', // Canada - G7
]),
// Major: G20 members and other major economies (~15)
major: new Set([
'in', // India - G20, rising power
'br', // Brazil - G20, regional power
'au', // Australia - G20
'kr', // South Korea - G20
'mx', // Mexico - G20
'id', // Indonesia - G20
'tr', // Turkey - G20, NATO
'sa', // Saudi Arabia - G20, OPEC leader
'ar', // Argentina - G20
'za', // South Africa - G20, BRICS
'es', // Spain - EU major
'nl', // Netherlands - EU founding
'pl', // Poland - EU major
'se', // Sweden - EU
'ch', // Switzerland - financial hub
]),
// Regional: Significant regional powers and influential nations (~45)
regional: new Set([
// Europe
'at',
'be',
'dk',
'fi',
'gr',
'ie',
'no',
'pt',
'cz',
'ro',
'hu',
// Middle East
'ae',
'il',
'eg',
'ir',
'iq',
'pk',
'qa',
'kw',
// Asia
'th',
'my',
'sg',
'vn',
'ph',
'bd',
'tw',
'hk',
// Africa
'ng',
'ke',
'et',
'dz',
'ma',
'gh',
// Americas
'cl',
'co',
'pe',
've',
'cu',
// Oceania
'nz',
]),
// Standard: Most UN member states with moderate influence (~100)
standard: new Set([
// Europe
'ua',
'by',
'sk',
'bg',
'hr',
'rs',
'lt',
'lv',
'ee',
'si',
'ba',
'mk',
'al',
'me',
'xk',
'md',
'is',
'cy',
'mt',
'lu',
// Asia
'af',
'kz',
'uz',
'tm',
'kg',
'tj',
'mn',
'np',
'lk',
'mm',
'kh',
'la',
'kp',
'bt',
'bn',
// Middle East
'jo',
'lb',
'sy',
'ye',
'om',
'bh',
'az',
'ge',
'am',
'ps',
// Africa
'tz',
'ug',
'zm',
'zw',
'sd',
'ss',
'cd',
'ao',
'mz',
'mg',
'cm',
'ci',
'sn',
'ml',
'bf',
'ne',
'td',
'cf',
'cg',
'ga',
'gq',
'bj',
'tg',
'gn',
'sl',
'lr',
'gm',
'gw',
'mr',
'tn',
'ly',
'so',
'er',
'dj',
'rw',
'bi',
'mw',
'bw',
'na',
'sz',
'ls',
// Americas
'ec',
'bo',
'py',
'uy',
'gy',
'sr',
'pa',
'cr',
'ni',
'hn',
'gt',
'sv',
'bz',
'do',
'ht',
'jm',
'tt',
'bs',
// Oceania
'pg',
'fj',
]),
// Minor: Small states, territories, and dependencies (~80+)
minor: new Set([
// Caribbean
'bb',
'ag',
'dm',
'lc',
'vc',
'gd',
'kn',
'aw',
'cw',
'bq',
'sx',
'mf',
'bl',
'tc',
'vg',
'vi',
'ky',
'ai',
'ms',
'pr',
'bm',
'gp',
'mq',
'gf',
// Europe
'li',
'ad',
'mc',
'sm',
'va',
'gi',
'fo',
'ax',
'gg',
'im',
'je',
// Pacific
'ws',
'to',
'vu',
'sb',
'nc',
'pf',
'gu',
'as',
'mp',
'pw',
'fm',
'mh',
'ki',
'nr',
'tv',
'nu',
'tk',
'ck',
'wf',
'pn',
// Indian Ocean
'mv',
'sc',
'mu',
'km',
'yt',
're',
// African territories
'cv',
'st',
'sh',
'eh',
// Asian territories
'mo',
'tl',
'io',
'cx',
'cc',
'nf',
// Other
'gl',
'pm',
'hm',
'bv',
'sj',
'fk',
'gs',
'aq',
'tf',
'go',
'ju',
'um-dq',
'um-fq',
'um-hq',
'um-jq',
'um-mq',
'um-wq',
]),
}
/**
* Display configuration for each importance level
*/
export const IMPORTANCE_LEVEL_CONFIG: Record<
ImportanceLevel,
{ label: string; emoji: string; description: string }
> = {
superpower: {
label: 'Superpower',
emoji: '🌟',
description: 'G7 and UNSC permanent members',
},
major: {
label: 'Major',
emoji: '🏛️',
description: 'G20 members and major economies',
},
regional: {
label: 'Regional',
emoji: '🌐',
description: 'Regional powers and influential nations',
},
standard: {
label: 'Standard',
emoji: '🏳️',
description: 'Most UN member states',
},
minor: {
label: 'Minor',
emoji: '🏝️',
description: 'Small states and territories',
},
}
/**
* All importance levels in order from most to least important
*/
export const ALL_IMPORTANCE_LEVELS: ImportanceLevel[] = [
'superpower',
'major',
'regional',
'standard',
'minor',
]
/**
* Population category for filtering
*/
export type PopulationLevel = 'huge' | 'large' | 'medium' | 'small' | 'tiny'
/**
* Population categories for world map
* Based on approximate population (2024 estimates)
* ISO 3166-1 alpha-2 codes (lowercase)
*/
const REGION_POPULATION_CATEGORIES: Record<PopulationLevel, Set<string>> = {
// Huge: 100M+ population (~13 countries)
huge: new Set([
'cn', // China - 1.4B
'in', // India - 1.4B
'us', // United States - 335M
'id', // Indonesia - 277M
'pk', // Pakistan - 235M
'br', // Brazil - 216M
'ng', // Nigeria - 224M
'bd', // Bangladesh - 173M
'ru', // Russia - 144M
'mx', // Mexico - 130M
'jp', // Japan - 125M
'et', // Ethiopia - 126M
'ph', // Philippines - 117M
]),
// Large: 30-100M population (~30 countries)
large: new Set([
'eg', // Egypt - 105M
'vn', // Vietnam - 99M
'cd', // DR Congo - 99M
'tr', // Turkey - 85M
'ir', // Iran - 87M
'de', // Germany - 84M
'th', // Thailand - 72M
'gb', // United Kingdom - 67M
'fr', // France - 68M
'it', // Italy - 59M
'za', // South Africa - 60M
'tz', // Tanzania - 65M
'mm', // Myanmar - 54M
'kr', // South Korea - 52M
'co', // Colombia - 52M
'ke', // Kenya - 54M
'es', // Spain - 48M
'ar', // Argentina - 46M
'ug', // Uganda - 48M
'dz', // Algeria - 45M
'sd', // Sudan - 46M
'ua', // Ukraine - 38M
'iq', // Iraq - 43M
'af', // Afghanistan - 41M
'pl', // Poland - 38M
'ca', // Canada - 40M
'ma', // Morocco - 37M
'sa', // Saudi Arabia - 36M
'uz', // Uzbekistan - 35M
'pe', // Peru - 34M
'my', // Malaysia - 34M
'ao', // Angola - 35M
]),
// Medium: 10-30M population (~50 countries)
medium: new Set([
've',
'np',
'gh',
'mz',
'ye',
'mg',
'kp',
'au',
'cm',
'ci',
'tw',
'ne',
'lk',
'bf',
'ml',
'sy',
'mw',
'ro',
'cl',
'kz',
'zm',
'ec',
'sn',
'td',
'nl',
'so',
'gt',
'zw',
'rw',
'gn',
'bj',
'bi',
'tn',
'be',
'bo',
'ht',
'cu',
'cz',
'jo',
'gr',
'do',
'se',
'pt',
'az',
'ae',
'hn',
'hu',
'tj',
'by',
'at',
'ch',
'pg',
'il',
'tg',
'sl',
'ss',
]),
// Small: 1-10M population (~60 countries)
small: new Set([
'hk',
'la',
'ly',
'rs',
'bg',
'pa',
'lb',
'lr',
'cf',
'ni',
'ie',
'cr',
'cg',
'ps',
'nz',
'sk',
'ge',
'hr',
'om',
'pr',
'dk',
'no',
'sg',
'er',
'fi',
'ky',
'mr',
'kw',
'bi',
'md',
'ja',
'na',
'mk',
'bw',
'lt',
'gm',
'ga',
'si',
'qa',
'xk',
'ba',
'lv',
'gw',
'ee',
'mu',
'tt',
'tl',
'cy',
'fj',
'dj',
'km',
'bt',
'gq',
'ls',
'sz',
'bh',
'mn',
'me',
'al',
'arm',
'jm',
'kg',
'tm',
]),
// Tiny: <1M population (~60+ countries/territories)
tiny: new Set([
// Caribbean
'bb',
'ag',
'dm',
'lc',
'vc',
'gd',
'kn',
'aw',
'cw',
'bq',
'sx',
'mf',
'bl',
'tc',
'vg',
'vi',
'ai',
'ms',
'bm',
'gp',
'mq',
'gf',
// Europe
'lu',
'mt',
'is',
'li',
'ad',
'mc',
'sm',
'va',
'gi',
'fo',
'ax',
'gg',
'im',
'je',
// Pacific
'ws',
'to',
'vu',
'sb',
'nc',
'pf',
'gu',
'as',
'mp',
'pw',
'fm',
'mh',
'ki',
'nr',
'tv',
'nu',
'tk',
'ck',
'wf',
'pn',
// Indian Ocean
'mv',
'sc',
're',
'yt',
// Africa
'cv',
'st',
'sh',
'eh',
// Asian/Other
'mo',
'bn',
'pm',
'io',
'cx',
'cc',
'nf',
'gl',
'hm',
'bv',
'sj',
'fk',
'gs',
'aq',
'tf',
'go',
'ju',
'um-dq',
'um-fq',
'um-hq',
'um-jq',
'um-mq',
'um-wq',
]),
}
/**
* Display configuration for each population level
*/
export const POPULATION_LEVEL_CONFIG: Record<
PopulationLevel,
{ label: string; emoji: string; description: string }
> = {
huge: {
label: 'Huge',
emoji: '🏙️',
description: 'Countries with 100M+ people',
},
large: {
label: 'Large',
emoji: '🌆',
description: 'Countries with 30-100M people',
},
medium: {
label: 'Medium',
emoji: '🏘️',
description: 'Countries with 10-30M people',
},
small: {
label: 'Small',
emoji: '🏡',
description: 'Countries with 1-10M people',
},
tiny: {
label: 'Tiny',
emoji: '🏝️',
description: 'Countries with <1M people',
},
}
/**
* All population levels in order from largest to smallest
*/
export const ALL_POPULATION_LEVELS: PopulationLevel[] = ['huge', 'large', 'medium', 'small', 'tiny']
/**
* Filter criteria type - which dimension to filter regions by
*/
export type FilterCriteria = 'size' | 'importance' | 'population'
/**
* Display configuration for filter criteria tabs
*/
export const FILTER_CRITERIA_CONFIG: Record<
FilterCriteria,
{ label: string; emoji: string; description: string }
> = {
size: {
label: 'Size',
emoji: '📏',
description: 'Filter by geographic size',
},
importance: {
label: 'Importance',
emoji: '🏛️',
description: 'Filter by geopolitical importance',
},
population: {
label: 'Population',
emoji: '👥',
description: 'Filter by population',
},
}
/**
* Get the importance category for a region ID
*/
export function getRegionImportanceCategory(regionId: string): ImportanceLevel | null {
for (const [level, ids] of Object.entries(REGION_IMPORTANCE_CATEGORIES)) {
if (ids.has(regionId)) {
return level as ImportanceLevel
}
}
return null
}
/**
* Get the population category for a region ID
*/
export function getRegionPopulationCategory(regionId: string): PopulationLevel | null {
for (const [level, ids] of Object.entries(REGION_POPULATION_CATEGORIES)) {
if (ids.has(regionId)) {
return level as PopulationLevel
}
}
return null
}
/**
* Check if a region should be included based on importance requirements
*/
export function shouldIncludeRegionByImportance(
regionId: string,
includeLevels: ImportanceLevel[]
): boolean {
const category = getRegionImportanceCategory(regionId)
if (!category) {
// If no category found, include by default (for regions not in our list)
return true
}
return includeLevels.includes(category)
}
/**
* Check if a region should be included based on population requirements
*/
export function shouldIncludeRegionByPopulation(
regionId: string,
includeLevels: PopulationLevel[]
): boolean {
const category = getRegionPopulationCategory(regionId)
if (!category) {
// If no category found, include by default (for regions not in our list)
return true
}
return includeLevels.includes(category)
}
/**
* Filter regions by importance levels
*/
export function filterRegionsByImportance(
regions: MapRegion[],
includeLevels: ImportanceLevel[]
): MapRegion[] {
if (includeLevels.length === 0 || includeLevels.length === ALL_IMPORTANCE_LEVELS.length) {
return regions
}
return regions.filter((r) => shouldIncludeRegionByImportance(r.id, includeLevels))
}
/**
* Filter regions by population levels
*/
export function filterRegionsByPopulation(
regions: MapRegion[],
includeLevels: PopulationLevel[]
): MapRegion[] {
if (includeLevels.length === 0 || includeLevels.length === ALL_POPULATION_LEVELS.length) {
return regions
}
return regions.filter((r) => shouldIncludeRegionByPopulation(r.id, includeLevels))
}
/**
* Get the size category for a region ID
*/

View File

@ -1,10 +1,32 @@
/**
* Utility functions for converting between region size arrays and min/max ranges.
* Utility functions for converting between region filter arrays and min/max ranges.
* Used by the DrillDownMapSelector's RangeThermometer component.
* Supports size, importance, and population filter criteria.
*/
import type { RegionSize } from '../maps'
import { ALL_REGION_SIZES } from '../maps'
import type { RegionSize, ImportanceLevel, PopulationLevel, FilterCriteria } from '../maps'
import { ALL_REGION_SIZES, ALL_IMPORTANCE_LEVELS, ALL_POPULATION_LEVELS } from '../maps'
/**
* Generic type for all filter level types
*/
export type FilterLevel = RegionSize | ImportanceLevel | PopulationLevel
/**
* Get the appropriate "ALL_*_LEVELS" array for a given filter criteria
*/
export function getAllLevelsForCriteria<T extends FilterLevel>(criteria: FilterCriteria): T[] {
switch (criteria) {
case 'size':
return ALL_REGION_SIZES as T[]
case 'importance':
return ALL_IMPORTANCE_LEVELS as T[]
case 'population':
return ALL_POPULATION_LEVELS as T[]
default:
return ALL_REGION_SIZES as T[]
}
}
/**
* Convert an array of sizes to min/max values for the range thermometer.
@ -75,3 +97,50 @@ export function calculatePreviewChanges(
return { addRegions, removeRegions }
}
/**
* Generic: Convert an array of levels to min/max values for the range thermometer.
* Works with any filter criteria (size, importance, population).
*/
export function levelsToRange<T extends FilterLevel>(levels: T[], allLevels: T[]): [T, T] {
const sorted = [...levels].sort((a, b) => allLevels.indexOf(a) - allLevels.indexOf(b))
return [sorted[0], sorted[sorted.length - 1]]
}
/**
* Generic: Convert min/max range values back to an array of levels.
* Works with any filter criteria (size, importance, population).
*/
export function rangeToLevels<T extends FilterLevel>(min: T, max: T, allLevels: T[]): T[] {
const minIdx = allLevels.indexOf(min)
const maxIdx = allLevels.indexOf(max)
return allLevels.slice(minIdx, maxIdx + 1)
}
/**
* Importance-specific: Convert array to range
*/
export function importanceToRange(levels: ImportanceLevel[]): [ImportanceLevel, ImportanceLevel] {
return levelsToRange(levels, ALL_IMPORTANCE_LEVELS)
}
/**
* Importance-specific: Convert range to array
*/
export function rangeToImportance(min: ImportanceLevel, max: ImportanceLevel): ImportanceLevel[] {
return rangeToLevels(min, max, ALL_IMPORTANCE_LEVELS)
}
/**
* Population-specific: Convert array to range
*/
export function populationToRange(levels: PopulationLevel[]): [PopulationLevel, PopulationLevel] {
return levelsToRange(levels, ALL_POPULATION_LEVELS)
}
/**
* Population-specific: Convert range to array
*/
export function rangeToPopulation(min: PopulationLevel, max: PopulationLevel): PopulationLevel[] {
return rangeToLevels(min, max, ALL_POPULATION_LEVELS)
}

View File

@ -221,7 +221,8 @@ export function RangeThermometer<T extends string>({
className={css({
display: 'flex',
flexDirection: isVertical ? 'row' : 'column',
gap: '2',
justifyContent: 'space-between',
gap: '1',
})}
>
{/* Labels column */}
@ -258,9 +259,9 @@ export function RangeThermometer<T extends string>({
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
gap: '1',
py: '1',
px: '2',
px: '1.5',
rounded: 'md',
cursor: 'pointer',
transition: 'all 0.15s',