Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
424 lines
12 KiB
Go
424 lines
12 KiB
Go
// Package render provides floor-plan thumbnail rendering for notifications.
|
|
package render
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image/color"
|
|
"math"
|
|
|
|
"github.com/fogleman/gg"
|
|
)
|
|
|
|
// NotificationType represents the type of notification event.
|
|
type NotificationType string
|
|
|
|
const (
|
|
NotificationZoneEnter NotificationType = "zone_enter"
|
|
NotificationZoneLeave NotificationType = "zone_leave"
|
|
NotificationZoneVacant NotificationType = "zone_vacant"
|
|
NotificationFallDetected NotificationType = "fall_detected"
|
|
NotificationFallEscalation NotificationType = "fall_escalation"
|
|
NotificationAnomalyAlert NotificationType = "anomaly_alert"
|
|
NotificationNodeOffline NotificationType = "node_offline"
|
|
NotificationSleepSummary NotificationType = "sleep_summary"
|
|
)
|
|
|
|
// Zone represents a zone in the floor plan.
|
|
type Zone struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
W float64 `json:"w"`
|
|
D float64 `json:"d"`
|
|
Color string `json:"color"`
|
|
Highlight bool `json:"highlight"` // Highlight this zone (event location)
|
|
}
|
|
|
|
// Node represents a node position.
|
|
type Node struct {
|
|
Label string `json:"label"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Z float64 `json:"z"`
|
|
}
|
|
|
|
// Person represents a tracked person.
|
|
type Person struct {
|
|
Name string `json:"name"`
|
|
X float64 `json:"x"`
|
|
Y float64 `json:"y"`
|
|
Z float64 `json:"z"`
|
|
Color string `json:"color"`
|
|
Confidence float64 `json:"confidence"`
|
|
IsFall bool `json:"is_fall"`
|
|
}
|
|
|
|
// Portal represents a portal between zones.
|
|
type Portal struct {
|
|
Name string `json:"name"`
|
|
X1 float64 `json:"x1"`
|
|
Y1 float64 `json:"y1"`
|
|
X2 float64 `json:"x2"`
|
|
Y2 float64 `json:"y2"`
|
|
}
|
|
|
|
// RenderConfig holds configuration for floor-plan rendering.
|
|
type RenderConfig struct {
|
|
Width int // Image width in pixels (default 300)
|
|
Height int // Image height in pixels (default 300)
|
|
RoomWidth float64 // Room width in meters
|
|
RoomDepth float64 // Room depth in meters
|
|
Zones []Zone // Zones to render
|
|
Nodes []Node // Nodes to render
|
|
People []Person // People to render
|
|
Portals []Portal // Portals to render
|
|
EventType NotificationType // Event type for title overlay
|
|
EventTitle string // Event title text
|
|
BackgroundColor color.Color // Background color
|
|
}
|
|
|
|
// DefaultRenderConfig returns a config with sensible defaults.
|
|
func DefaultRenderConfig() RenderConfig {
|
|
return RenderConfig{
|
|
Width: 300,
|
|
Height: 300,
|
|
RoomWidth: 10.0,
|
|
RoomDepth: 10.0,
|
|
BackgroundColor: color.RGBA{26, 26, 46, 255}, // Dark background
|
|
}
|
|
}
|
|
|
|
// Renderer generates floor-plan thumbnails.
|
|
type Renderer struct {
|
|
config RenderConfig
|
|
dc *gg.Context
|
|
}
|
|
|
|
// NewRenderer creates a new floor-plan renderer.
|
|
func NewRenderer(config RenderConfig) *Renderer {
|
|
if config.Width == 0 {
|
|
config.Width = 300
|
|
}
|
|
if config.Height == 0 {
|
|
config.Height = 300
|
|
}
|
|
if config.RoomWidth == 0 {
|
|
config.RoomWidth = 10.0
|
|
}
|
|
if config.RoomDepth == 0 {
|
|
config.RoomDepth = 10.0
|
|
}
|
|
if config.BackgroundColor == nil {
|
|
config.BackgroundColor = color.RGBA{26, 26, 46, 255}
|
|
}
|
|
|
|
return &Renderer{config: config}
|
|
}
|
|
|
|
// Render generates a floor-plan PNG as bytes.
|
|
func (r *Renderer) Render() ([]byte, error) {
|
|
// Create drawing context
|
|
r.dc = gg.NewContext(r.config.Width, r.config.Height)
|
|
|
|
// Draw background
|
|
r.dc.SetColor(r.config.BackgroundColor)
|
|
r.dc.Clear()
|
|
|
|
// Calculate scale factors
|
|
margin := 10.0
|
|
drawWidth := float64(r.config.Width) - 2*margin
|
|
drawHeight := float64(r.config.Height) - 2*margin - 20 // Reserve space for title
|
|
|
|
scaleX := drawWidth / r.config.RoomWidth
|
|
scaleY := drawHeight / r.config.RoomDepth
|
|
scale := math.Min(scaleX, scaleY)
|
|
|
|
// Center the drawing
|
|
offsetX := margin + (drawWidth-r.config.RoomWidth*scale)/2
|
|
offsetY := margin + (drawHeight-r.config.RoomDepth*scale)/2
|
|
|
|
// Draw zones
|
|
for _, zone := range r.config.Zones {
|
|
r.drawZone(zone, offsetX, offsetY, scale)
|
|
}
|
|
|
|
// Draw portals
|
|
for _, portal := range r.config.Portals {
|
|
r.drawPortal(portal, offsetX, offsetY, scale)
|
|
}
|
|
|
|
// Draw nodes
|
|
for _, node := range r.config.Nodes {
|
|
r.drawNode(node, offsetX, offsetY, scale)
|
|
}
|
|
|
|
// Draw people
|
|
for _, person := range r.config.People {
|
|
r.drawPerson(person, offsetX, offsetY, scale)
|
|
}
|
|
|
|
// Draw event title overlay
|
|
if r.config.EventTitle != "" {
|
|
r.drawEventTitle()
|
|
}
|
|
|
|
// Encode to PNG
|
|
var buf bytes.Buffer
|
|
if err := r.dc.EncodePNG(&buf); err != nil {
|
|
return nil, fmt.Errorf("encode png: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// drawZone draws a zone rectangle with optional highlight.
|
|
func (r *Renderer) drawZone(zone Zone, offsetX, offsetY, scale float64) {
|
|
// Calculate screen coordinates
|
|
x := offsetX + zone.X*scale
|
|
y := offsetY + zone.Y*scale
|
|
w := zone.W * scale
|
|
h := zone.D * scale
|
|
|
|
// Parse zone color
|
|
zoneColor := r.parseColor(zone.Color)
|
|
if zoneColor.A == 0 {
|
|
zoneColor = color.RGBA{79, 195, 247, 51} // Default blue with 20% opacity
|
|
}
|
|
|
|
// Draw zone fill
|
|
if zone.Highlight {
|
|
// Brighter fill for highlighted zone
|
|
r.dc.SetColor(color.RGBA{
|
|
R: uint8(math.Min(255, float64(zoneColor.R)*1.5)),
|
|
G: uint8(math.Min(255, float64(zoneColor.G)*1.5)),
|
|
B: uint8(math.Min(255, float64(zoneColor.B)*1.5)),
|
|
A: 150, // Higher opacity for highlight
|
|
})
|
|
r.dc.DrawRectangle(x, y, w, h)
|
|
r.dc.Fill()
|
|
|
|
// White border for highlighted zone
|
|
r.dc.SetLineWidth(2)
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
|
|
r.dc.DrawRectangle(x, y, w, h)
|
|
r.dc.Stroke()
|
|
} else {
|
|
// Normal semi-transparent fill
|
|
r.dc.SetColor(color.RGBA{
|
|
R: zoneColor.R,
|
|
G: zoneColor.G,
|
|
B: zoneColor.B,
|
|
A: 51, // 20% opacity
|
|
})
|
|
r.dc.DrawRectangle(x, y, w, h)
|
|
r.dc.Fill()
|
|
|
|
// Thin white outline
|
|
r.dc.SetLineWidth(1)
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 100})
|
|
r.dc.DrawRectangle(x, y, w, h)
|
|
r.dc.Stroke()
|
|
}
|
|
|
|
// Draw zone label (if space permits)
|
|
if w > 30 && h > 15 {
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 200})
|
|
|
|
// Truncate name if too long
|
|
label := zone.Name
|
|
if len(label) > 10 {
|
|
label = label[:7] + "..."
|
|
}
|
|
|
|
tw, th := r.dc.MeasureString(label)
|
|
r.dc.DrawStringAnchored(label, x+w/2, y+h/2, 0.5, 0.5)
|
|
_ = tw
|
|
_ = th
|
|
}
|
|
}
|
|
|
|
// drawPortal draws a portal as a purple line.
|
|
func (r *Renderer) drawPortal(portal Portal, offsetX, offsetY, scale float64) {
|
|
x1 := offsetX + portal.X1*scale
|
|
y1 := offsetY + portal.Y1*scale
|
|
x2 := offsetX + portal.X2*scale
|
|
y2 := offsetY + portal.Y2*scale
|
|
|
|
r.dc.SetLineWidth(2)
|
|
r.dc.SetColor(color.RGBA{168, 85, 247, 255}) // Purple
|
|
r.dc.DrawLine(x1, y1, x2, y2)
|
|
r.dc.Stroke()
|
|
}
|
|
|
|
// drawNode draws a node position as a small white circle.
|
|
func (r *Renderer) drawNode(node Node, offsetX, offsetY, scale float64) {
|
|
x := offsetX + node.X*scale
|
|
y := offsetY + node.Y*scale
|
|
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
|
|
r.dc.DrawCircle(x, y, 3)
|
|
r.dc.Fill()
|
|
}
|
|
|
|
// drawPerson draws a person as a colored circle with name label.
|
|
func (r *Renderer) drawPerson(person Person, offsetX, offsetY, scale float64) {
|
|
x := offsetX + person.X*scale
|
|
y := offsetY + person.Y*scale
|
|
|
|
// Parse person color
|
|
personColor := r.parseColor(person.Color)
|
|
if personColor.A == 0 {
|
|
if person.IsFall {
|
|
personColor = color.RGBA{239, 83, 80, 255} // Red for fall
|
|
} else {
|
|
personColor = color.RGBA{136, 136, 136, 255} // Gray for unknown
|
|
}
|
|
}
|
|
|
|
// Diameter proportional to confidence (10px to 20px)
|
|
diameter := 10.0 + person.Confidence*10.0
|
|
if diameter > 20 {
|
|
diameter = 20
|
|
}
|
|
if diameter < 10 {
|
|
diameter = 10
|
|
}
|
|
|
|
// Draw filled circle
|
|
r.dc.SetColor(personColor)
|
|
r.dc.DrawCircle(x, y, diameter/2)
|
|
r.dc.Fill()
|
|
|
|
// Draw white outline
|
|
r.dc.SetLineWidth(1.5)
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
|
|
r.dc.DrawCircle(x, y, diameter/2)
|
|
r.dc.Stroke()
|
|
|
|
// Draw name label above circle
|
|
if person.Name != "" {
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 255})
|
|
r.dc.DrawStringAnchored(person.Name, x, y-diameter/2-2, 0.5, 1.0)
|
|
}
|
|
}
|
|
|
|
// drawEventTitle draws the event title at the bottom.
|
|
func (r *Renderer) drawEventTitle() {
|
|
r.dc.SetColor(color.RGBA{255, 255, 255, 200})
|
|
|
|
// Draw at bottom-left with margin
|
|
margin := 10.0
|
|
r.dc.DrawStringWrapped(r.config.EventTitle, margin, float64(r.config.Height)-margin-10, 0, 0, float64(r.config.Width)-2*margin, 1.0, gg.AlignLeft)
|
|
}
|
|
|
|
// parseColor parses a hex color string or returns a default color.
|
|
func (r *Renderer) parseColor(hex string) color.RGBA {
|
|
if len(hex) == 0 {
|
|
return color.RGBA{}
|
|
}
|
|
|
|
var rVal, gVal, bVal uint8
|
|
n, _ := fmt.Sscanf(hex, "#%02x%02x%02x", &rVal, &gVal, &bVal)
|
|
if n == 3 {
|
|
return color.RGBA{R: rVal, G: gVal, B: bVal, A: 255}
|
|
}
|
|
|
|
// Try with alpha
|
|
var aVal uint8
|
|
n, _ = fmt.Sscanf(hex, "#%02x%02x%02x%02x", &rVal, &gVal, &bVal, &aVal)
|
|
if n == 4 {
|
|
return color.RGBA{R: rVal, G: gVal, B: bVal, A: aVal}
|
|
}
|
|
|
|
return color.RGBA{}
|
|
}
|
|
|
|
// GenerateThumbnail generates a floor-plan thumbnail with the given configuration.
|
|
func GenerateThumbnail(config RenderConfig) ([]byte, error) {
|
|
renderer := NewRenderer(config)
|
|
return renderer.Render()
|
|
}
|
|
|
|
// GenerateZoneEnterThumbnail generates a thumbnail for zone entry event.
|
|
func GenerateZoneEnterThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, zoneName string) ([]byte, error) {
|
|
// Highlight the zone where person entered
|
|
highlightedZones := make([]Zone, len(zones))
|
|
for i, z := range zones {
|
|
highlightedZones[i] = z
|
|
if z.Name == zoneName {
|
|
highlightedZones[i].Highlight = true
|
|
}
|
|
}
|
|
|
|
config := DefaultRenderConfig()
|
|
config.RoomWidth = roomWidth
|
|
config.RoomDepth = roomDepth
|
|
config.Zones = highlightedZones
|
|
config.People = []Person{person}
|
|
config.EventType = NotificationZoneEnter
|
|
config.EventTitle = fmt.Sprintf("%s entered %s", person.Name, zoneName)
|
|
|
|
return GenerateThumbnail(config)
|
|
}
|
|
|
|
// GenerateFallDetectedThumbnail generates a thumbnail for fall detection event.
|
|
func GenerateFallDetectedThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, zoneName string) ([]byte, error) {
|
|
// Highlight the zone where fall occurred
|
|
highlightedZones := make([]Zone, len(zones))
|
|
for i, z := range zones {
|
|
highlightedZones[i] = z
|
|
if z.Name == zoneName {
|
|
highlightedZones[i].Highlight = true
|
|
}
|
|
}
|
|
|
|
// Mark person as fallen
|
|
fallenPerson := person
|
|
fallenPerson.IsFall = true
|
|
|
|
config := DefaultRenderConfig()
|
|
config.RoomWidth = roomWidth
|
|
config.RoomDepth = roomDepth
|
|
config.Zones = highlightedZones
|
|
config.People = []Person{fallenPerson}
|
|
config.EventType = NotificationFallDetected
|
|
config.EventTitle = fmt.Sprintf("Fall: %s in %s", person.Name, zoneName)
|
|
|
|
return GenerateThumbnail(config)
|
|
}
|
|
|
|
// GenerateAnomalyAlertThumbnail generates a thumbnail for anomaly alert.
|
|
func GenerateAnomalyAlertThumbnail(roomWidth, roomDepth float64, zones []Zone, zoneName string) ([]byte, error) {
|
|
// Highlight the anomalous zone
|
|
highlightedZones := make([]Zone, len(zones))
|
|
for i, z := range zones {
|
|
highlightedZones[i] = z
|
|
if z.Name == zoneName {
|
|
highlightedZones[i].Highlight = true
|
|
}
|
|
}
|
|
|
|
config := DefaultRenderConfig()
|
|
config.RoomWidth = roomWidth
|
|
config.RoomDepth = roomDepth
|
|
config.Zones = highlightedZones
|
|
config.EventType = NotificationAnomalyAlert
|
|
config.EventTitle = fmt.Sprintf("Unusual activity in %s", zoneName)
|
|
|
|
return GenerateThumbnail(config)
|
|
}
|
|
|
|
// GenerateSleepSummaryThumbnail generates a thumbnail for sleep summary.
|
|
func GenerateSleepSummaryThumbnail(roomWidth, roomDepth float64, zones []Zone, person Person, duration string) ([]byte, error) {
|
|
config := DefaultRenderConfig()
|
|
config.RoomWidth = roomWidth
|
|
config.RoomDepth = roomDepth
|
|
config.Zones = zones
|
|
config.People = []Person{person}
|
|
config.EventType = NotificationSleepSummary
|
|
config.EventTitle = fmt.Sprintf("Sleep: %s (last night)", duration)
|
|
|
|
return GenerateThumbnail(config)
|
|
}
|