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:
243
apps/web/src/app/api/dev/save-crop/route.ts
Normal file
243
apps/web/src/app/api/dev/save-crop/route.ts
Normal file
@@ -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 { useRegionDetection } from '../hooks/useRegionDetection'
|
||||||
import { usePointerLock } from '../hooks/usePointerLock'
|
import { usePointerLock } from '../hooks/usePointerLock'
|
||||||
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
|
import { useMagnifierZoom } from '../hooks/useMagnifierZoom'
|
||||||
|
import { DevCropTool } from './DevCropTool'
|
||||||
|
|
||||||
// Debug flag: show technical info in magnifier (dev only)
|
// Debug flag: show technical info in magnifier (dev only)
|
||||||
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
|
const SHOW_MAGNIFIER_DEBUG_INFO = process.env.NODE_ENV === 'development'
|
||||||
@@ -3092,6 +3093,15 @@ export function MapRenderer({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Dev-only crop tool for getting custom viewBox coordinates */}
|
||||||
|
<DevCropTool
|
||||||
|
svgRef={svgRef}
|
||||||
|
containerRef={containerRef}
|
||||||
|
viewBox={mapData.viewBox}
|
||||||
|
mapId={selectedMap}
|
||||||
|
continentId={selectedContinent}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
27
apps/web/src/arcade-games/know-your-world/customCrops.ts
Normal file
27
apps/web/src/arcade-games/know-your-world/customCrops.ts
Normal file
@@ -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 type { MapData, MapRegion } from './types'
|
||||||
|
import { getCustomCrop } from './customCrops'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type definition for @svg-maps packages
|
* Type definition for @svg-maps packages
|
||||||
@@ -640,17 +641,25 @@ export function filterRegionsByContinent(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate adjusted viewBox for a continent
|
* 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(
|
export function calculateContinentViewBox(
|
||||||
regions: MapRegion[],
|
regions: MapRegion[],
|
||||||
continentId: ContinentId | 'all',
|
continentId: ContinentId | 'all',
|
||||||
originalViewBox: string
|
originalViewBox: string,
|
||||||
|
mapId: string = 'world'
|
||||||
): string {
|
): string {
|
||||||
if (continentId === 'all') {
|
if (continentId === 'all') {
|
||||||
return originalViewBox
|
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)
|
const filteredRegions = filterRegionsByContinent(regions, continentId)
|
||||||
|
|
||||||
if (filteredRegions.length === 0) {
|
if (filteredRegions.length === 0) {
|
||||||
@@ -792,7 +801,12 @@ export async function getFilteredMapData(
|
|||||||
// Apply continent filtering for world map
|
// Apply continent filtering for world map
|
||||||
if (mapId === 'world' && continentId !== 'all') {
|
if (mapId === 'world' && continentId !== 'all') {
|
||||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||||
adjustedViewBox = calculateContinentViewBox(mapData.regions, continentId, mapData.viewBox)
|
adjustedViewBox = calculateContinentViewBox(
|
||||||
|
mapData.regions,
|
||||||
|
continentId,
|
||||||
|
mapData.viewBox,
|
||||||
|
mapId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply difficulty filtering
|
// Apply difficulty filtering
|
||||||
@@ -843,7 +857,12 @@ export function getFilteredMapDataSync(
|
|||||||
// Apply continent filtering for world map
|
// Apply continent filtering for world map
|
||||||
if (mapId === 'world' && continentId !== 'all') {
|
if (mapId === 'world' && continentId !== 'all') {
|
||||||
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
filteredRegions = filterRegionsByContinent(filteredRegions, continentId)
|
||||||
adjustedViewBox = calculateContinentViewBox(mapData.regions, continentId, mapData.viewBox)
|
adjustedViewBox = calculateContinentViewBox(
|
||||||
|
mapData.regions,
|
||||||
|
continentId,
|
||||||
|
mapData.viewBox,
|
||||||
|
mapId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply difficulty filtering
|
// Apply difficulty filtering
|
||||||
|
|||||||
Reference in New Issue
Block a user