Artem Sukhodolskyi 820879b79f initial commit
2025-10-21 12:55:17 +02:00

594 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Infinite Place</title>
<style>
/* Base styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background: #f0f0f0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Canvas */
#canvas {
display: block;
cursor: crosshair;
}
/* UI Components */
.ui-panel {
position: fixed;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px;
border-radius: 8px;
font-size: 12px;
z-index: 1000;
backdrop-filter: blur(4px);
}
.info-panel {
top: 16px;
left: 16px;
}
.controls-panel {
top: 16px;
right: 16px;
}
.zoom-panel {
bottom: 20px;
right: 20px;
text-align: center;
}
/* Buttons */
.btn {
background: #4CAF50;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.btn:hover {
background: #45a049;
}
.btn-blue {
background: #2196F3;
font-weight: bold;
font-size: 14px;
min-width: 36px;
height: 36px;
}
.btn-blue:hover {
background: #1976D2;
}
.zoom-level {
margin-top: 8px;
font-size: 10px;
opacity: 0.8;
}
/* Status indicators */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-connected {
background: #4CAF50;
}
.status-disconnected {
background: #f44336;
}
.status-connecting {
background: #ff9800;
}
</style>
</head>
<body>
{{ .csrfField }}
<canvas id="canvas"></canvas>
<div class="ui-panel info-panel">
<div>Position: <span id="position">0, 0</span></div>
<div>
<span class="status-dot" id="connectionStatus"></span>
<span id="connectionText">Connecting...</span>
</div>
<div style="margin-top: 8px; font-size: 11px; opacity: 0.8;">
Click cells to toggle • Arrow keys to move
</div>
</div>
<div class="ui-panel controls-panel">
<button class="btn" onclick="gridCanvas.resetView()">Reset View</button>
<button class="btn" onclick="gridCanvas.clearAll()">Clear All</button>
</div>
<div class="ui-panel zoom-panel">
<button class="btn btn-blue" onclick="gridCanvas.zoomIn()">+</button>
<button class="btn btn-blue" onclick="gridCanvas.zoomOut()"></button>
<div class="zoom-level">Zoom: <span id="zoomLevel">100%</span></div>
</div>
<script>
class GridCanvas {
constructor(canvasId) {
// DOM elements
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.positionElement = document.getElementById('position');
this.zoomElement = document.getElementById('zoomLevel');
this.connectionStatus = document.getElementById('connectionStatus');
this.connectionText = document.getElementById('connectionText');
// Configuration
this.config = {
cellSize: 20,
gridColor: '#ddd',
filledColor: '#333',
emptyColor: '#fff',
moveSpeed: 50,
zoomStep: 0.1,
minZoom: 0.1,
maxZoom: 2.0,
debounceDelay: 100,
};
// State
this.view = { x: 0, y: 0 };
this.scale = 1;
this.filledCells = new Set();
this.isDragging = false;
this.lastMousePos = { x: 0, y: 0 };
// Server connection
this.eventSource = null;
this.csrfToken = this.getCSRFToken();
this.init();
}
init() {
this.setupEventListeners();
this.resizeCanvas();
this.updateSSEConnection();
}
getCSRFToken() {
const tokenElement = document.getElementsByName("gorilla.csrf.Token")[0];
return tokenElement ? tokenElement.value : null;
}
setupEventListeners() {
// Canvas events
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
this.canvas.addEventListener('wheel', this.handleWheel.bind(this));
this.canvas.addEventListener('contextmenu', e => e.preventDefault());
// Window events
window.addEventListener('resize', this.resizeCanvas.bind(this));
window.addEventListener('keydown', this.handleKeyDown.bind(this));
window.addEventListener('beforeunload', this.cleanup.bind(this));
}
// Coordinate conversion utilities
screenToGrid(screenX, screenY) {
const worldX = (screenX / this.scale) + this.view.x;
const worldY = (screenY / this.scale) + this.view.y;
return {
x: Math.floor(worldX / this.config.cellSize),
y: Math.floor(worldY / this.config.cellSize)
};
}
gridToScreen(gridX, gridY) {
const worldX = gridX * this.config.cellSize;
const worldY = gridY * this.config.cellSize;
return {
x: (worldX - this.view.x) * this.scale,
y: (worldY - this.view.y) * this.scale
};
}
getCellKey(gridX, gridY) {
return `${gridX},${gridY}`;
}
// Drawing methods
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.draw();
this.updateSSEConnection();
}
draw() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const bounds = this.getVisibleGridBounds();
this.drawGrid(bounds);
this.drawFilledCells(bounds);
this.updateUI();
}
getVisibleGridBounds() {
return {
startX: Math.floor(this.view.x / this.config.cellSize) - 1,
endX: Math.floor((this.view.x + this.canvas.width / this.scale) / this.config.cellSize) + 1,
startY: Math.floor(this.view.y / this.config.cellSize) - 1,
endY: Math.floor((this.view.y + this.canvas.height / this.scale) / this.config.cellSize) + 1
};
}
drawGrid(bounds) {
this.ctx.strokeStyle = this.config.gridColor;
this.ctx.lineWidth = Math.max(0.2, Math.min(3, 0.8 * this.scale));
// Vertical lines
for (let x = bounds.startX; x <= bounds.endX; x++) {
const screenPos = this.gridToScreen(x, 0);
this.ctx.beginPath();
this.ctx.moveTo(screenPos.x, 0);
this.ctx.lineTo(screenPos.x, this.canvas.height);
this.ctx.stroke();
}
// Horizontal lines
for (let y = bounds.startY; y <= bounds.endY; y++) {
const screenPos = this.gridToScreen(0, y);
this.ctx.beginPath();
this.ctx.moveTo(0, screenPos.y);
this.ctx.lineTo(this.canvas.width, screenPos.y);
this.ctx.stroke();
}
}
drawFilledCells(bounds) {
this.ctx.fillStyle = this.config.filledColor;
for (let x = bounds.startX; x <= bounds.endX; x++) {
for (let y = bounds.startY; y <= bounds.endY; y++) {
const key = this.getCellKey(x, y);
if (this.filledCells.has(key)) {
const screenPos = this.gridToScreen(x, y);
this.ctx.fillRect(
screenPos.x,
screenPos.y,
this.config.cellSize * this.scale,
this.config.cellSize * this.scale
);
}
}
}
}
updateUI() {
this.positionElement.textContent = `${Math.round(this.view.x)}, ${Math.round(this.view.y)}`;
this.zoomElement.textContent = `${Math.round(this.scale * 100)}%`;
}
// Event handlers
handleMouseDown(e) {
const rect = this.canvas.getBoundingClientRect();
this.lastMousePos = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
this.isDragging = false;
}
handleMouseMove(e) {
if (e.buttons === 1) { // Left mouse button pressed
const rect = this.canvas.getBoundingClientRect();
const currentPos = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
if (!this.isDragging) {
// Check if mouse moved enough to start dragging
const distance = Math.sqrt(
Math.pow(currentPos.x - this.lastMousePos.x, 2) +
Math.pow(currentPos.y - this.lastMousePos.y, 2)
);
if (distance > 5) {
this.isDragging = true;
}
}
if (this.isDragging) {
const deltaX = (currentPos.x - this.lastMousePos.x) / this.scale;
const deltaY = (currentPos.y - this.lastMousePos.y) / this.scale;
this.view.x -= deltaX;
this.view.y -= deltaY;
this.draw();
this.debounceSSEUpdate();
this.lastMousePos = currentPos;
}
}
}
handleMouseUp(e) {
if (!this.isDragging) {
// Single click - toggle cell
const rect = this.canvas.getBoundingClientRect();
const mousePos = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
this.toggleCell(mousePos.x, mousePos.y);
}
this.isDragging = false;
}
handleWheel(e) {
e.preventDefault();
const factor = e.deltaY > 0 ? -this.config.zoomStep : this.config.zoomStep;
this.zoomAt(e.offsetX, e.offsetY, factor);
}
handleKeyDown(e) {
switch (e.key) {
case 'ArrowLeft':
this.view.x -= this.config.moveSpeed;
this.draw();
this.debounceSSEUpdate();
break;
case 'ArrowRight':
this.view.x += this.config.moveSpeed;
this.draw();
this.debounceSSEUpdate();
break;
case 'ArrowUp':
this.view.y -= this.config.moveSpeed;
this.draw();
this.debounceSSEUpdate();
break;
case 'ArrowDown':
this.view.y += this.config.moveSpeed;
this.draw();
this.debounceSSEUpdate();
break;
default:
return;
}
}
// Cell manipulation
toggleCell(screenX, screenY) {
const gridPos = this.screenToGrid(screenX, screenY);
const key = this.getCellKey(gridPos.x, gridPos.y);
const newState = !this.filledCells.has(key);
if (newState) {
this.filledCells.add(key);
} else {
this.filledCells.delete(key);
}
this.draw();
this.sendCellState(gridPos.x, gridPos.y, newState);
}
// Zoom controls
zoomAt(screenX, screenY, factor) {
const newScale = Math.max(this.config.minZoom,
Math.min(this.config.maxZoom, this.scale + factor));
if (newScale !== this.scale) {
const worldX = (screenX / this.scale) + this.view.x;
const worldY = (screenY / this.scale) + this.view.y;
this.scale = newScale;
this.view.x = worldX - (screenX / this.scale);
this.view.y = worldY - (screenY / this.scale);
this.draw();
this.debounceSSEUpdate();
}
}
zoomIn() {
this.zoomAt(this.canvas.width / 2, this.canvas.height / 2, this.config.zoomStep);
}
zoomOut() {
this.zoomAt(this.canvas.width / 2, this.canvas.height / 2, -this.config.zoomStep);
}
resetView() {
this.view.x = 0;
this.view.y = 0;
this.scale = 1;
this.draw();
this.updateSSEConnection();
}
clearAll() {
if (confirm('Clear all cells? This action cannot be undone.')) {
this.filledCells.clear();
this.draw();
// Note: In a real implementation, you'd want to send this to the server too
}
}
// Server communication
async sendCellState(gridX, gridY, state) {
if (!this.csrfToken) {
console.warn('No CSRF token available');
return;
}
try {
const response = await fetch('/api/set-pos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.csrfToken,
},
body: JSON.stringify({
x: gridX,
y: gridY,
state: state
})
});
if (!response.ok) {
console.warn(`Failed to send cell state: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error('Error sending cell state to server:', error);
this.updateConnectionStatus('disconnected');
}
}
async updateSSEConnection() {
this.closeSSEConnection();
const bounds = this.getVisibleGridBounds();
const params = `${bounds.startX - 1},${bounds.startY - 1},${bounds.endX - bounds.startX + 2},${bounds.endY - bounds.startY + 2}`;
const sseUrl = `/sse/${params}`;
try {
this.eventSource = new EventSource(sseUrl);
this.updateConnectionStatus('connecting');
this.eventSource.addEventListener('state', (event) => {
try {
if (!event.data) {
return;
}
console.log('Received SSE data:', event.data);
const data = new DataView(Uint8Array.from(atob(event.data), char => char.charCodeAt(0)).buffer);
const numIntegers = data.byteLength / 4;
const cellData = [];
for (let i = 0; i < numIntegers - 1; i += 2) {
const pointX = data.getInt32(i * 4, true);
const pointY = data.getInt32((i + 1) * 4, true);
cellData.push([pointX, pointY]);
}
console.log('Received cell data:', cellData);
this.updateCellsFromServer(cellData);
this.draw();
this.updateConnectionStatus('connected');
} catch (error) {
console.error('Error parsing SSE data:', error);
}
});
this.eventSource.addEventListener('update', (event) => {
try {
console.log('Received SSE update:', event.data);
const data = new DataView(Uint8Array.from(atob(event.data), char => char.charCodeAt(0)).buffer);
const pointX = data.getInt32(0, true);
const pointY = data.getInt32(4, true);
const state = data.getUint8(8, true);
const key = this.getCellKey(pointX, pointY);
if (this.filledCells.has(key) && state === 0) {
this.filledCells.delete(key);
} else if (!this.filledCells.has(key) && state === 1) {
this.filledCells.add(key);
}
this.draw();
} catch (error) {
console.error('Error parsing SSE update:', error);
}
});
this.eventSource.onerror = async (error) => {
console.error('SSE connection error:', error);
this.updateConnectionStatus('disconnected');
await new Promise(resolve => setTimeout(resolve, 1000));
this.updateSSEConnection();
};
} catch (error) {
console.error('Error establishing SSE connection:', error);
this.updateConnectionStatus('disconnected');
await new Promise(resolve => setTimeout(resolve, 1000));
this.updateSSEConnection();
}
}
closeSSEConnection() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
clearTimeout(this.sseUpdateTimeout)
}
updateCellsFromServer(cellData) {
this.filledCells.clear();
cellData.forEach(([x, y]) => {
const key = this.getCellKey(x, y);
this.filledCells.add(key);
});
}
updateConnectionStatus(status) {
this.connectionStatus.className = `status-dot status-${status}`;
const statusText = {
connecting: 'Connecting...',
connected: 'Connected',
disconnected: 'Disconnected'
};
this.connectionText.textContent = statusText[status] || 'Unknown';
}
// Utility methods
debounceSSEUpdate() {
clearTimeout(this.sseUpdateTimeout);
this.sseUpdateTimeout = setTimeout(() => {
this.updateSSEConnection();
}, this.config.debounceDelay);
}
cleanup() {
this.closeSSEConnection();
}
}
// Initialize the application
const gridCanvas = new GridCanvas('canvas');
</script>
</body>
</html>