diff --git a/apps/web/src/app/api/dev/save-crop/route.ts b/apps/web/src/app/api/dev/save-crop/route.ts new file mode 100644 index 00000000..1c037a20 --- /dev/null +++ b/apps/web/src/app/api/dev/save-crop/route.ts @@ -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> { + 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>): 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 } + ) + } +} diff --git a/apps/web/src/arcade-games/know-your-world/components/DevCropTool.tsx b/apps/web/src/arcade-games/know-your-world/components/DevCropTool.tsx new file mode 100644 index 00000000..68ee45b7 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/components/DevCropTool.tsx @@ -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 + containerRef: React.RefObject + 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(null) + const [finalBox, setFinalBox] = useState(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 && ( +
+ )} + + {/* Draw the bounding box */} + {screenBox && ( +
+ {/* Show dimensions */} + {finalBox && ( +
+ {(finalBox.endX - finalBox.startX).toFixed(0)} ×{' '} + {(finalBox.endY - finalBox.startY).toFixed(0)} +
+ )} +
+ )} + + {/* Status indicator */} + {isActive && ( +
+
+ 🎯 CROP MODE: {mapId}/{continentId} +
+
+ Draw a box • R to reset • ESC to exit +
+ {saveStatus === 'saving' && ( +
+ ⏳ Saving... +
+ )} + {saveStatus === 'saved' && ( +
+ ✅ Saved! Reload to see changes. +
+ )} + {saveStatus === 'error' && ( +
+ ❌ Error saving. Check console. +
+ )} +
+ )} + + ) +} diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 3f6183c5..023b9ec0 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -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({ ) })()} + + {/* Dev-only crop tool for getting custom viewBox coordinates */} +
) } diff --git a/apps/web/src/arcade-games/know-your-world/customCrops.ts b/apps/web/src/arcade-games/know-your-world/customCrops.ts new file mode 100644 index 00000000..6ba76411 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/customCrops.ts @@ -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 +} diff --git a/apps/web/src/arcade-games/know-your-world/maps.ts b/apps/web/src/arcade-games/know-your-world/maps.ts index fae028ad..8fac6099 100644 --- a/apps/web/src/arcade-games/know-your-world/maps.ts +++ b/apps/web/src/arcade-games/know-your-world/maps.ts @@ -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