package world import ( "image" "image/color" "sync" ) // StyleID references a fully-materialized style stored in StyleTable. type StyleID int const ( // StyleIDInvalid means "no style". It should not be used for rendering. StyleIDInvalid StyleID = 0 // Built-in default styles (stable IDs). StyleIDDefaultLine StyleID = 1 StyleIDDefaultCircle StyleID = 2 StyleIDDefaultPoint StyleID = 3 ) // Default priorities (smaller draws earlier), step=100. const ( DefaultPriorityLine = 100 DefaultPriorityCircle = 200 DefaultPriorityPoint = 300 ) var ( transparentColor color.Color = &color.RGBA{A: 0} ) // TransparentFill returns a reusable fully transparent color value. // // It is intended for callers that want to explicitly disable fill while still // setting a non-nil FillColor override. func TransparentFill() color.Color { return transparentColor } // Style is a fully resolved style used by the renderer. // All fields are concrete values; no "optional" markers here. // Optionality is handled by StyleOverride during style creation. type Style struct { // FillColor is used for Fill() operations (points/circles typically). // If nil, the renderer may treat it as "do not fill" depending on primitive. FillColor color.Color // StrokeColor is used for Stroke() operations (lines typically). // If nil, the renderer may treat it as "do not stroke" depending on primitive. StrokeColor color.Color // StrokeWidthPx is a screen-space stroke width in pixels. StrokeWidthPx float64 // StrokeDashes is the dash pattern in pixels. nil/empty means "solid". StrokeDashes []float64 // StrokeDashOffset is the dash phase in pixels. StrokeDashOffset float64 // PointRadiusPx is a screen-space radius for Point markers. PointRadiusPx float64 } // StyleOverride describes partial modifications applied to a base Style. // Fields set to nil mean "do not override". type StyleOverride struct { FillColor color.Color StrokeColor color.Color StrokeWidthPx *float64 StrokeDashes *[]float64 StrokeDashOffset *float64 PointRadiusPx *float64 } // IsZero reports whether override does not specify any fields. func (o StyleOverride) IsZero() bool { return o.FillColor == nil && o.StrokeColor == nil && o.StrokeWidthPx == nil && o.StrokeDashes == nil && o.StrokeDashOffset == nil && o.PointRadiusPx == nil } // Apply applies override to base style and returns a new fully resolved style. // It copies slices defensively to avoid aliasing. func (o StyleOverride) Apply(base Style) Style { out := base if o.FillColor != nil { out.FillColor = o.FillColor } if o.StrokeColor != nil { out.StrokeColor = o.StrokeColor } if o.StrokeWidthPx != nil { out.StrokeWidthPx = *o.StrokeWidthPx } if o.StrokeDashes != nil { // Copy to avoid future mutation by caller. src := *o.StrokeDashes if src == nil { out.StrokeDashes = nil } else { dst := make([]float64, len(src)) copy(dst, src) out.StrokeDashes = dst } } if o.StrokeDashOffset != nil { out.StrokeDashOffset = *o.StrokeDashOffset } if o.PointRadiusPx != nil { out.PointRadiusPx = *o.PointRadiusPx } return out } // StyleTable stores fully resolved styles and provides stable lookups by StyleID. // It also holds three built-in defaults for Line/Circle/Point. type StyleTable struct { mu sync.RWMutex nextID StyleID styles map[StyleID]Style } // NewStyleTable creates a new style table with built-in default styles. // The default values are intentionally simple and stable. func NewStyleTable() *StyleTable { t := &StyleTable{ nextID: StyleIDDefaultPoint + 1, styles: make(map[StyleID]Style, 16), } // Defaults: conservative, deterministic. // Colors: opaque black. (Callers can override.) white := color.RGBA{R: 255, G: 255, B: 255, A: 255} t.styles[StyleIDDefaultLine] = Style{ FillColor: nil, StrokeColor: white, StrokeWidthPx: 2.0, StrokeDashes: nil, StrokeDashOffset: 0, PointRadiusPx: 0, } t.styles[StyleIDDefaultCircle] = Style{ FillColor: white, StrokeColor: nil, StrokeWidthPx: 0, StrokeDashes: nil, StrokeDashOffset: 0, PointRadiusPx: 0, } t.styles[StyleIDDefaultPoint] = Style{ FillColor: white, StrokeColor: nil, StrokeWidthPx: 0, StrokeDashes: nil, StrokeDashOffset: 0, PointRadiusPx: 2.0, } return t } // Get returns a style by id. func (t *StyleTable) Get(id StyleID) (Style, bool) { t.mu.RLock() defer t.mu.RUnlock() s, ok := t.styles[id] if !ok { return Style{}, false } // Defensive copy of slices. if s.StrokeDashes != nil { cp := make([]float64, len(s.StrokeDashes)) copy(cp, s.StrokeDashes) s.StrokeDashes = cp } return s, true } // AddDerived creates a new style based on baseID with an override applied. // It returns the new style ID. func (t *StyleTable) AddDerived(baseID StyleID, override StyleOverride) StyleID { t.mu.Lock() defer t.mu.Unlock() base, ok := t.styles[baseID] if !ok { panic("StyleTable.AddDerived: unknown base style ID") } derived := override.Apply(base) id := t.nextID t.nextID++ // Defensive copy of slices on store. if derived.StrokeDashes != nil { cp := make([]float64, len(derived.StrokeDashes)) copy(cp, derived.StrokeDashes) derived.StrokeDashes = cp } t.styles[id] = derived return id } // AddStyle stores a fully resolved style as a new StyleID. // It defensively copies slice fields. func (t *StyleTable) AddStyle(s Style) StyleID { t.mu.Lock() defer t.mu.Unlock() id := t.nextID t.nextID++ if s.StrokeDashes != nil { cp := make([]float64, len(s.StrokeDashes)) copy(cp, s.StrokeDashes) s.StrokeDashes = cp } t.styles[id] = s return id } // Count returns the number of styles stored in the table. // Intended for tests/diagnostics. func (t *StyleTable) Count() int { t.mu.RLock() defer t.mu.RUnlock() return len(t.styles) } // BackgroundTileMode defines how the background image is tiled. type BackgroundTileMode uint8 const ( BackgroundTileNone BackgroundTileMode = iota BackgroundTileRepeat ) // BackgroundAnchorMode defines whether the background image scrolls with the world or stays fixed to viewport. type BackgroundAnchorMode uint8 const ( BackgroundAnchorWorld BackgroundAnchorMode = iota BackgroundAnchorViewport ) // BackgroundScaleMode defines how the background image is scaled. // (Step 1: defined for API completeness; used later when rendering background image.) type BackgroundScaleMode uint8 const ( BackgroundScaleNone BackgroundScaleMode = iota BackgroundScaleFit BackgroundScaleFill ) // StyleTheme describes a cohesive style set (theme) for rendering. // Step 1: we store it in World and use it for background and default base styles. // Step 2+: theme-relative overrides and background image drawing. type StyleTheme interface { ID() string Name() string BackgroundColor() color.Color BackgroundImage() image.Image BackgroundTileMode() BackgroundTileMode BackgroundScaleMode() BackgroundScaleMode BackgroundAnchorMode() BackgroundAnchorMode PointStyle() Style LineStyle() Style CircleStyle() Style // Class overrides (relative to base kind style). // Return (override, true) when class is supported; (zero, false) means "no override". PointClassOverride(class PointClassID) (StyleOverride, bool) LineClassOverride(class LineClassID) (StyleOverride, bool) CircleClassOverride(class CircleClassID) (StyleOverride, bool) } // DefaultTheme is a conservative theme matching built-in default styles. type DefaultTheme struct{} func (DefaultTheme) ID() string { return "default" } func (DefaultTheme) Name() string { return "Default" } func (DefaultTheme) BackgroundColor() color.Color { return color.RGBA{A: 255} } func (DefaultTheme) BackgroundImage() image.Image { return nil } func (DefaultTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } func (DefaultTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (DefaultTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } func (DefaultTheme) PointStyle() Style { s, _ := NewStyleTable().Get(StyleIDDefaultPoint) return s } func (DefaultTheme) LineStyle() Style { s, _ := NewStyleTable().Get(StyleIDDefaultLine) return s } func (DefaultTheme) CircleStyle() Style { s, _ := NewStyleTable().Get(StyleIDDefaultCircle) return s } func (DefaultTheme) PointClassOverride(PointClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (DefaultTheme) LineClassOverride(LineClassID) (StyleOverride, bool) { return StyleOverride{}, false } func (DefaultTheme) CircleClassOverride(CircleClassID) (StyleOverride, bool) { return StyleOverride{}, false } // This file provides two sample themes for demos and UI integration: // LightTheme uses only background color, while DarkTheme also carries a // prebuilt tiled texture image. var ( // ThemeLight is the shared light theme instance used by the client package. ThemeLight = &LightTheme{} // ThemeDark is the shared dark theme instance used by the client package. ThemeDark = NewDarkTheme() ) // ----------------------------- // Helpers // ----------------------------- // cRGBA constructs an sRGB color from 8-bit RGBA channels. func cRGBA(r, g, b, a uint8) color.Color { return color.RGBA{R: r, G: g, B: b, A: a} } // ----------------------------- // Light Theme (color only) // ----------------------------- // LightTheme is a soft high-contrast theme intended for bright backgrounds. type LightTheme struct{} func (LightTheme) ID() string { return "theme.light.v1" } func (LightTheme) Name() string { return "Light (Soft)" } func (LightTheme) BackgroundColor() color.Color { return cRGBA(244, 246, 248, 255) } // #F4F6F8 func (LightTheme) BackgroundImage() image.Image { return nil } func (LightTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileNone } func (LightTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (LightTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorWorld } // Base styles per primitive kind (full Style, not override). func (LightTheme) PointStyle() Style { return Style{ FillColor: cRGBA(32, 161, 145, 255), // soft teal StrokeColor: nil, StrokeWidthPx: 0, PointRadiusPx: 3.0, } } func (LightTheme) LineStyle() Style { return Style{ FillColor: nil, StrokeColor: cRGBA(70, 108, 196, 220), // soft blue StrokeWidthPx: 2.0, StrokeDashes: nil, StrokeDashOffset: 0, } } func (LightTheme) CircleStyle() Style { return Style{ FillColor: cRGBA(133, 110, 201, 60), // soft purple with low alpha StrokeColor: cRGBA(133, 110, 201, 200), // soft purple StrokeWidthPx: 2.0, } } // Point class overrides. func (LightTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { switch class { case PointClassDefault: return StyleOverride{}, false case PointClassTrackUnknown: // muted gray-blue return StyleOverride{ FillColor: cRGBA(120, 135, 160, 230), PointRadiusPx: new(3.0), }, true case PointClassTrackIncoming: // soft green return StyleOverride{ FillColor: cRGBA(76, 171, 107, 240), PointRadiusPx: new(3.5), }, true case PointClassTrackOutgoing: // soft orange return StyleOverride{ FillColor: cRGBA(222, 142, 70, 240), PointRadiusPx: new(3.5), }, true default: return StyleOverride{}, false } } func (LightTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) { switch class { case LineClassDefault: return StyleOverride{}, false case LineClassTrackIncoming: return StyleOverride{ StrokeColor: cRGBA(76, 171, 107, 220), StrokeWidthPx: new(2.5), }, true case LineCLassTrackOutgoing: return StyleOverride{ StrokeColor: cRGBA(222, 142, 70, 220), StrokeWidthPx: new(2.5), }, true case LineClassMeasurement: // dashed neutral line d := []float64{6, 4} return StyleOverride{ StrokeColor: cRGBA(100, 110, 125, 200), StrokeWidthPx: new(1.8), StrokeDashes: &d, StrokeDashOffset: new(0.), }, true default: return StyleOverride{}, false } } func (LightTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) { switch class { case CircleClassDefault: return StyleOverride{}, false case CircleClassHome: // teal-ish, a bit stronger stroke return StyleOverride{ FillColor: cRGBA(32, 161, 145, 50), StrokeColor: cRGBA(32, 161, 145, 210), StrokeWidthPx: new(2.5), }, true case CircleClassAcquired: // blue return StyleOverride{ FillColor: cRGBA(70, 108, 196, 45), StrokeColor: cRGBA(70, 108, 196, 220), StrokeWidthPx: new(2.2), }, true case CircleClassOccupied: // orange return StyleOverride{ FillColor: cRGBA(222, 142, 70, 50), StrokeColor: cRGBA(222, 142, 70, 220), StrokeWidthPx: new(2.2), }, true case CircleClassFree: // green return StyleOverride{ FillColor: cRGBA(76, 171, 107, 45), StrokeColor: cRGBA(76, 171, 107, 220), StrokeWidthPx: new(2.2), }, true default: return StyleOverride{}, false } } // ----------------------------- // Dark Theme (color + tiled image) // ----------------------------- // DarkTheme is a dark theme with an optional reusable background tile. type DarkTheme struct { bg image.Image } // NewDarkTheme constructs a DarkTheme with its immutable texture tile prepared. func NewDarkTheme() *DarkTheme { return &DarkTheme{bg: makeDarkBackgroundTile(96, 96)} } func (*DarkTheme) ID() string { return "theme.dark.v1" } func (*DarkTheme) Name() string { return "Dark (Soft + Texture)" } func (*DarkTheme) BackgroundColor() color.Color { return cRGBA(30, 32, 38, 255) } // #1E2026 func (t *DarkTheme) BackgroundImage() image.Image { return nil // This image is immutable after creation. // return t.bg } func (*DarkTheme) BackgroundTileMode() BackgroundTileMode { return BackgroundTileRepeat } func (*DarkTheme) BackgroundScaleMode() BackgroundScaleMode { return BackgroundScaleNone } func (*DarkTheme) BackgroundAnchorMode() BackgroundAnchorMode { return BackgroundAnchorViewport } // Base styles for dark theme. func (*DarkTheme) PointStyle() Style { return Style{ FillColor: cRGBA(120, 214, 198, 255), StrokeColor: nil, StrokeWidthPx: 0, PointRadiusPx: 3.0, } } func (*DarkTheme) LineStyle() Style { return Style{ FillColor: nil, StrokeColor: cRGBA(155, 175, 235, 255), StrokeWidthPx: 2.0, StrokeDashes: nil, StrokeDashOffset: 0, } } func (*DarkTheme) CircleStyle() Style { return Style{ FillColor: nil, // cRGBA(186, 160, 255, 255), StrokeColor: cRGBA(186, 160, 255, 255), StrokeWidthPx: 2.0, } } // Point class overrides. func (*DarkTheme) PointClassOverride(class PointClassID) (StyleOverride, bool) { switch class { case PointClassDefault: return StyleOverride{}, false case PointClassTrackUnknown: return StyleOverride{ FillColor: cRGBA(150, 160, 175, 255), PointRadiusPx: new(3.0), }, true case PointClassTrackIncoming: return StyleOverride{ FillColor: cRGBA(132, 219, 162, 255), PointRadiusPx: new(3.5), }, true case PointClassTrackOutgoing: return StyleOverride{ FillColor: cRGBA(245, 178, 120, 255), PointRadiusPx: new(3.5), }, true default: return StyleOverride{}, false } } func (*DarkTheme) LineClassOverride(class LineClassID) (StyleOverride, bool) { switch class { case LineClassDefault: return StyleOverride{}, false case LineClassTrackIncoming: return StyleOverride{ StrokeColor: cRGBA(132, 219, 162, 255), StrokeWidthPx: new(2.5), }, true case LineCLassTrackOutgoing: return StyleOverride{ StrokeColor: cRGBA(245, 178, 120, 255), StrokeWidthPx: new(2.5), }, true case LineClassMeasurement: d := []float64{6, 4} return StyleOverride{ StrokeColor: cRGBA(170, 175, 190, 255), StrokeWidthPx: new(1.8), StrokeDashes: &d, StrokeDashOffset: new(0.), }, true default: return StyleOverride{}, false } } func (*DarkTheme) CircleClassOverride(class CircleClassID) (StyleOverride, bool) { switch class { case CircleClassDefault: return StyleOverride{}, false case CircleClassHome: return StyleOverride{ FillColor: nil, // cRGBA(120, 214, 198, 255), StrokeColor: cRGBA(120, 214, 198, 255), StrokeWidthPx: new(2.5), }, true case CircleClassAcquired: return StyleOverride{ FillColor: nil, // cRGBA(155, 175, 235, 255), StrokeColor: cRGBA(155, 175, 235, 255), StrokeWidthPx: new(2.2), }, true case CircleClassOccupied: return StyleOverride{ FillColor: nil, // cRGBA(245, 178, 120, 255), StrokeColor: cRGBA(245, 178, 120, 255), StrokeWidthPx: new(2.2), }, true case CircleClassFree: return StyleOverride{ FillColor: nil, // cRGBA(132, 219, 162, 255), StrokeColor: cRGBA(132, 219, 162, 255), StrokeWidthPx: new(2.2), }, true default: return StyleOverride{}, false } } // makeDarkBackgroundTile creates a subtle, low-contrast texture tile. // It is intentionally simple: a faint grid + a few diagonal accents. // The tile is meant to be repeated. func makeDarkBackgroundTile(w, h int) image.Image { if w <= 0 || h <= 0 { return nil } img := image.NewRGBA(image.Rect(0, 0, w, h)) // Base is transparent; background color is drawn separately. // We draw subtle strokes with low alpha. grid := color.RGBA{R: 255, G: 255, B: 255, A: 12} // very faint diag := color.RGBA{R: 255, G: 255, B: 255, A: 18} // slightly stronger dots := color.RGBA{R: 255, G: 255, B: 255, A: 10} // faint dots // Grid spacing (pixels). const step = 12 // Vertical grid lines. for x := 0; x < w; x += step { for y := 0; y < h; y++ { img.SetRGBA(x, y, grid) } } // Horizontal grid lines. for y := 0; y < h; y += step { for x := 0; x < w; x++ { img.SetRGBA(x, y, grid) } } // Diagonal accents (sparse). for x := 0; x < w; x += step * 2 { for i := 0; i < step && x+i < w && i < h; i++ { img.SetRGBA(x+i, i, diag) } } // Small dot pattern. for y := step / 2; y < h; y += step { for x := step / 2; x < w; x += step { img.SetRGBA(x, y, dots) } } return img }