Collaborative-pixel-art/backend/src/services/CanvasService.ts
martin 1da96f34a6 Modernize collaborative pixel art platform to production-ready architecture
Major refactor from simple HTML/JS app to modern full-stack TypeScript application:

## Architecture Changes
- Migrated to monorepo structure with workspaces (backend, frontend, shared)
- Backend: Node.js + Express + TypeScript + Socket.IO
- Frontend: Next.js 15.5 + React 19 + TypeScript + Tailwind CSS
- Shared: Common types and utilities across packages

## Key Features Implemented
- Real-time WebSocket collaboration via Socket.IO
- Virtual canvas with chunked loading for performance
- Modern UI with dark mode and responsive design
- Mock database system for easy development (Redis/PostgreSQL compatible)
- Comprehensive error handling and rate limiting
- User presence and cursor tracking
- Infinite canvas support with zoom/pan controls

## Performance Optimizations
- Canvas virtualization - only renders visible viewport
- Chunked pixel data loading (64x64 pixel chunks)
- Optimized WebSocket protocol
- Memory-efficient state management with Zustand

## Development Experience
- Full TypeScript support across all packages
- Hot reload for both frontend and backend
- Docker support for production deployment
- Comprehensive linting and formatting
- Automated development server startup

## Fixed Issues
- Corrected start script paths
- Updated environment configuration
- Fixed ESLint configuration issues
- Ensured all dependencies are properly installed
- Verified build process works correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 19:28:05 +02:00

153 lines
No EOL
4.9 KiB
TypeScript

import { redisClient } from '../config/database-factory';
import {
Pixel,
PixelChunk,
getChunkCoordinates,
getChunkKey,
getPixelKey,
CANVAS_CONFIG
} from '@gaplace/shared';
import { config } from '../config/env';
export class CanvasService {
private readonly keyPrefix = config.redis.keyPrefix;
async placePixel(canvasId: string, x: number, y: number, color: string, userId: string): Promise<boolean> {
try {
const { chunkX, chunkY } = getChunkCoordinates(x, y);
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
const pixelKey = getPixelKey(x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE, y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
// Simple approach without pipeline for better compatibility
await redisClient.hSet(chunkKey, pixelKey, color);
// Update chunk metadata
await redisClient.hSet(`${chunkKey}:meta`, {
lastModified: Date.now().toString(),
lastUser: userId,
});
// Track user pixel count
await redisClient.incr(`${this.keyPrefix}user:${userId}:pixels`);
// Update canvas stats
await redisClient.incr(`${this.keyPrefix}canvas:${canvasId}:totalPixels`);
await redisClient.set(`${this.keyPrefix}canvas:${canvasId}:lastActivity`, Date.now().toString());
return true;
} catch (error) {
console.error('Error placing pixel:', error);
return false;
}
}
async getChunk(canvasId: string, chunkX: number, chunkY: number): Promise<PixelChunk | null> {
try {
const chunkKey = `${this.keyPrefix}canvas:${canvasId}:chunk:${getChunkKey(chunkX, chunkY)}`;
const [pixelData, metadata] = await Promise.all([
redisClient.hGetAll(chunkKey),
redisClient.hGetAll(`${chunkKey}:meta`)
]);
if (Object.keys(pixelData).length === 0) {
return null;
}
const pixels = new Map<string, string>();
for (const [key, color] of Object.entries(pixelData)) {
pixels.set(key, String(color));
}
return {
chunkX,
chunkY,
pixels,
lastModified: parseInt(metadata.lastModified || '0'),
};
} catch (error) {
console.error('Error getting chunk:', error);
return null;
}
}
async getVisibleChunks(canvasId: string, startX: number, startY: number, endX: number, endY: number): Promise<PixelChunk[]> {
const chunks: PixelChunk[] = [];
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 promises: Promise<PixelChunk | null>[] = [];
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
promises.push(this.getChunk(canvasId, chunkX, chunkY));
}
}
const results = await Promise.all(promises);
for (const chunk of results) {
if (chunk) {
chunks.push(chunk);
}
}
return chunks;
}
async getCanvasStats(canvasId: string): Promise<{
totalPixels: number;
lastActivity: number;
activeUsers: number;
}> {
try {
const [totalPixels, lastActivity, activeUsers] = await Promise.all([
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:totalPixels`),
redisClient.get(`${this.keyPrefix}canvas:${canvasId}:lastActivity`),
redisClient.sCard(`${this.keyPrefix}canvas:${canvasId}:activeUsers`)
]);
return {
totalPixels: parseInt(totalPixels || '0'),
lastActivity: parseInt(lastActivity || '0'),
activeUsers: activeUsers || 0,
};
} catch (error) {
console.error('Error getting canvas stats:', error);
return {
totalPixels: 0,
lastActivity: 0,
activeUsers: 0,
};
}
}
async addActiveUser(canvasId: string, userId: string): Promise<void> {
try {
await redisClient.sAdd(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
// Set expiration to auto-remove inactive users
await redisClient.expire(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, 300); // 5 minutes
} catch (error) {
console.error('Error adding active user:', error);
}
}
async removeActiveUser(canvasId: string, userId: string): Promise<void> {
try {
await redisClient.sRem(`${this.keyPrefix}canvas:${canvasId}:activeUsers`, userId);
} catch (error) {
console.error('Error removing active user:', error);
}
}
async getActiveUsers(canvasId: string): Promise<string[]> {
try {
return await redisClient.sMembers(`${this.keyPrefix}canvas:${canvasId}:activeUsers`);
} catch (error) {
console.error('Error getting active users:', error);
return [];
}
}
}