soroban-abacus-flashcards/apps/web/src/arcade-games/know-your-world/maps.ts

981 lines
28 KiB
TypeScript

import type { MapData, MapRegion } from './types'
import { getCustomCrop } from './customCrops'
/**
* Type definition for @svg-maps packages
*/
interface SvgMapData {
label: string
viewBox: string
locations: Array<{ id: string; name: string; path: string }>
}
/**
* Cached map data - will be populated either via static imports (browser)
* or dynamic imports (Node.js server)
*/
let worldMapSource: SvgMapData | null = null
let usaMapSource: SvgMapData | null = null
/**
* Load map sources dynamically (async - for server-side)
* In browser, this is called eagerly at module load time
* In Node.js server, this is called on-demand
*/
async function ensureMapSourcesLoaded(): Promise<void> {
if (worldMapSource && usaMapSource) {
return // Already loaded
}
// Dynamic import works in both browser (via Next.js bundler) and Node.js (native ESM support)
const [worldModule, usaModule] = await Promise.all([
import('@svg-maps/world'),
import('@svg-maps/usa'),
])
worldMapSource = worldModule.default
usaMapSource = usaModule.default
console.log('[Maps] Loaded via dynamic import:', {
world: worldMapSource?.locations?.length,
usa: usaMapSource?.locations?.length,
env: typeof window === 'undefined' ? 'server' : 'browser',
})
}
/**
* In browser context, load maps immediately at module initialization
* This allows synchronous access in client components
*/
let browserMapsLoadingPromise: Promise<void> | null = null
if (typeof window !== 'undefined') {
// Browser: Start loading immediately and cache the promise
browserMapsLoadingPromise = (async () => {
await ensureMapSourcesLoaded()
// Populate the caches eagerly
await getWorldMapData()
await getUSAMapData()
})().catch((err) => {
console.error('[Maps] Failed to load map data in browser:', err)
throw err
})
}
/**
* Difficulty level configuration for a map
*/
export interface DifficultyLevel {
id: string // e.g., 'easy', 'medium', 'hard', 'standard'
label: string // Display name
emoji?: string // Optional emoji
description?: string // Optional description for UI
// Filtering: either explicit exclusions OR percentage
excludeRegions?: string[] // Explicit region IDs to exclude
keepPercentile?: number // 0-1, keep this % of largest regions (default 1.0)
}
/**
* Per-map difficulty configuration
*/
export interface MapDifficultyConfig {
levels: DifficultyLevel[]
defaultLevel: string // ID of default level
}
/**
* Global default difficulty config for maps without custom config
*/
export const DEFAULT_DIFFICULTY_CONFIG: MapDifficultyConfig = {
levels: [
{
id: 'easy',
label: 'Easy',
emoji: '😊',
description: '85% largest regions',
keepPercentile: 0.85,
},
{
id: 'medium',
label: 'Medium',
emoji: '🙂',
description: '98% largest regions',
keepPercentile: 0.98,
},
{
id: 'hard',
label: 'Hard',
emoji: '😰',
description: 'All regions',
keepPercentile: 1.0,
},
],
defaultLevel: 'medium',
}
/**
* USA map difficulty config - single level, all regions
*/
export const USA_DIFFICULTY_CONFIG: MapDifficultyConfig = {
levels: [
{
id: 'standard',
label: 'All States',
emoji: '🗺️',
description: 'All 50 states + DC',
keepPercentile: 1.0,
},
],
defaultLevel: 'standard',
}
/**
* Calculate the centroid (center of mass) of an SVG path
* Properly parses SVG path commands to extract endpoint coordinates only
*/
function calculatePathCenter(pathString: string): [number, number] {
const points: Array<[number, number]> = []
// Parse SVG path commands to extract endpoint coordinates
// Regex matches: command letter followed by numbers
const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g
let currentX = 0
let currentY = 0
let match
while ((match = commandRegex.exec(pathString)) !== null) {
const command = match[1]
const params =
match[2]
.trim()
.match(/-?\d+\.?\d*/g)
?.map(Number) || []
switch (command) {
case 'M': // Move to (absolute)
if (params.length >= 2) {
currentX = params[0]
currentY = params[1]
points.push([currentX, currentY])
}
break
case 'm': // Move to (relative)
if (params.length >= 2) {
currentX += params[0]
currentY += params[1]
points.push([currentX, currentY])
}
break
case 'L': // Line to (absolute)
for (let i = 0; i < params.length - 1; i += 2) {
currentX = params[i]
currentY = params[i + 1]
points.push([currentX, currentY])
}
break
case 'l': // Line to (relative)
for (let i = 0; i < params.length - 1; i += 2) {
currentX += params[i]
currentY += params[i + 1]
points.push([currentX, currentY])
}
break
case 'H': // Horizontal line (absolute)
for (const x of params) {
currentX = x
points.push([currentX, currentY])
}
break
case 'h': // Horizontal line (relative)
for (const dx of params) {
currentX += dx
points.push([currentX, currentY])
}
break
case 'V': // Vertical line (absolute)
for (const y of params) {
currentY = y
points.push([currentX, currentY])
}
break
case 'v': // Vertical line (relative)
for (const dy of params) {
currentY += dy
points.push([currentX, currentY])
}
break
case 'C': // Cubic Bezier (absolute) - take only the endpoint (last 2 params)
for (let i = 0; i < params.length - 1; i += 6) {
if (i + 5 < params.length) {
currentX = params[i + 4]
currentY = params[i + 5]
points.push([currentX, currentY])
}
}
break
case 'c': // Cubic Bezier (relative) - take only the endpoint
for (let i = 0; i < params.length - 1; i += 6) {
if (i + 5 < params.length) {
currentX += params[i + 4]
currentY += params[i + 5]
points.push([currentX, currentY])
}
}
break
case 'Q': // Quadratic Bezier (absolute) - take only the endpoint (last 2 params)
for (let i = 0; i < params.length - 1; i += 4) {
if (i + 3 < params.length) {
currentX = params[i + 2]
currentY = params[i + 3]
points.push([currentX, currentY])
}
}
break
case 'q': // Quadratic Bezier (relative) - take only the endpoint
for (let i = 0; i < params.length - 1; i += 4) {
if (i + 3 < params.length) {
currentX += params[i + 2]
currentY += params[i + 3]
points.push([currentX, currentY])
}
}
break
case 'Z':
case 'z':
// Close path - no new point needed
break
}
}
if (points.length === 0) {
return [0, 0]
}
if (points.length < 3) {
// Not enough points for a polygon, fallback to average
const avgX = points.reduce((sum, p) => sum + p[0], 0) / points.length
const avgY = points.reduce((sum, p) => sum + p[1], 0) / points.length
return [avgX, avgY]
}
// Calculate polygon centroid using shoelace formula
let signedArea = 0
let cx = 0
let cy = 0
for (let i = 0; i < points.length; i++) {
const [x0, y0] = points[i]
const [x1, y1] = points[(i + 1) % points.length]
const crossProduct = x0 * y1 - x1 * y0
signedArea += crossProduct
cx += (x0 + x1) * crossProduct
cy += (y0 + y1) * crossProduct
}
signedArea *= 0.5
// Avoid division by zero
if (Math.abs(signedArea) < 0.0001) {
// Fallback to average of all points
const avgX = points.reduce((sum, p) => sum + p[0], 0) / points.length
const avgY = points.reduce((sum, p) => sum + p[1], 0) / points.length
return [avgX, avgY]
}
cx = cx / (6 * signedArea)
cy = cy / (6 * signedArea)
return [cx, cy]
}
/**
* Convert @svg-maps location data to our MapRegion format
*/
function convertToMapRegions(
locations: Array<{ id: string; name: string; path: string }>
): MapRegion[] {
return locations.map((location) => ({
id: location.id,
name: location.name,
path: location.path,
center: calculatePathCenter(location.path),
}))
}
/**
* Cached MapData instances (after conversion)
*/
let worldMapDataCache: MapData | null = null
let usaMapDataCache: MapData | null = null
/**
* Get World map data (async)
*/
async function getWorldMapData(): Promise<MapData> {
if (worldMapDataCache) {
return worldMapDataCache
}
await ensureMapSourcesLoaded()
if (!worldMapSource) {
throw new Error('[Maps] World map source not loaded')
}
worldMapDataCache = {
id: 'world',
name: worldMapSource.label || 'Map of World',
viewBox: worldMapSource.viewBox,
originalViewBox: worldMapSource.viewBox, // Same as viewBox for base map
customCrop: null, // No custom crop for base map
regions: convertToMapRegions(worldMapSource.locations || []),
}
return worldMapDataCache
}
/**
* Get USA map data (async)
*/
async function getUSAMapData(): Promise<MapData> {
if (usaMapDataCache) {
return usaMapDataCache
}
await ensureMapSourcesLoaded()
if (!usaMapSource) {
throw new Error('[Maps] USA map source not loaded')
}
usaMapDataCache = {
id: 'usa',
name: usaMapSource.label || 'Map of USA',
viewBox: usaMapSource.viewBox,
originalViewBox: usaMapSource.viewBox, // Same as viewBox for base map
customCrop: null, // No custom crop for base map
regions: convertToMapRegions(usaMapSource.locations || []),
difficultyConfig: USA_DIFFICULTY_CONFIG,
}
return usaMapDataCache
}
/**
* Get map data synchronously (for client components)
* In browser, throws a promise to trigger React Suspense if not loaded yet
*/
function getMapDataSync(mapId: 'world' | 'usa'): MapData {
const cache = mapId === 'world' ? worldMapDataCache : usaMapDataCache
if (!cache) {
// In browser, if maps are still loading, throw the promise to trigger Suspense
if (typeof window !== 'undefined' && browserMapsLoadingPromise) {
throw browserMapsLoadingPromise
}
throw new Error(
`[Maps] ${mapId} map not yet loaded. Use await getMapData() or ensure maps are preloaded.`
)
}
return cache
}
/**
* Synchronous exports for client components
* These proxy to the cache and throw if accessed before loading
*/
export const WORLD_MAP: MapData = new Proxy({} as MapData, {
get(target, prop) {
return getMapDataSync('world')[prop as keyof MapData]
},
})
export const USA_MAP: MapData = new Proxy({} as MapData, {
get(target, prop) {
return getMapDataSync('usa')[prop as keyof MapData]
},
})
/**
* Get map data by ID (async - for server-side code)
*/
export async function getMapData(mapId: 'world' | 'usa'): Promise<MapData> {
return mapId === 'world' ? await getWorldMapData() : await getUSAMapData()
}
/**
* Get a specific region by ID from a map (async)
*/
export async function getRegionById(mapId: 'world' | 'usa', regionId: string) {
const mapData = await getMapData(mapId)
return mapData.regions.find((r) => r.id === regionId)
}
/**
* Calculate bounding box for a set of SVG paths
*/
export interface BoundingBox {
minX: number
maxX: number
minY: number
maxY: number
width: number
height: number
}
function calculateBoundingBox(paths: string[]): BoundingBox {
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const pathString of paths) {
// Parse SVG path commands properly (similar to calculatePathCenter)
const commandRegex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g
let currentX = 0
let currentY = 0
let match
while ((match = commandRegex.exec(pathString)) !== null) {
const command = match[1]
const params =
match[2]
.trim()
.match(/-?\d+\.?\d*/g)
?.map(Number) || []
switch (command) {
case 'M': // Move to (absolute)
if (params.length >= 2) {
currentX = params[0]
currentY = params[1]
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'm': // Move to (relative)
if (params.length >= 2) {
currentX += params[0]
currentY += params[1]
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'L': // Line to (absolute)
for (let i = 0; i < params.length - 1; i += 2) {
currentX = params[i]
currentY = params[i + 1]
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'l': // Line to (relative)
for (let i = 0; i < params.length - 1; i += 2) {
currentX += params[i]
currentY += params[i + 1]
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'H': // Horizontal line (absolute)
for (const x of params) {
currentX = x
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
}
break
case 'h': // Horizontal line (relative)
for (const dx of params) {
currentX += dx
minX = Math.min(minX, currentX)
maxX = Math.max(maxX, currentX)
}
break
case 'V': // Vertical line (absolute)
for (const y of params) {
currentY = y
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'v': // Vertical line (relative)
for (const dy of params) {
currentY += dy
minY = Math.min(minY, currentY)
maxY = Math.max(maxY, currentY)
}
break
case 'C': // Cubic Bezier (absolute)
for (let i = 0; i < params.length - 1; i += 6) {
if (i + 5 < params.length) {
// Check all control points and endpoint
for (let j = 0; j < 6; j += 2) {
const x = params[i + j]
const y = params[i + j + 1]
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
currentX = params[i + 4]
currentY = params[i + 5]
}
}
break
case 'c': // Cubic Bezier (relative)
for (let i = 0; i < params.length - 1; i += 6) {
if (i + 5 < params.length) {
// Check all control points and endpoint (converted to absolute)
for (let j = 0; j < 6; j += 2) {
const x = currentX + params[i + j]
const y = currentY + params[i + j + 1]
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
currentX += params[i + 4]
currentY += params[i + 5]
}
}
break
case 'Q': // Quadratic Bezier (absolute)
for (let i = 0; i < params.length - 1; i += 4) {
if (i + 3 < params.length) {
// Check control point and endpoint
for (let j = 0; j < 4; j += 2) {
const x = params[i + j]
const y = params[i + j + 1]
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
currentX = params[i + 2]
currentY = params[i + 3]
}
}
break
case 'q': // Quadratic Bezier (relative)
for (let i = 0; i < params.length - 1; i += 4) {
if (i + 3 < params.length) {
// Check control point and endpoint (converted to absolute)
for (let j = 0; j < 4; j += 2) {
const x = currentX + params[i + j]
const y = currentY + params[i + j + 1]
minX = Math.min(minX, x)
maxX = Math.max(maxX, x)
minY = Math.min(minY, y)
maxY = Math.max(maxY, y)
}
currentX += params[i + 2]
currentY += params[i + 3]
}
}
break
case 'Z':
case 'z':
// Close path - no new point needed
break
}
}
}
return {
minX,
maxX,
minY,
maxY,
width: maxX - minX,
height: maxY - minY,
}
}
/**
* Filter world map regions by continent
*/
import { getContinentForCountry, type ContinentId } from './continents'
export function filterRegionsByContinent(
regions: MapRegion[],
continentId: ContinentId | 'all'
): MapRegion[] {
if (continentId === 'all') {
return regions
}
return regions.filter((region) => {
const continent = getContinentForCountry(region.id)
return continent === continentId
})
}
/**
* Calculate adjusted viewBox for a continent
* Uses custom crop if available, otherwise calculates from bounding box with padding
*/
export function calculateContinentViewBox(
regions: MapRegion[],
continentId: ContinentId | 'all',
originalViewBox: string,
mapId: string = 'world'
): string {
if (continentId === 'all') {
return originalViewBox
}
// Check for custom crop override first
const customCrop = getCustomCrop(mapId, continentId)
if (customCrop) {
console.log(`[Maps] Using custom crop for ${mapId}/${continentId}: ${customCrop}`)
return customCrop
}
const filteredRegions = filterRegionsByContinent(regions, continentId)
if (filteredRegions.length === 0) {
return originalViewBox
}
const paths = filteredRegions.map((r) => r.path)
const bbox = calculateBoundingBox(paths)
// Add 10% padding on each side
const paddingX = bbox.width * 0.1
const paddingY = bbox.height * 0.1
const newMinX = bbox.minX - paddingX
const newMinY = bbox.minY - paddingY
const newWidth = bbox.width + 2 * paddingX
const newHeight = bbox.height + 2 * paddingY
return `${newMinX} ${newMinY} ${newWidth} ${newHeight}`
}
/**
* Parse a viewBox string into numeric components
*/
export function parseViewBox(viewBox: string): {
x: number
y: number
width: number
height: number
} {
const parts = viewBox.split(' ').map(Number)
return {
x: parts[0] || 0,
y: parts[1] || 0,
width: parts[2] || 1000,
height: parts[3] || 500,
}
}
/**
* Calculate a display viewBox that:
* 1. Guarantees the crop region is fully visible and centered
* 2. Fills any remaining viewport space with more of the map (no letterboxing)
* 3. Stays within the original map's bounds
*
* This creates a "fit crop with fill" effect - the crop region is the minimum
* visible area, but we expand to fill the container's aspect ratio.
*
* @param originalViewBox - The full map's viewBox (bounds)
* @param cropRegion - The custom crop that MUST be visible
* @param containerAspect - The container's width/height ratio
* @returns The display viewBox string
*/
export function calculateFitCropViewBox(
originalViewBox: { x: number; y: number; width: number; height: number },
cropRegion: { x: number; y: number; width: number; height: number },
containerAspect: number // width / height
): string {
const cropAspect = cropRegion.width / cropRegion.height
let viewBoxWidth: number
let viewBoxHeight: number
// Step 1: Calculate dimensions to fill container while containing crop
if (containerAspect > cropAspect) {
// Container is WIDER than crop - expand horizontally
viewBoxHeight = cropRegion.height
viewBoxWidth = viewBoxHeight * containerAspect
} else {
// Container is TALLER than crop - expand vertically
viewBoxWidth = cropRegion.width
viewBoxHeight = viewBoxWidth / containerAspect
}
// Step 2: Center on crop region
const cropCenterX = cropRegion.x + cropRegion.width / 2
const cropCenterY = cropRegion.y + cropRegion.height / 2
let viewBoxX = cropCenterX - viewBoxWidth / 2
let viewBoxY = cropCenterY - viewBoxHeight / 2
// Step 3: Clamp to original map bounds (shift if needed, don't resize)
// Clamp X
if (viewBoxX < originalViewBox.x) {
viewBoxX = originalViewBox.x
} else if (viewBoxX + viewBoxWidth > originalViewBox.x + originalViewBox.width) {
viewBoxX = originalViewBox.x + originalViewBox.width - viewBoxWidth
}
// Clamp Y
if (viewBoxY < originalViewBox.y) {
viewBoxY = originalViewBox.y
} else if (viewBoxY + viewBoxHeight > originalViewBox.y + originalViewBox.height) {
viewBoxY = originalViewBox.y + originalViewBox.height - viewBoxHeight
}
// Step 4: Handle case where expanded viewBox exceeds map bounds
// (show entire map dimension, may result in letterboxing on that axis)
if (viewBoxWidth > originalViewBox.width) {
viewBoxWidth = originalViewBox.width
viewBoxX = originalViewBox.x
}
if (viewBoxHeight > originalViewBox.height) {
viewBoxHeight = originalViewBox.height
viewBoxY = originalViewBox.y
}
return `${viewBoxX.toFixed(2)} ${viewBoxY.toFixed(2)} ${viewBoxWidth.toFixed(2)} ${viewBoxHeight.toFixed(2)}`
}
/**
* Calculate SVG bounding box area for a region path
*/
function calculateRegionArea(pathString: string): number {
const numbers = pathString.match(/-?\d+\.?\d*/g)?.map(Number) || []
if (numbers.length === 0) {
return 0
}
const xCoords: number[] = []
const yCoords: number[] = []
for (let i = 0; i < numbers.length; i += 2) {
xCoords.push(numbers[i])
if (i + 1 < numbers.length) {
yCoords.push(numbers[i + 1])
}
}
if (xCoords.length === 0 || yCoords.length === 0) {
return 0
}
const minX = Math.min(...xCoords)
const maxX = Math.max(...xCoords)
const minY = Math.min(...yCoords)
const maxY = Math.max(...yCoords)
const width = maxX - minX
const height = maxY - minY
return width * height
}
/**
* Filter regions based on difficulty level configuration
* Supports both explicit region exclusions and percentile-based filtering
*/
function filterRegionsByDifficulty(regions: MapRegion[], level: DifficultyLevel): MapRegion[] {
// Explicit exclusions take priority
if (level.excludeRegions && level.excludeRegions.length > 0) {
const filtered = regions.filter((r) => !level.excludeRegions!.includes(r.id))
console.log(
`[Difficulty Filter] ${level.id} mode: ${filtered.length}/${regions.length} regions (excluded: ${level.excludeRegions.join(', ')})`
)
return filtered
}
// Use percentile filtering
const percentile = level.keepPercentile ?? 1.0
if (percentile >= 1.0) {
return regions // Include all regions
}
// Calculate areas for all regions
const regionsWithAreas = regions.map((region) => ({
region,
area: calculateRegionArea(region.path),
}))
// Sort by area (largest first)
regionsWithAreas.sort((a, b) => b.area - a.area)
// Keep top N% of largest regions
const keepCount = Math.ceil(regions.length * percentile)
const filtered = regionsWithAreas.slice(0, keepCount).map((item) => item.region)
// Debug logging
const excluded = regionsWithAreas.slice(keepCount)
if (excluded.length > 0) {
console.log(
`[Difficulty Filter] EXCLUDED (smallest ${excluded.length}):`,
excluded.map((item) => `${item.region.name} (${item.area.toFixed(0)} units²)`)
)
}
console.log(
`[Difficulty Filter] ${level.id} mode: ${filtered.length}/${regions.length} regions (top ${(percentile * 100).toFixed(0)}%)`
)
return filtered
}
/**
* Get filtered map data for a continent and difficulty (async - for server-side)
*/
export async function getFilteredMapData(
mapId: 'world' | 'usa',
continentId: ContinentId | 'all',
difficultyLevelId?: string // Optional difficulty level ID (uses map's default if not provided)
): Promise<MapData> {
const mapData = await getMapData(mapId)
// Get difficulty config for this map (or use global default)
const difficultyConfig = mapData.difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
// Find the difficulty level by ID (or use default)
const levelId = difficultyLevelId || difficultyConfig.defaultLevel
const difficultyLevel = difficultyConfig.levels.find((level) => level.id === levelId)
if (!difficultyLevel) {
console.warn(
`[getFilteredMapData] Difficulty level "${levelId}" not found for map "${mapId}". Using default.`
)
const defaultLevel = difficultyConfig.levels.find(
(level) => level.id === difficultyConfig.defaultLevel
)
if (!defaultLevel) {
throw new Error(`Invalid difficulty config for map "${mapId}": no default level found`)
}
}
const level = difficultyLevel || difficultyConfig.levels[0]
let filteredRegions = mapData.regions
let adjustedViewBox = mapData.viewBox
let customCrop: string | null = null
// Apply continent filtering for world map
if (mapId === 'world' && continentId !== 'all') {
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
// Check for custom crop - this is what we'll use for fit-crop-with-fill
customCrop = getCustomCrop(mapId, continentId)
adjustedViewBox = calculateContinentViewBox(
mapData.regions,
continentId,
mapData.viewBox,
mapId
)
}
// Apply difficulty filtering
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
return {
...mapData,
regions: filteredRegions,
viewBox: adjustedViewBox,
originalViewBox: mapData.viewBox, // Always the base map's viewBox
customCrop, // The custom crop region if any (for fit-crop-with-fill)
}
}
/**
* Get filtered map data synchronously (for client components)
* Uses the synchronous WORLD_MAP/USA_MAP proxies
*/
export function getFilteredMapDataSync(
mapId: 'world' | 'usa',
continentId: ContinentId | 'all',
difficultyLevelId?: string
): MapData {
const mapData = mapId === 'world' ? WORLD_MAP : USA_MAP
// Get difficulty config for this map (or use global default)
const difficultyConfig = mapData.difficultyConfig || DEFAULT_DIFFICULTY_CONFIG
// Find the difficulty level by ID (or use default)
const levelId = difficultyLevelId || difficultyConfig.defaultLevel
const difficultyLevel = difficultyConfig.levels.find((level) => level.id === levelId)
if (!difficultyLevel) {
console.warn(
`[getFilteredMapDataSync] Difficulty level "${levelId}" not found for map "${mapId}". Using default.`
)
const defaultLevel = difficultyConfig.levels.find(
(level) => level.id === difficultyConfig.defaultLevel
)
if (!defaultLevel) {
throw new Error(`Invalid difficulty config for map "${mapId}": no default level found`)
}
}
const level = difficultyLevel || difficultyConfig.levels[0]
let filteredRegions = mapData.regions
let adjustedViewBox = mapData.viewBox
let customCrop: string | null = null
// Apply continent filtering for world map
if (mapId === 'world' && continentId !== 'all') {
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
// Check for custom crop - this is what we'll use for fit-crop-with-fill
customCrop = getCustomCrop(mapId, continentId)
adjustedViewBox = calculateContinentViewBox(
mapData.regions,
continentId,
mapData.viewBox,
mapId
)
}
// Apply difficulty filtering
filteredRegions = filterRegionsByDifficulty(filteredRegions, level)
return {
...mapData,
regions: filteredRegions,
viewBox: adjustedViewBox,
originalViewBox: mapData.viewBox, // Always the base map's viewBox
customCrop, // The custom crop region if any (for fit-crop-with-fill)
}
}