Restructure assets to top-level package and add Reset Map button
- Move assets from internal/assets to top-level assets/ package for clean embed directive - Consolidate all static files in single location (assets/static/) - Remove duplicate static file locations to maintain single source of truth - Add Reset Map button to map controls with full functionality - Implement resetMap() method to return map to calculated origin position - Store origin in this.mapOrigin for reset functionality - Fix go:embed pattern to work without parent directory references 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
af9bf8ecac
commit
1425f0a018
20 changed files with 263 additions and 2139 deletions
|
|
@ -14,7 +14,7 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
|
||||
"skyview/internal/merger"
|
||||
)
|
||||
|
||||
|
|
@ -32,12 +32,12 @@ type Server struct {
|
|||
staticFiles embed.FS
|
||||
server *http.Server
|
||||
origin OriginConfig
|
||||
|
||||
|
||||
// WebSocket management
|
||||
wsClients map[*websocket.Conn]bool
|
||||
wsClientsMu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
|
||||
|
||||
// Broadcast channels
|
||||
broadcastChan chan []byte
|
||||
stopChan chan struct{}
|
||||
|
|
@ -60,11 +60,11 @@ type AircraftUpdate struct {
|
|||
// NewServer creates a new HTTP server
|
||||
func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin OriginConfig) *Server {
|
||||
return &Server{
|
||||
port: port,
|
||||
merger: merger,
|
||||
staticFiles: staticFiles,
|
||||
origin: origin,
|
||||
wsClients: make(map[*websocket.Conn]bool),
|
||||
port: port,
|
||||
merger: merger,
|
||||
staticFiles: staticFiles,
|
||||
origin: origin,
|
||||
wsClients: make(map[*websocket.Conn]bool),
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins in development
|
||||
|
|
@ -81,25 +81,25 @@ func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin Ori
|
|||
func (s *Server) Start() error {
|
||||
// Start broadcast routine
|
||||
go s.broadcastRoutine()
|
||||
|
||||
|
||||
// Start periodic updates
|
||||
go s.periodicUpdateRoutine()
|
||||
|
||||
|
||||
// Setup routes
|
||||
router := s.setupRoutes()
|
||||
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
// Stop gracefully stops the server
|
||||
func (s *Server) Stop() {
|
||||
close(s.stopChan)
|
||||
|
||||
|
||||
if s.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
|
@ -109,7 +109,7 @@ func (s *Server) Stop() {
|
|||
|
||||
func (s *Server) setupRoutes() http.Handler {
|
||||
router := mux.NewRouter()
|
||||
|
||||
|
||||
// API routes
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
|
||||
|
|
@ -119,36 +119,36 @@ func (s *Server) setupRoutes() http.Handler {
|
|||
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
|
||||
api.HandleFunc("/coverage/{sourceId}", s.handleGetCoverage).Methods("GET")
|
||||
api.HandleFunc("/heatmap/{sourceId}", s.handleGetHeatmap).Methods("GET")
|
||||
|
||||
|
||||
// WebSocket
|
||||
router.HandleFunc("/ws", s.handleWebSocket)
|
||||
|
||||
|
||||
// Static files
|
||||
router.PathPrefix("/static/").Handler(s.staticFileHandler())
|
||||
router.HandleFunc("/favicon.ico", s.handleFavicon)
|
||||
|
||||
|
||||
// Main page
|
||||
router.HandleFunc("/", s.handleIndex)
|
||||
|
||||
|
||||
// Enable CORS
|
||||
return s.enableCORS(router)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
|
||||
aircraft := s.merger.GetAircraft()
|
||||
|
||||
|
||||
// Convert ICAO keys to hex strings for JSON
|
||||
aircraftMap := make(map[string]*merger.AircraftState)
|
||||
for icao, state := range aircraft {
|
||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||
}
|
||||
|
||||
|
||||
response := map[string]interface{}{
|
||||
"timestamp": time.Now().Unix(),
|
||||
"aircraft": aircraftMap,
|
||||
"count": len(aircraft),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
|
@ -156,14 +156,14 @@ func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
|
|||
func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
icaoStr := vars["icao"]
|
||||
|
||||
|
||||
// Parse ICAO hex string
|
||||
icao, err := strconv.ParseUint(icaoStr, 16, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid ICAO address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
aircraft := s.merger.GetAircraft()
|
||||
if state, exists := aircraft[uint32(icao)]; exists {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -175,7 +175,7 @@ func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request
|
|||
|
||||
func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
|
||||
sources := s.merger.GetSources()
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"sources": sources,
|
||||
|
|
@ -185,7 +185,7 @@ func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats := s.merger.GetStatistics()
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
|
@ -198,11 +198,11 @@ func (s *Server) handleGetOrigin(w http.ResponseWriter, r *http.Request) {
|
|||
func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
sourceID := vars["sourceId"]
|
||||
|
||||
|
||||
// Generate coverage data based on signal strength
|
||||
aircraft := s.merger.GetAircraft()
|
||||
coveragePoints := make([]map[string]interface{}, 0)
|
||||
|
||||
|
||||
for _, state := range aircraft {
|
||||
if srcData, exists := state.Sources[sourceID]; exists {
|
||||
coveragePoints = append(coveragePoints, map[string]interface{}{
|
||||
|
|
@ -214,7 +214,7 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"source": sourceID,
|
||||
|
|
@ -225,21 +225,21 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
|
|||
func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
sourceID := vars["sourceId"]
|
||||
|
||||
|
||||
// Generate heatmap data grid
|
||||
aircraft := s.merger.GetAircraft()
|
||||
heatmapData := make(map[string]interface{})
|
||||
|
||||
|
||||
// Simple grid-based heatmap
|
||||
grid := make([][]float64, 100)
|
||||
for i := range grid {
|
||||
grid[i] = make([]float64, 100)
|
||||
}
|
||||
|
||||
|
||||
// Find bounds
|
||||
minLat, maxLat := 90.0, -90.0
|
||||
minLon, maxLon := 180.0, -180.0
|
||||
|
||||
|
||||
for _, state := range aircraft {
|
||||
if _, exists := state.Sources[sourceID]; exists {
|
||||
if state.Latitude < minLat {
|
||||
|
|
@ -256,19 +256,19 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fill grid
|
||||
for _, state := range aircraft {
|
||||
if srcData, exists := state.Sources[sourceID]; exists {
|
||||
latIdx := int((state.Latitude - minLat) / (maxLat - minLat) * 99)
|
||||
lonIdx := int((state.Longitude - minLon) / (maxLon - minLon) * 99)
|
||||
|
||||
|
||||
if latIdx >= 0 && latIdx < 100 && lonIdx >= 0 && lonIdx < 100 {
|
||||
grid[latIdx][lonIdx] += srcData.SignalLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
heatmapData["grid"] = grid
|
||||
heatmapData["bounds"] = map[string]float64{
|
||||
"minLat": minLat,
|
||||
|
|
@ -276,7 +276,7 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
|
|||
"minLon": minLon,
|
||||
"maxLon": maxLon,
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(heatmapData)
|
||||
}
|
||||
|
|
@ -288,15 +288,15 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
|
||||
// Register client
|
||||
s.wsClientsMu.Lock()
|
||||
s.wsClients[conn] = true
|
||||
s.wsClientsMu.Unlock()
|
||||
|
||||
|
||||
// Send initial data
|
||||
s.sendInitialData(conn)
|
||||
|
||||
|
||||
// Handle client messages (ping/pong)
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
|
|
@ -304,7 +304,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Unregister client
|
||||
s.wsClientsMu.Lock()
|
||||
delete(s.wsClients, conn)
|
||||
|
|
@ -315,25 +315,25 @@ func (s *Server) sendInitialData(conn *websocket.Conn) {
|
|||
aircraft := s.merger.GetAircraft()
|
||||
sources := s.merger.GetSources()
|
||||
stats := s.merger.GetStatistics()
|
||||
|
||||
|
||||
// Convert ICAO keys to hex strings
|
||||
aircraftMap := make(map[string]*merger.AircraftState)
|
||||
for icao, state := range aircraft {
|
||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||
}
|
||||
|
||||
|
||||
update := AircraftUpdate{
|
||||
Aircraft: aircraftMap,
|
||||
Sources: sources,
|
||||
Stats: stats,
|
||||
}
|
||||
|
||||
|
||||
msg := WebSocketMessage{
|
||||
Type: "initial_data",
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: update,
|
||||
}
|
||||
|
||||
|
||||
conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
|
|
@ -358,7 +358,7 @@ func (s *Server) broadcastRoutine() {
|
|||
func (s *Server) periodicUpdateRoutine() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
|
|
@ -373,25 +373,25 @@ func (s *Server) broadcastUpdate() {
|
|||
aircraft := s.merger.GetAircraft()
|
||||
sources := s.merger.GetSources()
|
||||
stats := s.merger.GetStatistics()
|
||||
|
||||
|
||||
// Convert ICAO keys to hex strings
|
||||
aircraftMap := make(map[string]*merger.AircraftState)
|
||||
for icao, state := range aircraft {
|
||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||
}
|
||||
|
||||
|
||||
update := AircraftUpdate{
|
||||
Aircraft: aircraftMap,
|
||||
Sources: sources,
|
||||
Stats: stats,
|
||||
}
|
||||
|
||||
|
||||
msg := WebSocketMessage{
|
||||
Type: "aircraft_update",
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: update,
|
||||
}
|
||||
|
||||
|
||||
if data, err := json.Marshal(msg); err == nil {
|
||||
select {
|
||||
case s.broadcastChan <- data:
|
||||
|
|
@ -407,7 +407,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "Page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
}
|
||||
|
|
@ -418,7 +418,7 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
|
|||
http.Error(w, "Favicon not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
w.Write(data)
|
||||
}
|
||||
|
|
@ -427,21 +427,21 @@ func (s *Server) staticFileHandler() http.Handler {
|
|||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Remove /static/ prefix from URL path to get the actual file path
|
||||
filePath := "static" + r.URL.Path[len("/static"):]
|
||||
|
||||
|
||||
data, err := s.staticFiles.ReadFile(filePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Set content type
|
||||
ext := path.Ext(filePath)
|
||||
contentType := getContentType(ext)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
|
||||
// Cache control
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
|
||||
w.Write(data)
|
||||
})
|
||||
}
|
||||
|
|
@ -474,12 +474,12 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler {
|
|||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
handler.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,17 +39,17 @@ func TestCORSHeaders(t *testing.T) {
|
|||
}
|
||||
|
||||
handler := New(cfg, testStaticFiles)
|
||||
|
||||
|
||||
req := httptest.NewRequest("OPTIONS", "/api/aircraft", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
||||
t.Errorf("Expected CORS header, got %s", w.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue