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>
153 lines
No EOL
4.9 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
} |