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