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:
parent
32c1d2c44c
commit
b6569ed4e1
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue