From b6569ed4e125265facd5ba940fc5d184babb0ae1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 26 Nov 2025 05:53:30 -0600 Subject: [PATCH] feat: implement fit-crop-with-fill for custom map crops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of strict cropping that causes letterboxing, custom crops now: 1. Guarantee the crop region is fully visible and centered 2. Fill remaining viewport space with more of the map 3. Stay within original map bounds Implementation: - Add originalViewBox and customCrop fields to MapData type - Add parseViewBox() and calculateFitCropViewBox() utility functions - Calculate displayViewBox dynamically in MapRenderer based on container aspect ratio - Update all coordinate calculations to use displayViewBox Example: Europe in a wide container will show parts of Africa and Middle East alongside the centered Europe region, instead of wasted letterbox space. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../.claude/FIT_CROP_WITH_FILL_PLAN.md | 242 ++++++++++++++++++ .../components/MapRenderer.tsx | 83 ++++-- .../src/arcade-games/know-your-world/maps.ts | 104 ++++++++ .../src/arcade-games/know-your-world/types.ts | 4 +- 4 files changed, 407 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/arcade-games/know-your-world/.claude/FIT_CROP_WITH_FILL_PLAN.md 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) }