594 lines
21 KiB
HTML
594 lines
21 KiB
HTML
<!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> |