'use client'; import { useRef, useEffect, useState, useCallback } from 'react'; import { useCanvasStore } from '../../store/canvasStore'; import { CANVAS_CONFIG } from '@gaplace/shared'; interface VirtualCanvasProps { onPixelClick: (x: number, y: number) => void; onCursorMove: (x: number, y: number) => void; onChunkNeeded: (chunkX: number, chunkY: number) => void; onHoverChange: (x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => void; selectedColor: string; } export function VirtualCanvas({ onPixelClick, onCursorMove, onChunkNeeded, onHoverChange, selectedColor }: VirtualCanvasProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const animationFrameRef = useRef(undefined); const isMouseDownRef = useRef(false); const isPanningRef = useRef(false); const lastPanPointRef = useRef<{ x: number; y: number } | null>(null); const mouseDownPositionRef = useRef<{ x: number; y: number } | null>(null); const DRAG_THRESHOLD = 5; // pixels const [cursorStyle, setCursorStyle] = useState<'crosshair' | 'grab' | 'grabbing'>('crosshair'); const [hoverPixel, setHoverPixel] = useState<{ x: number; y: number } | null>(null); // Touch handling state const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); const lastTouchesRef = useRef(null); const pinchStartDistanceRef = useRef(null); const pinchStartZoomRef = useRef(null); const { viewport, chunks, selectedTool, showGrid, userCursors, setViewport, setZoom, pan, getChunkCoordinates, getPixelAt, getPixelInfo, } = useCanvasStore(); // Large modern pixel size - fixed base size to avoid circular dependencies const BASE_PIXEL_SIZE = 32; const pixelSize = BASE_PIXEL_SIZE * viewport.zoom; // Convert screen coordinates to canvas coordinates const screenToCanvas = useCallback((screenX: number, screenY: number) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; const x = Math.floor((screenX - rect.left + viewport.x) / pixelSize); const y = Math.floor((screenY - rect.top + viewport.y) / pixelSize); return { x, y }; }, [viewport.x, viewport.y, pixelSize]); // Convert canvas coordinates to screen coordinates const canvasToScreen = useCallback((canvasX: number, canvasY: number) => { return { x: canvasX * pixelSize - viewport.x, y: canvasY * pixelSize - viewport.y, }; }, [viewport.x, viewport.y, pixelSize]); // Track requested chunks to prevent spam const requestedChunksRef = useRef(new Set()); // Get visible chunks and request loading if needed const getVisibleChunks = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return []; const startX = Math.floor(viewport.x / pixelSize); const startY = Math.floor(viewport.y / pixelSize); const endX = Math.floor((viewport.x + canvas.width) / pixelSize); const endY = Math.floor((viewport.y + canvas.height) / pixelSize); const startChunkX = Math.floor(startX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE); const startChunkY = Math.floor(startY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE); const endChunkX = Math.floor(endX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE); const endChunkY = Math.floor(endY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE); const visibleChunks: Array<{ chunkX: number; chunkY: number }> = []; for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) { for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) { visibleChunks.push({ chunkX, chunkY }); // Request chunk if not loaded and not already requested const chunkKey = `${chunkX},${chunkY}`; const chunk = chunks.get(chunkKey); if (!chunk || !chunk.isLoaded) { if (!requestedChunksRef.current.has(chunkKey)) { requestedChunksRef.current.add(chunkKey); onChunkNeeded(chunkX, chunkY); // Remove from requested after a delay to allow retry setTimeout(() => { requestedChunksRef.current.delete(chunkKey); }, 5000); } } } } return visibleChunks; }, [viewport, pixelSize, chunks, onChunkNeeded]); // Track dirty state to avoid unnecessary renders const isDirtyRef = useRef(true); const lastRenderTimeRef = useRef(0); const MIN_RENDER_INTERVAL = 16; // ~60fps max // Render function with performance optimizations const render = useCallback(() => { const now = performance.now(); if (now - lastRenderTimeRef.current < MIN_RENDER_INTERVAL) { return; } if (!isDirtyRef.current) { return; } const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx) return; // Clear canvas ctx.clearRect(0, 0, canvas.width, canvas.height); // Set pixel rendering ctx.imageSmoothingEnabled = false; const visibleChunks = getVisibleChunks(); // Render pixels from visible chunks for (const { chunkX, chunkY } of visibleChunks) { const chunkKey = `${chunkX},${chunkY}`; const chunk = chunks.get(chunkKey); if (!chunk || !chunk.isLoaded) continue; for (const [pixelKey, pixelInfo] of chunk.pixels) { const [localX, localY] = pixelKey.split(',').map(Number); const worldX = chunkX * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localX; const worldY = chunkY * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localY; const screenPos = canvasToScreen(worldX, worldY); // Only render if pixel is visible if ( screenPos.x >= -pixelSize && screenPos.y >= -pixelSize && screenPos.x < canvas.width && screenPos.y < canvas.height ) { ctx.fillStyle = pixelInfo.color; ctx.fillRect(screenPos.x, screenPos.y, pixelSize, pixelSize); } } } // Render grid only when enabled and zoomed in enough (pixel size > 16px) if (showGrid && pixelSize > 16) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; ctx.shadowColor = 'rgba(0, 0, 0, 0.1)'; ctx.shadowBlur = 2; const startX = Math.floor(viewport.x / pixelSize) * pixelSize - viewport.x; const startY = Math.floor(viewport.y / pixelSize) * pixelSize - viewport.y; // Vertical grid lines for (let x = startX; x < canvas.width; x += pixelSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } // Horizontal grid lines for (let y = startY; y < canvas.height; y += pixelSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } // Reset shadow ctx.shadowBlur = 0; } // Render user cursors for (const [userId, cursor] of userCursors) { const screenPos = canvasToScreen(cursor.x, cursor.y); if ( screenPos.x >= 0 && screenPos.y >= 0 && screenPos.x < canvas.width && screenPos.y < canvas.height ) { // Draw cursor ctx.strokeStyle = cursor.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(screenPos.x + pixelSize / 2, screenPos.y + pixelSize / 2, pixelSize + 4, 0, Math.PI * 2); ctx.stroke(); // Draw username ctx.fillStyle = cursor.color; ctx.font = '12px sans-serif'; ctx.fillText(cursor.username, screenPos.x, screenPos.y - 8); } } // Render hover cursor indicator when zoomed in enough if (hoverPixel && pixelSize > 32) { const screenPos = canvasToScreen(hoverPixel.x, hoverPixel.y); if ( screenPos.x >= 0 && screenPos.y >= 0 && screenPos.x < canvas.width && screenPos.y < canvas.height ) { // Draw subtle pixel highlight border ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.lineWidth = 2; ctx.setLineDash([4, 4]); ctx.strokeRect(screenPos.x + 1, screenPos.y + 1, pixelSize - 2, pixelSize - 2); ctx.setLineDash([]); // Reset line dash // Draw small corner indicators ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; const cornerSize = 3; // Top-left corner ctx.fillRect(screenPos.x, screenPos.y, cornerSize, cornerSize); // Top-right corner ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y, cornerSize, cornerSize); // Bottom-left corner ctx.fillRect(screenPos.x, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize); // Bottom-right corner ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize); } } isDirtyRef.current = false; lastRenderTimeRef.current = now; }, [viewport, chunks, pixelSize, showGrid, userCursors, hoverPixel]); // Mouse event handlers - Left click for both pixel placement and panning const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); if (e.button === 0) { // Left click only isMouseDownRef.current = true; isPanningRef.current = false; // Reset panning state mouseDownPositionRef.current = { x: e.clientX, y: e.clientY }; lastPanPointRef.current = { x: e.clientX, y: e.clientY }; setCursorStyle('grab'); } }; const handleMouseMove = (e: React.MouseEvent) => { const { x, y } = screenToCanvas(e.clientX, e.clientY); onCursorMove(x, y); // Update hover pixel for cursor indicator setHoverPixel({ x, y }); // Get pixel info at this position and call onHoverChange const pixelInfo = getPixelInfo(x, y); onHoverChange(x, y, pixelInfo); if (isMouseDownRef.current && mouseDownPositionRef.current && lastPanPointRef.current) { // Calculate distance from initial mouse down position const deltaFromStart = Math.sqrt( Math.pow(e.clientX - mouseDownPositionRef.current.x, 2) + Math.pow(e.clientY - mouseDownPositionRef.current.y, 2) ); // If moved more than threshold, start panning if (deltaFromStart > DRAG_THRESHOLD && !isPanningRef.current) { isPanningRef.current = true; setCursorStyle('grabbing'); } // If we're panning, update viewport if (isPanningRef.current) { const deltaX = lastPanPointRef.current.x - e.clientX; const deltaY = lastPanPointRef.current.y - e.clientY; pan(deltaX, deltaY); lastPanPointRef.current = { x: e.clientX, y: e.clientY }; } } }; const handleMouseUp = (e: React.MouseEvent) => { if (e.button === 0) { // If we weren't panning, treat as pixel click if (!isPanningRef.current && mouseDownPositionRef.current) { const { x, y } = screenToCanvas(e.clientX, e.clientY); onPixelClick(x, y); } // Reset all mouse state isMouseDownRef.current = false; isPanningRef.current = false; lastPanPointRef.current = null; mouseDownPositionRef.current = null; setCursorStyle('crosshair'); } }; const handleMouseLeave = () => { // Stop all interactions when mouse leaves canvas isMouseDownRef.current = false; isPanningRef.current = false; lastPanPointRef.current = null; mouseDownPositionRef.current = null; setHoverPixel(null); setCursorStyle('crosshair'); }; const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); // Get mouse position in screen coordinates (relative to canvas) const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const mouseScreenX = e.clientX - rect.left; const mouseScreenY = e.clientY - rect.top; // Calculate world position that mouse is pointing to const worldX = (mouseScreenX + viewport.x) / pixelSize; const worldY = (mouseScreenY + viewport.y) / pixelSize; // Calculate new zoom with better zoom increments const zoomFactor = e.deltaY > 0 ? 0.8 : 1.25; const newZoom = Math.max(0.1, Math.min(10.0, viewport.zoom * zoomFactor)); // Calculate new pixel size const newPixelSize = BASE_PIXEL_SIZE * newZoom; // Calculate new viewport position to keep world position under cursor const newViewportX = worldX * newPixelSize - mouseScreenX; const newViewportY = worldY * newPixelSize - mouseScreenY; // Update viewport with new zoom and position setViewport({ zoom: newZoom, x: newViewportX, y: newViewportY, }); }; // Resize handler useEffect(() => { const handleResize = () => { const container = containerRef.current; const canvas = canvasRef.current; if (!container || !canvas) return; const rect = container.getBoundingClientRect(); const devicePixelRatio = window.devicePixelRatio || 1; // Set the internal size to actual resolution canvas.width = rect.width * devicePixelRatio; canvas.height = rect.height * devicePixelRatio; // Scale the canvas back down using CSS canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; // Scale the drawing context so everything draws at the correct size const ctx = canvas.getContext('2d'); if (ctx) { ctx.scale(devicePixelRatio, devicePixelRatio); } setViewport({ width: rect.width, height: rect.height }); }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [setViewport]); // Mark as dirty when viewport, chunks, or other dependencies change useEffect(() => { isDirtyRef.current = true; }, [viewport, chunks, userCursors, hoverPixel]); // Animation loop with render on demand useEffect(() => { const animate = () => { render(); animationFrameRef.current = requestAnimationFrame(animate); }; animationFrameRef.current = requestAnimationFrame(animate); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [render]); // Touch event handlers for mobile const handleTouchStart = (e: React.TouchEvent) => { e.preventDefault(); if (e.touches.length === 1) { // Single touch - potential tap or pan const touch = e.touches[0]; touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; lastPanPointRef.current = { x: touch.clientX, y: touch.clientY }; } else if (e.touches.length === 2) { // Two finger pinch to zoom const touch1 = e.touches[0]; const touch2 = e.touches[1]; const distance = Math.sqrt( Math.pow(touch1.clientX - touch2.clientX, 2) + Math.pow(touch1.clientY - touch2.clientY, 2) ); pinchStartDistanceRef.current = distance; pinchStartZoomRef.current = viewport.zoom; // Center point between touches for zoom const centerX = (touch1.clientX + touch2.clientX) / 2; const centerY = (touch1.clientY + touch2.clientY) / 2; lastPanPointRef.current = { x: centerX, y: centerY }; } lastTouchesRef.current = Array.from(e.touches) as any; }; const handleTouchMove = (e: React.TouchEvent) => { e.preventDefault(); if (e.touches.length === 1 && touchStartRef.current && lastPanPointRef.current) { // Single touch pan const touch = e.touches[0]; const deltaX = lastPanPointRef.current.x - touch.clientX; const deltaY = lastPanPointRef.current.y - touch.clientY; // Check if we've moved enough to start panning const totalDistance = Math.sqrt( Math.pow(touch.clientX - touchStartRef.current.x, 2) + Math.pow(touch.clientY - touchStartRef.current.y, 2) ); if (totalDistance > DRAG_THRESHOLD) { isPanningRef.current = true; // Pan the viewport const newViewportX = viewport.x + deltaX; const newViewportY = viewport.y + deltaY; setViewport({ x: newViewportX, y: newViewportY, }); lastPanPointRef.current = { x: touch.clientX, y: touch.clientY }; } // Update hover for single touch const { x, y } = screenToCanvas(touch.clientX, touch.clientY); setHoverPixel({ x, y }); const pixelInfo = getPixelInfo(x, y); onHoverChange(x, y, pixelInfo); } else if (e.touches.length === 2 && pinchStartDistanceRef.current && pinchStartZoomRef.current) { // Two finger pinch zoom const touch1 = e.touches[0]; const touch2 = e.touches[1]; const currentDistance = Math.sqrt( Math.pow(touch1.clientX - touch2.clientX, 2) + Math.pow(touch1.clientY - touch2.clientY, 2) ); const scale = currentDistance / pinchStartDistanceRef.current; const newZoom = Math.max(0.1, Math.min(5.0, pinchStartZoomRef.current * scale)); // Zoom towards center of pinch const rect = canvasRef.current?.getBoundingClientRect(); if (rect) { const centerX = (touch1.clientX + touch2.clientX) / 2 - rect.left; const centerY = (touch1.clientY + touch2.clientY) / 2 - rect.top; const worldX = (centerX + viewport.x) / pixelSize; const worldY = (centerY + viewport.y) / pixelSize; const newPixelSize = BASE_PIXEL_SIZE * newZoom; const newViewportX = worldX * newPixelSize - centerX; const newViewportY = worldY * newPixelSize - centerY; setViewport({ zoom: newZoom, x: newViewportX, y: newViewportY, }); } } }; const handleTouchEnd = (e: React.TouchEvent) => { e.preventDefault(); if (e.touches.length === 0) { // All touches ended if (touchStartRef.current && !isPanningRef.current) { // This was a tap, not a pan const timeDiff = Date.now() - touchStartRef.current.time; if (timeDiff < 300) { // Quick tap const { x, y } = screenToCanvas(touchStartRef.current.x, touchStartRef.current.y); onPixelClick(x, y); } } // Reset touch state touchStartRef.current = null; isPanningRef.current = false; lastPanPointRef.current = null; pinchStartDistanceRef.current = null; pinchStartZoomRef.current = null; } lastTouchesRef.current = Array.from(e.touches) as any; }; return (
e.preventDefault()} className="w-full h-full touch-none" style={{ touchAction: 'none' }} />
); }