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:
Thomas Hallock 2025-11-25 16:29:30 -06:00
parent e2884a5103
commit 855e5df2c0
5 changed files with 687 additions and 4 deletions

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

View File

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

View File

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

View 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
}

View File

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