initial commit
This commit is contained in:
		
							parent
							
								
									df123c81d8
								
							
						
					
					
						commit
						820879b79f
					
				
							
								
								
									
										15
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								go.mod
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					module github.com/artemvang/infinite-place
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					go 1.23.3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						github.com/gorilla/csrf v1.7.3
 | 
				
			||||||
 | 
						github.com/gorilla/handlers v1.5.2
 | 
				
			||||||
 | 
						github.com/gorilla/mux v1.8.1
 | 
				
			||||||
 | 
						github.com/mattn/go-sqlite3 v1.14.28
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require (
 | 
				
			||||||
 | 
						github.com/felixge/httpsnoop v1.0.3 // indirect
 | 
				
			||||||
 | 
						github.com/gorilla/securecookie v1.1.2 // indirect
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
							
								
								
									
										14
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								go.sum
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
 | 
				
			||||||
 | 
					github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 | 
				
			||||||
 | 
					github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 | 
				
			||||||
 | 
					github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 | 
				
			||||||
 | 
					github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
 | 
				
			||||||
 | 
					github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
 | 
				
			||||||
 | 
					github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
 | 
				
			||||||
 | 
					github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
 | 
				
			||||||
 | 
					github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 | 
				
			||||||
 | 
					github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
 | 
				
			||||||
 | 
					github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
 | 
				
			||||||
 | 
					github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
 | 
				
			||||||
 | 
					github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
 | 
				
			||||||
 | 
					github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 | 
				
			||||||
							
								
								
									
										121
									
								
								internal/db.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										121
									
								
								internal/db.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,121 @@
 | 
				
			|||||||
 | 
					package app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"sync"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const (
 | 
				
			||||||
 | 
						ChunkSize = 16
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Point represents the request body for setting cell position
 | 
				
			||||||
 | 
					type Point struct {
 | 
				
			||||||
 | 
						X, Y int
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Area represents the visible area for SSE updates
 | 
				
			||||||
 | 
					type Area struct {
 | 
				
			||||||
 | 
						X, Y, Width, Height int
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Contains checks if a point is within the SSE area
 | 
				
			||||||
 | 
					func (a Area) Contains(x int, y int) bool {
 | 
				
			||||||
 | 
						return x >= a.X && x <= a.X+a.Width && y >= a.Y && y <= a.Y+a.Height
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// SpatialHashMap implements spatial hashing for efficient point storage and retrieval
 | 
				
			||||||
 | 
					type SpatialHashMap struct {
 | 
				
			||||||
 | 
						mu sync.RWMutex
 | 
				
			||||||
 | 
						db *SQLiteEngine
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// NewSpatialHashMap creates a new spatial hash map
 | 
				
			||||||
 | 
					func NewSpatialHashMap(dbPath string) (*SpatialHashMap, error) {
 | 
				
			||||||
 | 
						db, err := CreateSQLite(dbPath)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return &SpatialHashMap{
 | 
				
			||||||
 | 
							db: db,
 | 
				
			||||||
 | 
						}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) InitSchema() error {
 | 
				
			||||||
 | 
						err := s.db.Execute(
 | 
				
			||||||
 | 
							`CREATE TABLE IF NOT EXISTS spatial_hashmap (
 | 
				
			||||||
 | 
								cluster_x INTEGER NOT NULL,
 | 
				
			||||||
 | 
								cluster_y INTEGER NOT NULL,
 | 
				
			||||||
 | 
								x INTEGER NOT NULL,
 | 
				
			||||||
 | 
								y INTEGER NOT NULL,
 | 
				
			||||||
 | 
								PRIMARY KEY (cluster_x, cluster_y, x, y)) WITHOUT ROWID`)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// getHash calculates the hash coordinates for a given point
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) getHash(x, y int) Point {
 | 
				
			||||||
 | 
						return Point{X: x / ChunkSize, Y: y / ChunkSize}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Insert adds a point to the spatial hash map
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) Insert(x, y int) error {
 | 
				
			||||||
 | 
						s.mu.Lock()
 | 
				
			||||||
 | 
						defer s.mu.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						key := s.getHash(x, y)
 | 
				
			||||||
 | 
						err := s.db.Execute(
 | 
				
			||||||
 | 
							"INSERT INTO spatial_hashmap (cluster_x, cluster_y, x, y) VALUES (?, ?, ?, ?)",
 | 
				
			||||||
 | 
							key.X, key.Y, x, y)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Remove removes a point from the spatial hash map
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) Remove(x, y int) error {
 | 
				
			||||||
 | 
						s.mu.Lock()
 | 
				
			||||||
 | 
						defer s.mu.Unlock()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						key := s.getHash(x, y)
 | 
				
			||||||
 | 
						err := s.db.Execute(
 | 
				
			||||||
 | 
							"DELETE FROM spatial_hashmap WHERE cluster_x = ? AND cluster_y = ? AND x = ? AND y = ?",
 | 
				
			||||||
 | 
							key.X, key.Y, x, y)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// GetLocalPoints returns all points within the given bounding box
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) GetLocalPoints(area Area) []Point {
 | 
				
			||||||
 | 
						hashStart, hashEnd := s.getHash(area.X, area.Y), s.getHash(area.X+area.Width, area.Y+area.Height)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						rows, err := s.db.Query(
 | 
				
			||||||
 | 
							"SELECT x, y FROM spatial_hashmap WHERE cluster_x BETWEEN ? AND ? AND cluster_y BETWEEN ? AND ?",
 | 
				
			||||||
 | 
							hashStart.X, hashEnd.X, hashStart.Y, hashEnd.Y)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer rows.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						localPoints := make([]Point, 0)
 | 
				
			||||||
 | 
						for rows.Next() {
 | 
				
			||||||
 | 
							var x, y int
 | 
				
			||||||
 | 
							if err := rows.Scan(&x, &y); err != nil {
 | 
				
			||||||
 | 
								continue
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							localPoints = append(localPoints, Point{X: x, Y: y})
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return localPoints
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *SpatialHashMap) Close() error {
 | 
				
			||||||
 | 
						if s.db != nil {
 | 
				
			||||||
 | 
							return s.db.Close()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										481
									
								
								internal/server.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										481
									
								
								internal/server.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,481 @@
 | 
				
			|||||||
 | 
					package app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"bytes"
 | 
				
			||||||
 | 
						"context"
 | 
				
			||||||
 | 
						"embed"
 | 
				
			||||||
 | 
						"encoding/base64"
 | 
				
			||||||
 | 
						"encoding/binary"
 | 
				
			||||||
 | 
						"encoding/json"
 | 
				
			||||||
 | 
						"fmt"
 | 
				
			||||||
 | 
						"log"
 | 
				
			||||||
 | 
						"log/slog"
 | 
				
			||||||
 | 
						"net/http"
 | 
				
			||||||
 | 
						"os"
 | 
				
			||||||
 | 
						"os/signal"
 | 
				
			||||||
 | 
						"strconv"
 | 
				
			||||||
 | 
						"strings"
 | 
				
			||||||
 | 
						"syscall"
 | 
				
			||||||
 | 
						"text/template"
 | 
				
			||||||
 | 
						"time"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						"github.com/gorilla/csrf"
 | 
				
			||||||
 | 
						"github.com/gorilla/handlers"
 | 
				
			||||||
 | 
						"github.com/gorilla/mux"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					//go:embed templates
 | 
				
			||||||
 | 
					var tplFolder embed.FS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Config holds server configuration
 | 
				
			||||||
 | 
					type Config struct {
 | 
				
			||||||
 | 
						Host             string
 | 
				
			||||||
 | 
						Port             string
 | 
				
			||||||
 | 
						CSRFKey          []byte
 | 
				
			||||||
 | 
						TrustedOrigins   []string
 | 
				
			||||||
 | 
						MaxWidth         int
 | 
				
			||||||
 | 
						MaxHeight        int
 | 
				
			||||||
 | 
						SSEFlushInterval time.Duration
 | 
				
			||||||
 | 
						WriteTimeout     time.Duration
 | 
				
			||||||
 | 
						ReadTimeout      time.Duration
 | 
				
			||||||
 | 
						ShutdownTimeout  time.Duration
 | 
				
			||||||
 | 
						DB               string
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// DefaultConfig returns default server configuration
 | 
				
			||||||
 | 
					func DefaultConfig() *Config {
 | 
				
			||||||
 | 
						return &Config{
 | 
				
			||||||
 | 
							Host:             "localhost",
 | 
				
			||||||
 | 
							Port:             "5002",
 | 
				
			||||||
 | 
							CSRFKey:          []byte("0e6139e71f1972259e4b2ce6464b80a3"), // Should be from env in production
 | 
				
			||||||
 | 
							TrustedOrigins:   []string{"localhost:5002"},
 | 
				
			||||||
 | 
							MaxWidth:         1024,
 | 
				
			||||||
 | 
							MaxHeight:        1024,
 | 
				
			||||||
 | 
							SSEFlushInterval: 200 * time.Millisecond,
 | 
				
			||||||
 | 
							WriteTimeout:     15 * time.Second,
 | 
				
			||||||
 | 
							ReadTimeout:      15 * time.Second,
 | 
				
			||||||
 | 
							ShutdownTimeout:  10 * time.Second,
 | 
				
			||||||
 | 
							DB:               "points.db?_journal_mode=WAL&cache=shared&_foreign_keys=0&synchronous=1",
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type CellState struct {
 | 
				
			||||||
 | 
						X     int  `json:"x"`
 | 
				
			||||||
 | 
						Y     int  `json:"y"`
 | 
				
			||||||
 | 
						State bool `json:"state"`
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// SSEConnection represents an SSE connection
 | 
				
			||||||
 | 
					type SSEConnection struct {
 | 
				
			||||||
 | 
						ID       string
 | 
				
			||||||
 | 
						Channel  chan CellState
 | 
				
			||||||
 | 
						Area     Area
 | 
				
			||||||
 | 
						LastSent time.Time
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Server encapsulates the HTTP server and its dependencies
 | 
				
			||||||
 | 
					type Server struct {
 | 
				
			||||||
 | 
						config         *Config
 | 
				
			||||||
 | 
						logger         *slog.Logger
 | 
				
			||||||
 | 
						templates      *template.Template
 | 
				
			||||||
 | 
						data           *SpatialHashMap
 | 
				
			||||||
 | 
						sseConnections map[string]*SSEConnection
 | 
				
			||||||
 | 
						router         *mux.Router
 | 
				
			||||||
 | 
						httpServer     *http.Server
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// NewServer creates a new server instance
 | 
				
			||||||
 | 
					func NewServer(config *Config, spatialMap *SpatialHashMap) (*Server, error) {
 | 
				
			||||||
 | 
						if config == nil {
 | 
				
			||||||
 | 
							config = DefaultConfig()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
 | 
				
			||||||
 | 
							Level: slog.LevelInfo,
 | 
				
			||||||
 | 
						}))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						templates, err := template.ParseFS(tplFolder, "templates/*.html")
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							log.Fatal(err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						server := &Server{
 | 
				
			||||||
 | 
							config:         config,
 | 
				
			||||||
 | 
							logger:         logger,
 | 
				
			||||||
 | 
							templates:      templates,
 | 
				
			||||||
 | 
							data:           spatialMap,
 | 
				
			||||||
 | 
							sseConnections: make(map[string]*SSEConnection),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						server.setupRoutes()
 | 
				
			||||||
 | 
						return server, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// setupRoutes configures the HTTP routes and middleware
 | 
				
			||||||
 | 
					func (s *Server) setupRoutes() {
 | 
				
			||||||
 | 
						s.router = mux.NewRouter()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Middleware chain
 | 
				
			||||||
 | 
						s.router.Use(s.loggingMiddleware)
 | 
				
			||||||
 | 
						s.router.Use(s.corsMiddleware)
 | 
				
			||||||
 | 
						s.router.Use(s.csrfMiddleware)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Routes
 | 
				
			||||||
 | 
						s.router.HandleFunc("/api/set-pos", s.setPositionHandler).Methods("POST")
 | 
				
			||||||
 | 
						s.router.HandleFunc("/sse/{params}", s.streamEventsHandler).Methods("GET")
 | 
				
			||||||
 | 
						s.router.HandleFunc("/", s.serveIndexHandler).Methods("GET")
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Middleware functions
 | 
				
			||||||
 | 
					func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
 | 
				
			||||||
 | 
						return handlers.LoggingHandler(os.Stdout, next)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) corsMiddleware(next http.Handler) http.Handler {
 | 
				
			||||||
 | 
						return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
 | 
							w.Header().Set("Access-Control-Allow-Origin", "*")
 | 
				
			||||||
 | 
							w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
 | 
				
			||||||
 | 
							w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if r.Method == "OPTIONS" {
 | 
				
			||||||
 | 
								w.WriteHeader(http.StatusOK)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							next.ServeHTTP(w, r)
 | 
				
			||||||
 | 
						})
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) csrfMiddleware(next http.Handler) http.Handler {
 | 
				
			||||||
 | 
						csrfProtection := csrf.Protect(
 | 
				
			||||||
 | 
							s.config.CSRFKey,
 | 
				
			||||||
 | 
							csrf.Secure(false), // Set to true in production with HTTPS
 | 
				
			||||||
 | 
							csrf.HttpOnly(true),
 | 
				
			||||||
 | 
							csrf.TrustedOrigins(s.config.TrustedOrigins),
 | 
				
			||||||
 | 
						)
 | 
				
			||||||
 | 
						return csrfProtection(next)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// HTTP Handlers
 | 
				
			||||||
 | 
					func (s *Server) setPositionHandler(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						var cellState CellState
 | 
				
			||||||
 | 
						if err := json.NewDecoder(r.Body).Decode(&cellState); err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Failed to decode JSON", "error", err)
 | 
				
			||||||
 | 
							s.writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Update spatial data
 | 
				
			||||||
 | 
						if cellState.State {
 | 
				
			||||||
 | 
							s.data.Insert(cellState.X, cellState.Y)
 | 
				
			||||||
 | 
						} else {
 | 
				
			||||||
 | 
							s.data.Remove(cellState.X, cellState.Y)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Notify SSE clients
 | 
				
			||||||
 | 
						s.notifySSEClients(cellState)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						response := map[string]string{"status": "ok"}
 | 
				
			||||||
 | 
						if err := json.NewEncoder(w).Encode(response); err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Failed to encode response", "error", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) streamEventsHandler(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
 | 
						params, err := s.parseSSEParams(r)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Invalid SSE parameters", "error", err)
 | 
				
			||||||
 | 
							s.writeErrorResponse(w, "Invalid parameters", http.StatusBadRequest)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						area := Area{
 | 
				
			||||||
 | 
							X:      params[0],
 | 
				
			||||||
 | 
							Y:      params[1],
 | 
				
			||||||
 | 
							Width:  params[2],
 | 
				
			||||||
 | 
							Height: params[3],
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := s.validateArea(area); err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Invalid SSE area", "error", err)
 | 
				
			||||||
 | 
							s.writeErrorResponse(w, err.Error(), http.StatusBadRequest)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Set SSE headers
 | 
				
			||||||
 | 
						w.Header().Set("Content-Type", "text/event-stream")
 | 
				
			||||||
 | 
						w.Header().Set("Cache-Control", "no-cache")
 | 
				
			||||||
 | 
						w.Header().Set("Connection", "keep-alive")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						flusher, ok := w.(http.Flusher)
 | 
				
			||||||
 | 
						if !ok {
 | 
				
			||||||
 | 
							s.writeErrorResponse(w, "Streaming unsupported", http.StatusInternalServerError)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						client := s.createSSEClient(area)
 | 
				
			||||||
 | 
						defer s.removeSSEClient(client.ID)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						s.handleSSEConnection(w, r, client, flusher)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) serveIndexHandler(w http.ResponseWriter, r *http.Request) {
 | 
				
			||||||
 | 
						data := map[string]any{
 | 
				
			||||||
 | 
							csrf.TemplateTag: csrf.TemplateField(r),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := s.templates.ExecuteTemplate(w, "index.html", data); err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Failed to execute template", "error", err)
 | 
				
			||||||
 | 
							http.Error(w, "Internal server error", http.StatusInternalServerError)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) parseSSEParams(r *http.Request) ([]int, error) {
 | 
				
			||||||
 | 
						vars := mux.Vars(r)
 | 
				
			||||||
 | 
						path := vars["params"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						paramStrs := strings.Split(path, ",")
 | 
				
			||||||
 | 
						if len(paramStrs) != 4 {
 | 
				
			||||||
 | 
							return nil, fmt.Errorf("expected 4 parameters, got %d", len(paramStrs))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						params := make([]int, 4)
 | 
				
			||||||
 | 
						for i, paramStr := range paramStrs {
 | 
				
			||||||
 | 
							var err error
 | 
				
			||||||
 | 
							params[i], err = strconv.Atoi(paramStr)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return nil, fmt.Errorf("invalid parameter %d: %s", i, paramStr)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return params, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) validateArea(area Area) error {
 | 
				
			||||||
 | 
						if area.Width <= 0 || area.Height <= 0 {
 | 
				
			||||||
 | 
							return fmt.Errorf("width and height must be positive")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						if area.Width > s.config.MaxWidth || area.Height > s.config.MaxHeight {
 | 
				
			||||||
 | 
							return fmt.Errorf("area dimensions exceed maximum bounds")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) createSSEClient(area Area) *SSEConnection {
 | 
				
			||||||
 | 
						clientID := fmt.Sprintf("%d,%d,%d,%d", area.X, area.Y, area.Width, area.Height)
 | 
				
			||||||
 | 
						client := &SSEConnection{
 | 
				
			||||||
 | 
							ID:       clientID,
 | 
				
			||||||
 | 
							Channel:  make(chan CellState, 10),
 | 
				
			||||||
 | 
							Area:     area,
 | 
				
			||||||
 | 
							LastSent: time.Unix(0, 0),
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						s.sseConnections[clientID] = client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return client
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) removeSSEClient(clientID string) {
 | 
				
			||||||
 | 
						if client, exists := s.sseConnections[clientID]; exists {
 | 
				
			||||||
 | 
							close(client.Channel)
 | 
				
			||||||
 | 
							delete(s.sseConnections, clientID)
 | 
				
			||||||
 | 
							s.logger.Info("SSE client disconnected", "client", clientID)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) notifySSEClients(cellState CellState) {
 | 
				
			||||||
 | 
						for _, client := range s.sseConnections {
 | 
				
			||||||
 | 
							if client.Area.Contains(cellState.X, cellState.Y) {
 | 
				
			||||||
 | 
								select {
 | 
				
			||||||
 | 
								case client.Channel <- cellState:
 | 
				
			||||||
 | 
								default:
 | 
				
			||||||
 | 
									// Channel is full, skip this update
 | 
				
			||||||
 | 
									s.logger.Warn("SSE client channel full, skipping update", "client", client.ID)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) handleSSEConnection(w http.ResponseWriter, r *http.Request, client *SSEConnection, flusher http.Flusher) {
 | 
				
			||||||
 | 
						clientGone := r.Context().Done()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := s.sendPointsState(w, client, flusher); err != nil {
 | 
				
			||||||
 | 
							s.logger.Error("Failed to send points state", "error", err, "client", client.ID)
 | 
				
			||||||
 | 
							return
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						for {
 | 
				
			||||||
 | 
							select {
 | 
				
			||||||
 | 
							case <-clientGone:
 | 
				
			||||||
 | 
								s.logger.Info("SSE connection closed", "client", client.ID)
 | 
				
			||||||
 | 
								return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							case cellState := <-client.Channel:
 | 
				
			||||||
 | 
								// Throttle updates
 | 
				
			||||||
 | 
								if since := time.Since(client.LastSent); since < s.config.SSEFlushInterval {
 | 
				
			||||||
 | 
									select {
 | 
				
			||||||
 | 
									case client.Channel <- cellState:
 | 
				
			||||||
 | 
									default:
 | 
				
			||||||
 | 
										// Channel is full, skip this update
 | 
				
			||||||
 | 
										s.logger.Warn("SSE client channel full, skipping update", "client", client.ID)
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
									time.Sleep(s.config.SSEFlushInterval - since)
 | 
				
			||||||
 | 
									continue
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								buffer := new(bytes.Buffer)
 | 
				
			||||||
 | 
								binary.Write(buffer, binary.LittleEndian, int32(cellState.X))
 | 
				
			||||||
 | 
								binary.Write(buffer, binary.LittleEndian, int32(cellState.Y))
 | 
				
			||||||
 | 
								binary.Write(buffer, binary.LittleEndian, boolToInt(cellState.State))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								datab64 := base64.StdEncoding.EncodeToString(buffer.Bytes())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								if _, err := fmt.Fprintf(w, "event: update\ndata: %s\n\n", datab64); err != nil {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
								flusher.Flush()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
								client.LastSent = time.Now()
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) sendPointsState(w http.ResponseWriter, client *SSEConnection, flusher http.Flusher) error {
 | 
				
			||||||
 | 
						area := client.Area
 | 
				
			||||||
 | 
						points := s.data.GetLocalPoints(area)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						buffer := new(bytes.Buffer)
 | 
				
			||||||
 | 
						for _, point := range points {
 | 
				
			||||||
 | 
							binary.Write(buffer, binary.LittleEndian, int32(point.X))
 | 
				
			||||||
 | 
							binary.Write(buffer, binary.LittleEndian, int32(point.Y))
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						datab64 := base64.StdEncoding.EncodeToString(buffer.Bytes())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if _, err := fmt.Fprintf(w, "event: state\ndata: %s\n\n", datab64); err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						flusher.Flush()
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) writeErrorResponse(w http.ResponseWriter, message string, statusCode int) {
 | 
				
			||||||
 | 
						w.Header().Set("Content-Type", "application/json")
 | 
				
			||||||
 | 
						w.WriteHeader(statusCode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						response := map[string]string{"error": message}
 | 
				
			||||||
 | 
						json.NewEncoder(w).Encode(response)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Server lifecycle methods
 | 
				
			||||||
 | 
					func (s *Server) Start() error {
 | 
				
			||||||
 | 
						addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						s.httpServer = &http.Server{
 | 
				
			||||||
 | 
							Handler:      s.router,
 | 
				
			||||||
 | 
							Addr:         addr,
 | 
				
			||||||
 | 
							WriteTimeout: s.config.WriteTimeout,
 | 
				
			||||||
 | 
							ReadTimeout:  s.config.ReadTimeout,
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						s.logger.Info("Server starting", "address", addr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
 | 
				
			||||||
 | 
							return fmt.Errorf("server failed to start: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (s *Server) Stop(ctx context.Context) error {
 | 
				
			||||||
 | 
						s.logger.Info("Server shutting down...")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Close all SSE connections
 | 
				
			||||||
 | 
						for _, client := range s.sseConnections {
 | 
				
			||||||
 | 
							close(client.Channel)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						s.sseConnections = make(map[string]*SSEConnection)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if s.httpServer != nil {
 | 
				
			||||||
 | 
							err := s.httpServer.Shutdown(ctx)
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								s.logger.Error("Failed to shutdown server", "error", err)
 | 
				
			||||||
 | 
								return fmt.Errorf("server shutdown error: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if s.data != nil {
 | 
				
			||||||
 | 
							s.logger.Info("Closing spatial hash map")
 | 
				
			||||||
 | 
							err := s.data.Close()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								s.logger.Error("Failed to close spatial hash map", "error", err)
 | 
				
			||||||
 | 
								return fmt.Errorf("failed to close spatial hash map: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						s.logger.Info("Server stopped gracefully")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Run starts the server with graceful shutdown handling
 | 
				
			||||||
 | 
					func Run() error {
 | 
				
			||||||
 | 
						return RunWithConfig(nil, nil)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// RunWithConfig starts the server with custom configuration and spatial map
 | 
				
			||||||
 | 
					func RunWithConfig(config *Config, spatialMap *SpatialHashMap) (err error) {
 | 
				
			||||||
 | 
						if config == nil {
 | 
				
			||||||
 | 
							config = DefaultConfig()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						if spatialMap == nil {
 | 
				
			||||||
 | 
							spatialMap, err = NewSpatialHashMap(config.DB) // Assumes this function exists
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("failed to create spatial hash map: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							err = spatialMap.InitSchema()
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return fmt.Errorf("failed to create spatial hash map: %w", err)
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						defer spatialMap.Close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						server, err := NewServer(config, spatialMap)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return fmt.Errorf("failed to create server: %w", err)
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Setup graceful shutdown
 | 
				
			||||||
 | 
						sigChan := make(chan os.Signal, 1)
 | 
				
			||||||
 | 
						signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Start server in goroutine
 | 
				
			||||||
 | 
						errChan := make(chan error, 1)
 | 
				
			||||||
 | 
						go func() {
 | 
				
			||||||
 | 
							errChan <- server.Start()
 | 
				
			||||||
 | 
						}()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						// Wait for shutdown signal or server error
 | 
				
			||||||
 | 
						select {
 | 
				
			||||||
 | 
						case err := <-errChan:
 | 
				
			||||||
 | 
							if err != nil {
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						case <-sigChan:
 | 
				
			||||||
 | 
							server.logger.Info("Shutdown signal received")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							ctx, cancel := context.WithTimeout(context.Background(), config.ShutdownTimeout)
 | 
				
			||||||
 | 
							defer cancel()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if err := server.Stop(ctx); err != nil {
 | 
				
			||||||
 | 
								server.logger.Error("Server shutdown error", "error", err)
 | 
				
			||||||
 | 
								return err
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							server.logger.Info("Server stopped gracefully")
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										45
									
								
								internal/sqlite.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								internal/sqlite.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,45 @@
 | 
				
			|||||||
 | 
					package app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import (
 | 
				
			||||||
 | 
						"database/sql"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						_ "github.com/mattn/go-sqlite3"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type SQLiteEngine struct {
 | 
				
			||||||
 | 
						connection *sql.DB
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func CreateSQLite(url string) (*SQLiteEngine, error) {
 | 
				
			||||||
 | 
						sqliteDatabase, err := sql.Open("sqlite3", url)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return &SQLiteEngine{connection: sqliteDatabase}, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *SQLiteEngine) Query(sql string, args ...any) (*sql.Rows, error) {
 | 
				
			||||||
 | 
						result, err := e.connection.Query(sql, args...)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return nil, err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return result, nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *SQLiteEngine) Execute(sql string, args ...any) error {
 | 
				
			||||||
 | 
						_, err := e.connection.Exec(sql, args...)
 | 
				
			||||||
 | 
						if err != nil {
 | 
				
			||||||
 | 
							return err
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func (e *SQLiteEngine) Close() error {
 | 
				
			||||||
 | 
						if e.connection != nil {
 | 
				
			||||||
 | 
							return e.connection.Close()
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return nil
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										594
									
								
								internal/templates/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										594
									
								
								internal/templates/index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,594 @@
 | 
				
			|||||||
 | 
					<!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>
 | 
				
			||||||
							
								
								
									
										8
									
								
								internal/utils.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								internal/utils.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					package app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					func boolToInt(b bool) uint8 {
 | 
				
			||||||
 | 
						if b {
 | 
				
			||||||
 | 
							return 1
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
						return 0
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user