diff --git a/apps/web/src/arcade-games/know-your-world/.claude/FIT_CROP_WITH_FILL_PLAN.md b/apps/web/src/arcade-games/know-your-world/.claude/FIT_CROP_WITH_FILL_PLAN.md
new file mode 100644
index 00000000..9658998c
--- /dev/null
+++ b/apps/web/src/arcade-games/know-your-world/.claude/FIT_CROP_WITH_FILL_PLAN.md
@@ -0,0 +1,242 @@
+# Fit Crop with Fill - Implementation Plan
+
+## Goal
+
+Instead of strictly cropping to the custom viewBox, we want to:
+1. **Guarantee** the custom crop region is fully visible and centered
+2. **Fill** any remaining viewport space with more of the map (no letterboxing)
+3. **Stay** within the original map's bounds
+
+## Current Behavior
+
+```
+┌─────────────────────────────────────────┐
+│ Container (wide) │
+│ ┌─────────────────────┐ │
+│ │ │ Letterbox │
+│ │ Cropped Europe │ (wasted) │
+│ │ │ │
+│ └─────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+## Desired Behavior
+
+```
+┌─────────────────────────────────────────┐
+│ Container (wide) │
+│ ┌─────────────────────────────────────┐ │
+│ │ More │ Europe │ More │ │
+│ │ Africa │ (centered) │ Middle │ │
+│ │ │ │ East │ │
+│ └─────────────────────────────────────┘ │
+└─────────────────────────────────────────┘
+```
+
+## Math Strategy
+
+### Inputs
+- `originalViewBox`: Full map bounds (e.g., `0 0 1000 500` for world map)
+- `cropRegion`: Custom crop that MUST be visible (e.g., `346.40 53.73 247.56 360.70` for Europe)
+- `containerAspect`: Container's width/height ratio
+
+### Algorithm
+
+```typescript
+function calculateFitCropViewBox(
+ originalViewBox: { x: number; y: number; width: number; height: number },
+ cropRegion: { x: number; y: number; width: number; height: number },
+ containerAspect: number // width / height
+): { x: number; y: number; width: number; height: number } {
+
+ 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)
+ if (viewBoxWidth > originalViewBox.width) {
+ viewBoxWidth = originalViewBox.width;
+ viewBoxX = originalViewBox.x;
+ }
+ if (viewBoxHeight > originalViewBox.height) {
+ viewBoxHeight = originalViewBox.height;
+ viewBoxY = originalViewBox.y;
+ }
+
+ return { x: viewBoxX, y: viewBoxY, width: viewBoxWidth, height: viewBoxHeight };
+}
+```
+
+### Example: Europe in Wide Container
+
+```
+Original map: 0, 0, 1000, 500 (aspect 2:1)
+Europe crop: 346.40, 53.73, 247.56, 360.70 (aspect ~0.69:1)
+Container: 800x400 pixels (aspect 2:1)
+
+Container is wider (2.0 > 0.69), expand horizontally:
+ viewBoxHeight = 360.70
+ viewBoxWidth = 360.70 * 2.0 = 721.40
+
+Center on Europe:
+ cropCenterX = 346.40 + 247.56/2 = 470.18
+ cropCenterY = 53.73 + 360.70/2 = 234.08
+ viewBoxX = 470.18 - 721.40/2 = 109.48
+ viewBoxY = 234.08 - 360.70/2 = 53.73
+
+Check bounds:
+ Left: 109.48 >= 0 ✓
+ Right: 109.48 + 721.40 = 830.88 <= 1000 ✓
+ Top: 53.73 >= 0 ✓
+ Bottom: 53.73 + 360.70 = 414.43 <= 500 ✓
+
+Final viewBox: 109.48 53.73 721.40 360.70
+```
+
+Result: Europe is centered, but we also see parts of Africa on the left and Middle East on the right!
+
+### Example: North America in Tall Container
+
+```
+Original map: 0, 0, 1000, 500
+N.America crop: -2.22, 158.58, 361.43, 293.07 (aspect ~1.23:1)
+Container: 400x600 pixels (aspect 0.67:1)
+
+Container is taller (0.67 < 1.23), expand vertically:
+ viewBoxWidth = 361.43
+ viewBoxHeight = 361.43 / 0.67 = 539.45
+
+But 539.45 > 500 (map height), so clamp:
+ viewBoxHeight = 500
+ viewBoxY = 0
+
+Result: Shows full map height, N.America visible but not centered vertically
+```
+
+## Implementation Changes
+
+### 1. Update MapData Type (`types.ts`)
+
+```typescript
+interface MapData {
+ // ... existing fields
+ viewBox: string; // The display viewBox (crop or full)
+ originalViewBox: string; // Always the full map bounds
+ customCrop: string | null; // The custom crop if any
+}
+```
+
+### 2. Update `getFilteredMapDataSync` (`maps.ts`)
+
+Return both the custom crop (as `customCrop`) and original bounds (as `originalViewBox`):
+
+```typescript
+return {
+ ...mapData,
+ regions: filteredRegions,
+ viewBox: adjustedViewBox, // For backward compatibility
+ originalViewBox: mapData.viewBox, // Original full bounds
+ customCrop: customCrop, // The crop region, or null
+}
+```
+
+### 3. Create `calculateFitCropViewBox` utility (`maps.ts`)
+
+Export the function described above.
+
+### 4. Update `MapRenderer.tsx`
+
+Calculate the display viewBox dynamically based on container dimensions:
+
+```typescript
+// In MapRenderer, after getting container dimensions:
+const displayViewBox = useMemo(() => {
+ if (!mapData.customCrop || !containerRef.current) {
+ return mapData.viewBox; // No custom crop, use as-is
+ }
+
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const containerAspect = containerRect.width / containerRect.height;
+
+ const originalBounds = parseViewBox(mapData.originalViewBox);
+ const cropRegion = parseViewBox(mapData.customCrop);
+
+ return calculateFitCropViewBox(originalBounds, cropRegion, containerAspect);
+}, [mapData, svgDimensions]); // Re-calculate when container resizes
+
+// Use displayViewBox for the main SVG
+
+```
+
+### 5. Update Dependent Calculations
+
+The following need to use `displayViewBox` instead of `mapData.viewBox`:
+- Magnifier viewBox calculations
+- Debug bounding box label positioning
+- Cursor-to-SVG coordinate conversions
+- Any other coordinate transformations
+
+## Edge Cases to Handle
+
+1. **No custom crop**: Use `mapData.viewBox` as-is (current behavior)
+
+2. **Crop region at edge of map**: Expand only in available direction
+ - Europe at far right: Can only expand leftward
+ - Result: Crop visible but not centered
+
+3. **Very wide container with narrow crop**: May show most of the map width
+ - That's OK - crop is still centered and visible
+
+4. **Container matches crop aspect ratio exactly**: No expansion needed
+ - Display viewBox = crop viewBox
+
+5. **Window resize**: Re-calculate displayViewBox on container size change
+ - Use `svgDimensions` state + ResizeObserver (already implemented)
+
+## Testing Checklist
+
+- [ ] Europe in wide container shows Africa + Middle East
+- [ ] Europe in tall container shows more of map vertically
+- [ ] North America in various aspect ratios
+- [ ] Magnifier still works correctly at edges
+- [ ] Give-up zoom animation still works
+- [ ] Debug bounding boxes align with regions
+- [ ] Window resize recalculates correctly
+- [ ] No custom crop = unchanged behavior
diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
index ddb088db..7c987754 100644
--- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
+++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
@@ -12,7 +12,13 @@ import {
getLabelTextShadow,
} from '../mapColors'
import { forceSimulation, forceCollide, forceX, forceY, type SimulationNodeDatum } from 'd3-force'
-import { WORLD_MAP, USA_MAP, filterRegionsByContinent } from '../maps'
+import {
+ WORLD_MAP,
+ USA_MAP,
+ filterRegionsByContinent,
+ parseViewBox,
+ calculateFitCropViewBox,
+} from '../maps'
import type { ContinentId } from '../continents'
import {
calculateScreenPixelRatio,
@@ -490,16 +496,43 @@ export function MapRenderer({
[targetOpacity, targetTop, targetLeft, smallestRegionSize]
)
- // Parse the default viewBox for animation
+ // Calculate the display viewBox using fit-crop-with-fill strategy
+ // This ensures the custom crop region is visible while filling the container
+ const displayViewBox = useMemo(() => {
+ // If no custom crop, use the regular viewBox (which may be a calculated bounding box)
+ if (!mapData.customCrop) {
+ return mapData.viewBox
+ }
+
+ // Need container dimensions to calculate aspect ratio
+ if (svgDimensions.width <= 0 || svgDimensions.height <= 0) {
+ return mapData.viewBox
+ }
+
+ const containerAspect = svgDimensions.width / svgDimensions.height
+ const originalBounds = parseViewBox(mapData.originalViewBox)
+ const cropRegion = parseViewBox(mapData.customCrop)
+
+ const result = calculateFitCropViewBox(originalBounds, cropRegion, containerAspect)
+ console.log('[MapRenderer] Calculated displayViewBox:', {
+ customCrop: mapData.customCrop,
+ originalViewBox: mapData.originalViewBox,
+ containerAspect: containerAspect.toFixed(2),
+ result,
+ })
+ return result
+ }, [mapData.customCrop, mapData.originalViewBox, mapData.viewBox, svgDimensions])
+
+ // Parse the display viewBox for animation and calculations
const defaultViewBoxParts = useMemo(() => {
- const parts = mapData.viewBox.split(' ').map(Number)
+ const parts = displayViewBox.split(' ').map(Number)
return {
x: parts[0] || 0,
y: parts[1] || 0,
width: parts[2] || 1000,
height: parts[3] || 500,
}
- }, [mapData.viewBox])
+ }, [displayViewBox])
// State for give-up zoom animation target values
const [giveUpZoomTarget, setGiveUpZoomTarget] = useState({
@@ -760,7 +793,7 @@ export function MapRenderer({
updateDimensions()
return () => observer.disconnect()
- }, [mapData.viewBox]) // Re-measure when viewBox changes (continent/map selection)
+ }, [displayViewBox]) // Re-measure when viewBox changes (continent/map selection)
// Calculate label positions using ghost elements
useEffect(() => {
@@ -774,7 +807,7 @@ export function MapRenderer({
const smallPositions: typeof smallRegionLabelPositions = []
// Parse viewBox for scale calculations
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -1106,10 +1139,10 @@ export function MapRenderer({
return () => {
clearTimeout(timeoutId)
}
- }, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds])
+ }, [mapData, regionsFound, guessHistory, svgDimensions, excludedRegions, excludedRegionIds, displayViewBox])
// Calculate viewBox dimensions for label offset calculations and sea background
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -1465,7 +1498,7 @@ export function MapRenderer({
const containerRect = containerRef.current.getBoundingClientRect()
const svgRect = svgRef.current.getBoundingClientRect()
const magnifierWidth = containerRect.width * 0.5
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (viewBoxWidth && !Number.isNaN(viewBoxWidth)) {
@@ -1576,7 +1609,7 @@ export function MapRenderer({
>
{
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -1778,7 +1811,7 @@ export function MapRenderer({
y={zoomSpring.to((zoom: number) => {
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -1792,12 +1825,12 @@ export function MapRenderer({
return cursorSvgY - magnifiedHeight / 2
})}
width={zoomSpring.to((zoom: number) => {
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2] || 1000
return viewBoxWidth / zoom
})}
height={zoomSpring.to((zoom: number) => {
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxHeight = viewBoxParts[3] || 1000
return viewBoxHeight / zoom
})}
@@ -2002,7 +2035,7 @@ export function MapRenderer({
// Convert SVG coordinates to pixel coordinates
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -2134,7 +2167,7 @@ export function MapRenderer({
const svgRect = svgRef.current!.getBoundingClientRect()
// Convert cursor position to SVG coordinates
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -2171,7 +2204,7 @@ export function MapRenderer({
if (!containerRect || !svgRect) return 'none'
const magnifierWidth = containerRect.width * 0.5
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return 'none'
@@ -2194,7 +2227,7 @@ export function MapRenderer({
>
{/* Sea/ocean background for magnifier - solid color to match container */}
{(() => {
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
return (
{
const containerRect = containerRef.current!.getBoundingClientRect()
const svgRect = svgRef.current!.getBoundingClientRect()
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -2312,7 +2345,7 @@ export function MapRenderer({
if (!containerRect || !svgRect) return null
const magnifierWidth = containerRect.width * 0.5
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
const viewBoxHeight = viewBoxParts[3]
const viewBoxX = viewBoxParts[0] || 0
@@ -2474,7 +2507,7 @@ export function MapRenderer({
}
// Parse viewBox - these are stable values from mapData
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -2598,7 +2631,7 @@ export function MapRenderer({
}
const magnifierWidth = containerRect.width * 0.5
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) {
@@ -2634,7 +2667,7 @@ export function MapRenderer({
if (!containerRect || !svgRect) return null
const magnifierWidth = containerRect.width * 0.5
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxWidth = viewBoxParts[2]
if (!viewBoxWidth || Number.isNaN(viewBoxWidth)) return null
@@ -2684,7 +2717,7 @@ export function MapRenderer({
const magLeft = targetLeft
// Calculate indicator box position in screen coordinates
- const viewBoxParts = mapData.viewBox.split(' ').map(Number)
+ const viewBoxParts = displayViewBox.split(' ').map(Number)
const viewBoxX = viewBoxParts[0] || 0
const viewBoxY = viewBoxParts[1] || 0
const viewBoxWidth = viewBoxParts[2] || 1000
@@ -3131,7 +3164,7 @@ export function MapRenderer({
diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts
index 8fac6099..31d9c3c2 100644
--- a/apps/web/src/arcade-games/know-your-world/maps.ts
+++ b/apps/web/src/arcade-games/know-your-world/maps.ts
@@ -338,6 +338,8 @@ async function getWorldMapData(): Promise {
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 || []),
}
@@ -362,6 +364,8 @@ async function getUSAMapData(): Promise {
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,
}
@@ -681,6 +685,96 @@ export function calculateContinentViewBox(
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
*/
@@ -797,10 +891,13 @@ export async function getFilteredMapData(
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,
@@ -816,6 +913,8 @@ export async function getFilteredMapData(
...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)
}
}
@@ -853,10 +952,13 @@ export function getFilteredMapDataSync(
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,
@@ -872,5 +974,7 @@ export function getFilteredMapDataSync(
...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)
}
}
diff --git a/apps/web/src/arcade-games/know-your-world/types.ts b/apps/web/src/arcade-games/know-your-world/types.ts
index 076bd414..47c4c821 100644
--- a/apps/web/src/arcade-games/know-your-world/types.ts
+++ b/apps/web/src/arcade-games/know-your-world/types.ts
@@ -22,7 +22,9 @@ export interface MapRegion {
export interface MapData {
id: string // "world" or "usa"
name: string // "World" or "USA States"
- viewBox: string // SVG viewBox attribute (e.g., "0 0 1000 500")
+ viewBox: string // SVG viewBox attribute - may be cropped (e.g., "346.40 53.73 247.56 360.70" for Europe)
+ originalViewBox: string // Original full map viewBox (e.g., "0 0 1000 500") - used for fit-crop-with-fill
+ customCrop: string | null // Custom crop region if any, null if no custom crop applied
regions: MapRegion[]
difficultyConfig?: MapDifficultyConfig // Optional per-map difficulty config (uses global default if not provided)
}