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