feat: implement fit-crop-with-fill for custom map crops

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-26 05:53:30 -06:00
parent 32c1d2c44c
commit b6569ed4e1
4 changed files with 407 additions and 26 deletions

View File

@ -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
<animated.svg viewBox={displayViewBox} ... />
```
### 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

View File

@ -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({
>
<animated.svg
ref={svgRef}
viewBox={mapData.viewBox}
viewBox={displayViewBox}
className={css({
maxWidth: '100%',
maxHeight: '100%',
@ -1762,7 +1795,7 @@ export function MapRenderer({
x={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
@ -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 (
<rect
x={viewBoxParts[0] || 0}
@ -2259,7 +2292,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
@ -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({
<DevCropTool
svgRef={svgRef}
containerRef={containerRef}
viewBox={mapData.viewBox}
viewBox={displayViewBox}
mapId={selectedMap}
continentId={selectedContinent}
/>

View File

@ -338,6 +338,8 @@ async function getWorldMapData(): Promise<MapData> {
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<MapData> {
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)
}
}

View File

@ -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)
}