feat: add dev-only crop tool for custom map region cropping
- Add DevCropTool component (Ctrl+Shift+B to activate, R to reset, ESC to exit) - Add customCrops.ts for storing viewBox overrides per map/continent - Add /api/dev/save-crop endpoint to automatically update customCrops.ts - Integrate custom crops into calculateContinentViewBox in maps.ts - Use proper brace-matching algorithm for safe TypeScript file editing This allows fine-tuning map region crops (e.g., cropping Russia out of Europe view) without manual file editing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e2884a5103
commit
855e5df2c0
|
|
@ -0,0 +1,243 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
/**
|
||||
* Dev-only API endpoint to save/delete crop coordinates in customCrops.ts
|
||||
* Only works in development mode
|
||||
*
|
||||
* POST: Save a new crop
|
||||
* DELETE: Remove a crop
|
||||
*/
|
||||
|
||||
const CUSTOM_CROPS_PATH = join(process.cwd(), 'src/arcade-games/know-your-world/customCrops.ts')
|
||||
|
||||
function parseCropsFile(): Record<string, Record<string, string>> {
|
||||
const currentContent = readFileSync(CUSTOM_CROPS_PATH, 'utf-8')
|
||||
|
||||
// Find the customCrops assignment and extract the object
|
||||
// Look for the pattern and then find the matching closing brace
|
||||
const startMatch = currentContent.match(/export const customCrops: CropOverrides = /)
|
||||
if (!startMatch || startMatch.index === undefined) {
|
||||
console.error('[DevCropTool] Could not find customCrops declaration')
|
||||
return {}
|
||||
}
|
||||
|
||||
const startIndex = startMatch.index + startMatch[0].length
|
||||
let braceCount = 0
|
||||
let endIndex = startIndex
|
||||
let inString = false
|
||||
let stringChar = ''
|
||||
|
||||
for (let i = startIndex; i < currentContent.length; i++) {
|
||||
const char = currentContent[i]
|
||||
|
||||
// Handle string literals
|
||||
if ((char === "'" || char === '"') && currentContent[i - 1] !== '\\') {
|
||||
if (!inString) {
|
||||
inString = true
|
||||
stringChar = char
|
||||
} else if (char === stringChar) {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inString) continue
|
||||
|
||||
if (char === '{') {
|
||||
braceCount++
|
||||
} else if (char === '}') {
|
||||
braceCount--
|
||||
if (braceCount === 0) {
|
||||
endIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const objectStr = currentContent.slice(startIndex, endIndex)
|
||||
|
||||
try {
|
||||
const cleanedObject = objectStr
|
||||
.replace(/\/\/.*$/gm, '') // Remove comments
|
||||
.replace(/,(\s*[}\]])/g, '$1') // Remove trailing commas
|
||||
.replace(/'/g, '"') // Convert single quotes to double
|
||||
return JSON.parse(cleanedObject)
|
||||
} catch (e) {
|
||||
console.error('[DevCropTool] Failed to parse crops:', e, objectStr)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeCropsFile(crops: Record<string, Record<string, string>>): void {
|
||||
const currentContent = readFileSync(CUSTOM_CROPS_PATH, 'utf-8')
|
||||
|
||||
// Find the customCrops assignment
|
||||
const startMatch = currentContent.match(/export const customCrops: CropOverrides = /)
|
||||
if (!startMatch || startMatch.index === undefined) {
|
||||
console.error('[DevCropTool] Could not find customCrops declaration for writing')
|
||||
return
|
||||
}
|
||||
|
||||
const declStart = startMatch.index
|
||||
const objStart = declStart + startMatch[0].length
|
||||
|
||||
// Find the matching closing brace
|
||||
let braceCount = 0
|
||||
let endIndex = objStart
|
||||
let inString = false
|
||||
let stringChar = ''
|
||||
|
||||
for (let i = objStart; i < currentContent.length; i++) {
|
||||
const char = currentContent[i]
|
||||
|
||||
if ((char === "'" || char === '"') && currentContent[i - 1] !== '\\') {
|
||||
if (!inString) {
|
||||
inString = true
|
||||
stringChar = char
|
||||
} else if (char === stringChar) {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (inString) continue
|
||||
|
||||
if (char === '{') {
|
||||
braceCount++
|
||||
} else if (char === '}') {
|
||||
braceCount--
|
||||
if (braceCount === 0) {
|
||||
endIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format the new crops object
|
||||
let formattedCrops: string
|
||||
if (Object.keys(crops).length === 0) {
|
||||
formattedCrops = '{}'
|
||||
} else {
|
||||
formattedCrops = JSON.stringify(crops, null, 2)
|
||||
.replace(/"([^"]+)":/g, '$1:') // Remove quotes from keys
|
||||
.replace(/"/g, "'") // Use single quotes for values
|
||||
}
|
||||
|
||||
// Replace the object
|
||||
const newContent =
|
||||
currentContent.slice(0, objStart) + formattedCrops + currentContent.slice(endIndex)
|
||||
|
||||
writeFileSync(CUSTOM_CROPS_PATH, newContent, 'utf-8')
|
||||
}
|
||||
|
||||
interface CropRequest {
|
||||
mapId: string
|
||||
continentId: string
|
||||
viewBox?: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Only allow in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This endpoint is only available in development mode' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: CropRequest = await request.json()
|
||||
const { mapId, continentId, viewBox } = body
|
||||
|
||||
if (!mapId || !continentId || !viewBox) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: mapId, continentId, viewBox' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const crops = parseCropsFile()
|
||||
|
||||
// Update the crops
|
||||
if (!crops[mapId]) {
|
||||
crops[mapId] = {}
|
||||
}
|
||||
crops[mapId][continentId] = viewBox
|
||||
|
||||
writeCropsFile(crops)
|
||||
|
||||
console.log(`[DevCropTool] Saved crop for ${mapId}/${continentId}: ${viewBox}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Saved crop for ${mapId}/${continentId}`,
|
||||
crops,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[DevCropTool] Error saving crop:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save crop', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Only allow in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return NextResponse.json(
|
||||
{ error: 'This endpoint is only available in development mode' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const mapId = searchParams.get('mapId')
|
||||
const continentId = searchParams.get('continentId')
|
||||
|
||||
if (!mapId || !continentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required query params: mapId, continentId' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const crops = parseCropsFile()
|
||||
|
||||
// Check if crop exists
|
||||
if (!crops[mapId]?.[continentId]) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `No crop found for ${mapId}/${continentId}`,
|
||||
crops,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete the crop
|
||||
delete crops[mapId][continentId]
|
||||
|
||||
// Clean up empty map objects
|
||||
if (Object.keys(crops[mapId]).length === 0) {
|
||||
delete crops[mapId]
|
||||
}
|
||||
|
||||
writeCropsFile(crops)
|
||||
|
||||
console.log(`[DevCropTool] Deleted crop for ${mapId}/${continentId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Deleted crop for ${mapId}/${continentId}`,
|
||||
crops,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[DevCropTool] Error deleting crop:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete crop', details: String(error) },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { css } from '@styled/css'
|
||||
|
||||
/**
|
||||
* Dev-only tool for drawing bounding boxes to get crop coordinates
|
||||
* Activated with Ctrl+Shift+B (or Cmd+Shift+B on Mac)
|
||||
*
|
||||
* Usage:
|
||||
* 1. Press Ctrl+Shift+B to activate crop mode
|
||||
* 2. Click and drag to draw a bounding box
|
||||
* 3. A JSON file is automatically downloaded
|
||||
* 4. Save it to: apps/web/src/arcade-games/know-your-world/pending-crop.json
|
||||
* 5. Run: npm run apply-crop
|
||||
* 6. Press Escape or Ctrl+Shift+B again to deactivate
|
||||
*/
|
||||
|
||||
interface DevCropToolProps {
|
||||
svgRef: React.RefObject<SVGSVGElement>
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
viewBox: string
|
||||
mapId: string
|
||||
continentId: string
|
||||
}
|
||||
|
||||
interface CropBox {
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
}
|
||||
|
||||
export function DevCropTool({
|
||||
svgRef,
|
||||
containerRef,
|
||||
viewBox,
|
||||
mapId,
|
||||
continentId,
|
||||
}: DevCropToolProps) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [cropBox, setCropBox] = useState<CropBox | null>(null)
|
||||
const [finalBox, setFinalBox] = useState<CropBox | null>(null)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
// Reset crop for current map/continent
|
||||
const resetCrop = useCallback(() => {
|
||||
setSaveStatus('saving')
|
||||
fetch(`/api/dev/save-crop?mapId=${mapId}&continentId=${continentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log(`✅ Crop reset! Reload to see changes.`)
|
||||
setSaveStatus('saved')
|
||||
setFinalBox(null)
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
} else {
|
||||
console.error('❌ Failed to reset crop:', data.error)
|
||||
setSaveStatus('error')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('❌ Failed to reset crop:', err)
|
||||
setSaveStatus('error')
|
||||
})
|
||||
}, [mapId, continentId])
|
||||
|
||||
// Parse viewBox
|
||||
const viewBoxParts = viewBox.split(' ').map(Number)
|
||||
const viewBoxX = viewBoxParts[0] || 0
|
||||
const viewBoxY = viewBoxParts[1] || 0
|
||||
const viewBoxWidth = viewBoxParts[2] || 1000
|
||||
const viewBoxHeight = viewBoxParts[3] || 1000
|
||||
|
||||
// Convert screen coordinates to SVG coordinates
|
||||
const screenToSvg = useCallback(
|
||||
(screenX: number, screenY: number): { x: number; y: number } | null => {
|
||||
const svg = svgRef.current
|
||||
const container = containerRef.current
|
||||
if (!svg || !container) return null
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
// Position relative to SVG
|
||||
const relX = screenX - svgRect.left
|
||||
const relY = screenY - svgRect.top
|
||||
|
||||
// Scale to SVG coordinates
|
||||
const scaleX = viewBoxWidth / svgRect.width
|
||||
const scaleY = viewBoxHeight / svgRect.height
|
||||
|
||||
return {
|
||||
x: viewBoxX + relX * scaleX,
|
||||
y: viewBoxY + relY * scaleY,
|
||||
}
|
||||
},
|
||||
[svgRef, containerRef, viewBoxX, viewBoxY, viewBoxWidth, viewBoxHeight]
|
||||
)
|
||||
|
||||
// Convert SVG coordinates to screen coordinates (for display)
|
||||
const svgToScreen = useCallback(
|
||||
(svgX: number, svgY: number): { x: number; y: number } | null => {
|
||||
const svg = svgRef.current
|
||||
const container = containerRef.current
|
||||
if (!svg || !container) return null
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const svgRect = svg.getBoundingClientRect()
|
||||
|
||||
const scaleX = svgRect.width / viewBoxWidth
|
||||
const scaleY = svgRect.height / viewBoxHeight
|
||||
|
||||
return {
|
||||
x: (svgX - viewBoxX) * scaleX + (svgRect.left - containerRect.left),
|
||||
y: (svgY - viewBoxY) * scaleY + (svgRect.top - containerRect.top),
|
||||
}
|
||||
},
|
||||
[svgRef, containerRef, viewBoxX, viewBoxY, viewBoxWidth, viewBoxHeight]
|
||||
)
|
||||
|
||||
// Handle keyboard shortcut
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Shift+B (or Cmd+Shift+B on Mac)
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'b') {
|
||||
e.preventDefault()
|
||||
setIsActive((prev) => !prev)
|
||||
if (isActive) {
|
||||
setCropBox(null)
|
||||
setFinalBox(null)
|
||||
}
|
||||
}
|
||||
// R to reset crop (when in crop mode)
|
||||
if (e.key.toLowerCase() === 'r' && isActive && !isDrawing) {
|
||||
e.preventDefault()
|
||||
resetCrop()
|
||||
}
|
||||
// Escape to deactivate
|
||||
if (e.key === 'Escape' && isActive) {
|
||||
setIsActive(false)
|
||||
setCropBox(null)
|
||||
setFinalBox(null)
|
||||
setIsDrawing(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isActive, isDrawing, resetCrop])
|
||||
|
||||
// Handle mouse events for drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isActive) return
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY)
|
||||
if (!svgCoords) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setFinalBox(null)
|
||||
setCropBox({
|
||||
startX: svgCoords.x,
|
||||
startY: svgCoords.y,
|
||||
endX: svgCoords.x,
|
||||
endY: svgCoords.y,
|
||||
})
|
||||
},
|
||||
[isActive, screenToSvg]
|
||||
)
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isActive || !isDrawing || !cropBox) return
|
||||
|
||||
const svgCoords = screenToSvg(e.clientX, e.clientY)
|
||||
if (!svgCoords) return
|
||||
|
||||
setCropBox((prev) => (prev ? { ...prev, endX: svgCoords.x, endY: svgCoords.y } : null))
|
||||
},
|
||||
[isActive, isDrawing, cropBox, screenToSvg]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isActive || !isDrawing || !cropBox) return
|
||||
|
||||
setIsDrawing(false)
|
||||
|
||||
// Calculate final box (normalize so min is start)
|
||||
const minX = Math.min(cropBox.startX, cropBox.endX)
|
||||
const maxX = Math.max(cropBox.startX, cropBox.endX)
|
||||
const minY = Math.min(cropBox.startY, cropBox.endY)
|
||||
const maxY = Math.max(cropBox.startY, cropBox.endY)
|
||||
|
||||
const finalCropBox = {
|
||||
startX: minX,
|
||||
startY: minY,
|
||||
endX: maxX,
|
||||
endY: maxY,
|
||||
}
|
||||
|
||||
setFinalBox(finalCropBox)
|
||||
setCropBox(null)
|
||||
|
||||
// Calculate viewBox string
|
||||
const width = maxX - minX
|
||||
const height = maxY - minY
|
||||
const viewBoxStr = `${minX.toFixed(2)} ${minY.toFixed(2)} ${width.toFixed(2)} ${height.toFixed(2)}`
|
||||
|
||||
console.log(`[DevCropTool] Saving crop for ${mapId}/${continentId}: ${viewBoxStr}`)
|
||||
setSaveStatus('saving')
|
||||
|
||||
// POST to API to save directly to customCrops.ts
|
||||
fetch('/api/dev/save-crop', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mapId,
|
||||
continentId,
|
||||
viewBox: viewBoxStr,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log(`✅ Crop saved! Reload to see changes.`)
|
||||
console.log('Updated crops:', data.crops)
|
||||
setSaveStatus('saved')
|
||||
setTimeout(() => setSaveStatus('idle'), 2000)
|
||||
} else {
|
||||
console.error('❌ Failed to save crop:', data.error)
|
||||
setSaveStatus('error')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('❌ Failed to save crop:', err)
|
||||
setSaveStatus('error')
|
||||
})
|
||||
}, [isActive, isDrawing, cropBox, mapId, continentId])
|
||||
|
||||
// Don't render in production
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate screen coordinates for display
|
||||
const currentBox = cropBox || finalBox
|
||||
let screenBox: { left: number; top: number; width: number; height: number } | null = null
|
||||
|
||||
if (currentBox) {
|
||||
const minX = Math.min(currentBox.startX, currentBox.endX)
|
||||
const maxX = Math.max(currentBox.startX, currentBox.endX)
|
||||
const minY = Math.min(currentBox.startY, currentBox.endY)
|
||||
const maxY = Math.max(currentBox.startY, currentBox.endY)
|
||||
|
||||
const topLeft = svgToScreen(minX, minY)
|
||||
const bottomRight = svgToScreen(maxX, maxY)
|
||||
|
||||
if (topLeft && bottomRight) {
|
||||
screenBox = {
|
||||
left: topLeft.x,
|
||||
top: topLeft.y,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay for capturing mouse events when active */}
|
||||
{isActive && (
|
||||
<div
|
||||
data-element="crop-tool-overlay"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
cursor: 'crosshair',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Draw the bounding box */}
|
||||
{screenBox && (
|
||||
<div
|
||||
data-element="crop-box"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
border: '2px dashed',
|
||||
borderColor: finalBox ? 'green.500' : 'red.500',
|
||||
bg: finalBox ? 'green.500/20' : 'red.500/20',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1001,
|
||||
})}
|
||||
style={{
|
||||
left: `${screenBox.left}px`,
|
||||
top: `${screenBox.top}px`,
|
||||
width: `${screenBox.width}px`,
|
||||
height: `${screenBox.height}px`,
|
||||
}}
|
||||
>
|
||||
{/* Show dimensions */}
|
||||
{finalBox && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-24px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
bg: 'green.700',
|
||||
color: 'white',
|
||||
px: 2,
|
||||
py: 1,
|
||||
rounded: 'md',
|
||||
fontSize: 'xs',
|
||||
fontFamily: 'mono',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{(finalBox.endX - finalBox.startX).toFixed(0)} ×{' '}
|
||||
{(finalBox.endY - finalBox.startY).toFixed(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
{isActive && (
|
||||
<div
|
||||
data-element="crop-tool-status"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: 2,
|
||||
bg: 'red.600',
|
||||
color: 'white',
|
||||
px: 3,
|
||||
py: 2,
|
||||
rounded: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
zIndex: 1002,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
🎯 CROP MODE: {mapId}/{continentId}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', fontWeight: 'normal' })}>
|
||||
Draw a box • R to reset • ESC to exit
|
||||
</div>
|
||||
{saveStatus === 'saving' && (
|
||||
<div className={css({ fontSize: 'xs', fontWeight: 'normal', color: 'yellow.200' })}>
|
||||
⏳ Saving...
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'saved' && (
|
||||
<div className={css({ fontSize: 'xs', fontWeight: 'normal', color: 'green.200' })}>
|
||||
✅ Saved! Reload to see changes.
|
||||
</div>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<div className={css({ fontSize: 'xs', fontWeight: 'normal', color: 'red.200' })}>
|
||||
❌ Error saving. Check console.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { findOptimalZoom, type BoundingBox as DebugBoundingBox } from '../utils/
|
|||
import { useRegionDetection } from '../hooks/useRegionDetection'
|
||||
import { usePointerLock } from '../hooks/usePointerLock'
|
||||
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
|
||||
import { DevCropTool } from './DevCropTool'
|
||||
|
||||
// Debug flag: show technical info in magnifier (dev only)
|
||||
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
|
||||
|
|
@ -3092,6 +3093,15 @@ export function MapRenderer({
|
|||
</button>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Dev-only crop tool for getting custom viewBox coordinates */}
|
||||
<DevCropTool
|
||||
svgRef={svgRef}
|
||||
containerRef={containerRef}
|
||||
viewBox={mapData.viewBox}
|
||||
mapId={selectedMap}
|
||||
continentId={selectedContinent}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Custom viewBox overrides for map/continent combinations
|
||||
*
|
||||
* This file is automatically updated by the DevCropTool (Ctrl+Shift+B in dev mode)
|
||||
*
|
||||
* Format: { [mapId]: { [continentId]: viewBox } }
|
||||
*/
|
||||
|
||||
export interface CropOverrides {
|
||||
[mapId: string]: {
|
||||
[continentId: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export const customCrops: CropOverrides = {
|
||||
world: {
|
||||
europe: '441.40 70.72 193.21 291.71'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom crop viewBox for a map/continent combination
|
||||
* Returns null if no custom crop is defined
|
||||
*/
|
||||
export function getCustomCrop(mapId: string, continentId: string): string | null {
|
||||
return customCrops[mapId]?.[continentId] ?? null
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { MapData, MapRegion } from './types'
|
||||
import { getCustomCrop } from './customCrops'
|
||||
|
||||
/**
|
||||
* Type definition for @svg-maps packages
|
||||
|
|
@ -640,17 +641,25 @@ export function filterRegionsByContinent(
|
|||
|
||||
/**
|
||||
* Calculate adjusted viewBox for a continent
|
||||
* Adds padding around the bounding box
|
||||
* Uses custom crop if available, otherwise calculates from bounding box with padding
|
||||
*/
|
||||
export function calculateContinentViewBox(
|
||||
regions: MapRegion[],
|
||||
continentId: ContinentId | 'all',
|
||||
originalViewBox: string
|
||||
originalViewBox: string,
|
||||
mapId: string = 'world'
|
||||
): string {
|
||||
if (continentId === 'all') {
|
||||
return originalViewBox
|
||||
}
|
||||
|
||||
// Check for custom crop override first
|
||||
const customCrop = getCustomCrop(mapId, continentId)
|
||||
if (customCrop) {
|
||||
console.log(`[Maps] Using custom crop for ${mapId}/${continentId}: ${customCrop}`)
|
||||
return customCrop
|
||||
}
|
||||
|
||||
const filteredRegions = filterRegionsByContinent(regions, continentId)
|
||||
|
||||
if (filteredRegions.length === 0) {
|
||||
|
|
@ -792,7 +801,12 @@ export async function getFilteredMapData(
|
|||
// Apply continent filtering for world map
|
||||
if (mapId === 'world' && continentId !== 'all') {
|
||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||
adjustedViewBox = calculateContinentViewBox(mapData.regions, continentId, mapData.viewBox)
|
||||
adjustedViewBox = calculateContinentViewBox(
|
||||
mapData.regions,
|
||||
continentId,
|
||||
mapData.viewBox,
|
||||
mapId
|
||||
)
|
||||
}
|
||||
|
||||
// Apply difficulty filtering
|
||||
|
|
@ -843,7 +857,12 @@ export function getFilteredMapDataSync(
|
|||
// Apply continent filtering for world map
|
||||
if (mapId === 'world' && continentId !== 'all') {
|
||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||
adjustedViewBox = calculateContinentViewBox(mapData.regions, continentId, mapData.viewBox)
|
||||
adjustedViewBox = calculateContinentViewBox(
|
||||
mapData.regions,
|
||||
continentId,
|
||||
mapData.viewBox,
|
||||
mapId
|
||||
)
|
||||
}
|
||||
|
||||
// Apply difficulty filtering
|
||||
|
|
|
|||
Loading…
Reference in New Issue