add nap
ci / test (push) Successful in 59s
ci / release (push) Successful in 1m29s

This commit is contained in:
2026-07-10 15:26:05 -04:00
parent 87f968c610
commit d820d4238d
15 changed files with 1626 additions and 34 deletions
+4 -2
View File
@@ -101,7 +101,7 @@ Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
|-----|--------|
| `j` / `k`, `↑` / `↓` | Move selection |
| `Tab` / `Shift+Tab` | Next / previous view |
| `1``6` | Jump to Feed · Incidents · Peers · Stats · History · Blog |
| `1``7` | Jump to Feed · Incidents · Peers · Stats · History · Map · Blog |
| `Enter` | Open detail (or read blog post on Blog tab) |
| `Esc` | Close detail / back from blog post / cancel filter or search |
| `m` | Toggle mouse (off by default) |
@@ -116,7 +116,8 @@ Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
| `e` | Toggle event sounds |
| `-` / `=` | Volume down / up (while sound is on) |
| `PgUp` / `PgDn` | Page list / load more blog or history |
| `]` / `[` | History sub-tab (attacks/reputation) / time range |
| `]` / `[` | History sub-tab / time range · Map all↔1h |
| `0` | Map: reset pan/zoom |
| `r` | Reconnect stream |
| `?` | Help overlay |
| `q` | Quit |
@@ -141,6 +142,7 @@ Source builds (`go build` without release ldflags) are not auto-updated. Install
- **Peers** — animated hub-and-spoke mesh (like the web viz), live roster, connect events
- **Stats** — aggregates and breakdowns
- **History** — paginated attack and reputation database history, with time-range filters
- **Map** — global attack map (web-parity): geo heat, live pulses, peers, pan/zoom
- **Blog** — published HoneyPeer threat briefings (markdown, tables, charts)
Timestamps use your **local timezone**.
BIN
View File
Binary file not shown.
+270
View File
@@ -0,0 +1,270 @@
// Code generated from viz.honeypeer.com country centroid table. DO NOT hand-edit bulk rows.
package geomap
// Country is an ISO-3166-1 alpha-2 place with display name and centroid.
type Country struct {
Code string
Name string
Lat float64
Lng float64
}
// UnknownGeo is the web-viz bucket for missing / non-ISO geos.
const UnknownGeo = "unknown"
// Countries is the web-viz centroid table used for equirectangular markers.
var Countries = []Country{
{Code: "US", Name: "United States", Lat: 39.8, Lng: -98.6},
{Code: "CN", Name: "China", Lat: 35.9, Lng: 104.2},
{Code: "RU", Name: "Russia", Lat: 61.5, Lng: 105.3},
{Code: "BR", Name: "Brazil", Lat: -14.2, Lng: -51.9},
{Code: "IN", Name: "India", Lat: 20.6, Lng: 78.9},
{Code: "GB", Name: "United Kingdom", Lat: 55.4, Lng: -3.4},
{Code: "DE", Name: "Germany", Lat: 51.2, Lng: 10.4},
{Code: "FR", Name: "France", Lat: 46.6, Lng: 2.2},
{Code: "JP", Name: "Japan", Lat: 36.2, Lng: 138.3},
{Code: "KR", Name: "South Korea", Lat: 35.9, Lng: 127.8},
{Code: "IT", Name: "Italy", Lat: 41.9, Lng: 12.6},
{Code: "CA", Name: "Canada", Lat: 56.1, Lng: -106.4},
{Code: "AU", Name: "Australia", Lat: -25.3, Lng: 133.8},
{Code: "MX", Name: "Mexico", Lat: 23.6, Lng: -102.5},
{Code: "TR", Name: "Turkey", Lat: 39, Lng: 35.2},
{Code: "NL", Name: "Netherlands", Lat: 52.1, Lng: 5.3},
{Code: "SA", Name: "Saudi Arabia", Lat: 24, Lng: 45.1},
{Code: "CH", Name: "Switzerland", Lat: 46.8, Lng: 8.2},
{Code: "SE", Name: "Sweden", Lat: 62, Lng: 15},
{Code: "PL", Name: "Poland", Lat: 52.1, Lng: 19.4},
{Code: "BE", Name: "Belgium", Lat: 50.5, Lng: 4.5},
{Code: "AR", Name: "Argentina", Lat: -38.4, Lng: -63.6},
{Code: "NO", Name: "Norway", Lat: 60.5, Lng: 8.5},
{Code: "ES", Name: "Spain", Lat: 40.2, Lng: -3.7},
{Code: "ZA", Name: "South Africa", Lat: -30.6, Lng: 22.9},
{Code: "UA", Name: "Ukraine", Lat: 48.4, Lng: 31.2},
{Code: "TH", Name: "Thailand", Lat: 15.9, Lng: 101},
{Code: "VN", Name: "Vietnam", Lat: 14.1, Lng: 108.3},
{Code: "TW", Name: "Taiwan", Lat: 23.7, Lng: 121},
{Code: "IR", Name: "Iran", Lat: 32.4, Lng: 53.7},
{Code: "EG", Name: "Egypt", Lat: 26.8, Lng: 30.8},
{Code: "NG", Name: "Nigeria", Lat: 9.1, Lng: 8.4},
{Code: "PK", Name: "Pakistan", Lat: 30.4, Lng: 69.3},
{Code: "BD", Name: "Bangladesh", Lat: 23.7, Lng: 90.4},
{Code: "PH", Name: "Philippines", Lat: 12.9, Lng: 121.8},
{Code: "MY", Name: "Malaysia", Lat: 4.2, Lng: 102},
{Code: "SG", Name: "Singapore", Lat: 1.3, Lng: 103.8},
{Code: "HK", Name: "Hong Kong", Lat: 22.4, Lng: 114.1},
{Code: "IL", Name: "Israel", Lat: 31, Lng: 34.8},
{Code: "KE", Name: "Kenya", Lat: -0, Lng: 37.9},
{Code: "CO", Name: "Colombia", Lat: 4.6, Lng: -74.3},
{Code: "CL", Name: "Chile", Lat: -35.7, Lng: -71.5},
{Code: "RO", Name: "Romania", Lat: 45.9, Lng: 24.9},
{Code: "CZ", Name: "Czech Republic", Lat: 49.8, Lng: 15.5},
{Code: "PT", Name: "Portugal", Lat: 39.5, Lng: -8},
{Code: "GR", Name: "Greece", Lat: 39.1, Lng: 21.8},
{Code: "HU", Name: "Hungary", Lat: 47.2, Lng: 19.5},
{Code: "AT", Name: "Austria", Lat: 47.5, Lng: 14.6},
{Code: "DK", Name: "Denmark", Lat: 56.1, Lng: 10.4},
{Code: "FI", Name: "Finland", Lat: 63.2, Lng: 26},
{Code: "IE", Name: "Ireland", Lat: 53.2, Lng: -8.1},
{Code: "NZ", Name: "New Zealand", Lat: -41.3, Lng: 174.8},
{Code: "AE", Name: "UAE", Lat: 24, Lng: 54},
{Code: "SK", Name: "Slovakia", Lat: 48.7, Lng: 19.5},
{Code: "BG", Name: "Bulgaria", Lat: 42.7, Lng: 25.5},
{Code: "HR", Name: "Croatia", Lat: 44, Lng: 15.2},
{Code: "LT", Name: "Lithuania", Lat: 55.2, Lng: 24},
{Code: "SI", Name: "Slovenia", Lat: 46.2, Lng: 14.9},
{Code: "LV", Name: "Latvia", Lat: 56.9, Lng: 24.6},
{Code: "EE", Name: "Estonia", Lat: 58.6, Lng: 25},
{Code: "PE", Name: "Peru", Lat: -9.2, Lng: -75},
{Code: "VE", Name: "Venezuela", Lat: 7.1, Lng: -65},
{Code: "DZ", Name: "Algeria", Lat: 28, Lng: 2.6},
{Code: "MA", Name: "Morocco", Lat: 32, Lng: -6},
{Code: "IQ", Name: "Iraq", Lat: 33.3, Lng: 43.4},
{Code: "KZ", Name: "Kazakhstan", Lat: 48, Lng: 66.5},
{Code: "UZ", Name: "Uzbekistan", Lat: 41.4, Lng: 63.7},
{Code: "AF", Name: "Afghanistan", Lat: 33.9, Lng: 66},
{Code: "YE", Name: "Yemen", Lat: 15.6, Lng: 48.5},
{Code: "MM", Name: "Myanmar", Lat: 21.2, Lng: 95.9},
{Code: "KP", Name: "North Korea", Lat: 40.2, Lng: 127.2},
{Code: "CU", Name: "Cuba", Lat: 22, Lng: -79.5},
{Code: "PR", Name: "Puerto Rico", Lat: 18.2, Lng: -66.6},
{Code: "SY", Name: "Syria", Lat: 34.8, Lng: 39},
{Code: "LB", Name: "Lebanon", Lat: 33.9, Lng: 35.5},
{Code: "JO", Name: "Jordan", Lat: 31, Lng: 36.7},
{Code: "OM", Name: "Oman", Lat: 21, Lng: 57},
{Code: "QA", Name: "Qatar", Lat: 25.3, Lng: 51.2},
{Code: "KW", Name: "Kuwait", Lat: 29.3, Lng: 48},
{Code: "BH", Name: "Bahrain", Lat: 26, Lng: 50.5},
{Code: "MN", Name: "Mongolia", Lat: 46, Lng: 105},
{Code: "NP", Name: "Nepal", Lat: 28.4, Lng: 84.1},
{Code: "LK", Name: "Sri Lanka", Lat: 7.9, Lng: 80.8},
{Code: "KH", Name: "Cambodia", Lat: 12.6, Lng: 104.9},
{Code: "LA", Name: "Laos", Lat: 19.9, Lng: 102.5},
{Code: "GE", Name: "Georgia", Lat: 42.3, Lng: 43.4},
{Code: "AM", Name: "Armenia", Lat: 40.1, Lng: 45},
{Code: "AZ", Name: "Azerbaijan", Lat: 40.4, Lng: 47.6},
{Code: "BY", Name: "Belarus", Lat: 53.7, Lng: 28},
{Code: "MD", Name: "Moldova", Lat: 47.4, Lng: 28.5},
{Code: "RS", Name: "Serbia", Lat: 44.2, Lng: 20.8},
{Code: "BA", Name: "Bosnia", Lat: 44, Lng: 17.7},
{Code: "MK", Name: "North Macedonia", Lat: 41.6, Lng: 21.7},
{Code: "AL", Name: "Albania", Lat: 41.2, Lng: 20.2},
{Code: "ME", Name: "Montenegro", Lat: 42.7, Lng: 19.4},
{Code: "TN", Name: "Tunisia", Lat: 34, Lng: 9.6},
{Code: "LY", Name: "Libya", Lat: 26.3, Lng: 17.5},
{Code: "SD", Name: "Sudan", Lat: 16, Lng: 29.9},
{Code: "ET", Name: "Ethiopia", Lat: 9, Lng: 39.8},
{Code: "SO", Name: "Somalia", Lat: 5.2, Lng: 46.2},
{Code: "TZ", Name: "Tanzania", Lat: -6.5, Lng: 35},
{Code: "UG", Name: "Uganda", Lat: 1.3, Lng: 32.4},
{Code: "GH", Name: "Ghana", Lat: 7.9, Lng: -1.2},
{Code: "CI", Name: "Côte d'Ivoire", Lat: 7.5, Lng: -5.6},
{Code: "CM", Name: "Cameroon", Lat: 5.6, Lng: 12.4},
{Code: "AO", Name: "Angola", Lat: -12, Lng: 17},
{Code: "MZ", Name: "Mozambique", Lat: -17.5, Lng: 35.6},
{Code: "ZW", Name: "Zimbabwe", Lat: -19, Lng: 29.9},
{Code: "ZM", Name: "Zambia", Lat: -14.8, Lng: 28.3},
{Code: "MW", Name: "Malawi", Lat: -13.5, Lng: 34},
{Code: "MG", Name: "Madagascar", Lat: -19.5, Lng: 46.8},
{Code: "PG", Name: "Papua New Guinea", Lat: -6, Lng: 147},
{Code: "PY", Name: "Paraguay", Lat: -23.4, Lng: -58.4},
{Code: "BO", Name: "Bolivia", Lat: -16.7, Lng: -64.7},
{Code: "EC", Name: "Ecuador", Lat: -1.8, Lng: -78.2},
{Code: "GT", Name: "Guatemala", Lat: 15.7, Lng: -90.2},
{Code: "SV", Name: "El Salvador", Lat: 13.8, Lng: -88.9},
{Code: "HN", Name: "Honduras", Lat: 15.2, Lng: -86.5},
{Code: "NI", Name: "Nicaragua", Lat: 12.9, Lng: -84.7},
{Code: "CR", Name: "Costa Rica", Lat: 10, Lng: -84},
{Code: "PA", Name: "Panama", Lat: 8.5, Lng: -80},
{Code: "DO", Name: "Dominican Republic", Lat: 19, Lng: -70.6},
{Code: "HT", Name: "Haiti", Lat: 19, Lng: -72.5},
{Code: "JM", Name: "Jamaica", Lat: 18.2, Lng: -77.3},
{Code: "TT", Name: "Trinidad & Tobago", Lat: 10.6, Lng: -61.4},
{Code: "BS", Name: "Bahamas", Lat: 24.8, Lng: -77.9},
{Code: "CY", Name: "Cyprus", Lat: 35, Lng: 33},
{Code: "MT", Name: "Malta", Lat: 35.9, Lng: 14.4},
{Code: "IS", Name: "Iceland", Lat: 64.9, Lng: -18.6},
{Code: "LU", Name: "Luxembourg", Lat: 49.8, Lng: 6.1},
{Code: "MC", Name: "Monaco", Lat: 43.7, Lng: 7.4},
{Code: "LI", Name: "Liechtenstein", Lat: 47.1, Lng: 9.5},
{Code: "SM", Name: "San Marino", Lat: 43.9, Lng: 12.5},
{Code: "VA", Name: "Vatican City", Lat: 41.9, Lng: 12.5},
{Code: "GI", Name: "Gibraltar", Lat: 36.1, Lng: -5.4},
{Code: "FO", Name: "Faroe Islands", Lat: 62, Lng: -6.8},
{Code: "IM", Name: "Isle of Man", Lat: 54.2, Lng: -4.5},
{Code: "JE", Name: "Jersey", Lat: 49.2, Lng: -2.1},
{Code: "GG", Name: "Guernsey", Lat: 49.5, Lng: -2.6},
{Code: "BM", Name: "Bermuda", Lat: 32.3, Lng: -64.8},
{Code: "KY", Name: "Cayman Islands", Lat: 19.5, Lng: -80.5},
{Code: "VI", Name: "US Virgin Islands", Lat: 18.3, Lng: -64.9},
{Code: "AW", Name: "Aruba", Lat: 12.5, Lng: -70},
{Code: "BB", Name: "Barbados", Lat: 13.2, Lng: -59.5},
{Code: "MV", Name: "Maldives", Lat: 3.2, Lng: 73.5},
{Code: "MU", Name: "Mauritius", Lat: -20.3, Lng: 57.6},
{Code: "SC", Name: "Seychelles", Lat: -4.7, Lng: 55.5},
{Code: "FJ", Name: "Fiji", Lat: -17.7, Lng: 179},
{Code: "SB", Name: "Solomon Islands", Lat: -9.6, Lng: 160.2},
{Code: "VU", Name: "Vanuatu", Lat: -16, Lng: 167},
{Code: "WS", Name: "Samoa", Lat: -13.8, Lng: -172.1},
{Code: "TO", Name: "Tonga", Lat: -21.2, Lng: -175.2},
{Code: "TL", Name: "Timor-Leste", Lat: -8.9, Lng: 125.5},
{Code: "BT", Name: "Bhutan", Lat: 27.5, Lng: 90.4},
{Code: "GQ", Name: "Equatorial Guinea", Lat: 1.7, Lng: 10},
{Code: "CD", Name: "DR Congo", Lat: -4, Lng: 21.8},
{Code: "CF", Name: "Central African Rep.", Lat: 6.6, Lng: 20.5},
{Code: "TD", Name: "Chad", Lat: 15.5, Lng: 18.7},
{Code: "ML", Name: "Mali", Lat: 17.6, Lng: -3.9},
{Code: "NE", Name: "Niger", Lat: 17.6, Lng: 8.1},
{Code: "BF", Name: "Burkina Faso", Lat: 12.2, Lng: -1.7},
{Code: "MR", Name: "Mauritania", Lat: 20.3, Lng: -10.5},
{Code: "SN", Name: "Senegal", Lat: 14.3, Lng: -14.5},
{Code: "GM", Name: "Gambia", Lat: 13.5, Lng: -15.5},
{Code: "GN", Name: "Guinea", Lat: 10.5, Lng: -10.8},
{Code: "SL", Name: "Sierra Leone", Lat: 8.5, Lng: -11.9},
{Code: "LR", Name: "Liberia", Lat: 6.5, Lng: -9.5},
{Code: "BJ", Name: "Benin", Lat: 9.3, Lng: 2.3},
{Code: "RW", Name: "Rwanda", Lat: -2, Lng: 29.9},
{Code: "BI", Name: "Burundi", Lat: -3.4, Lng: 29.9},
{Code: "DJ", Name: "Djibouti", Lat: 11.8, Lng: 42.6},
{Code: "ER", Name: "Eritrea", Lat: 15.9, Lng: 39.3},
{Code: "SS", Name: "South Sudan", Lat: 7.9, Lng: 29.9},
{Code: "BW", Name: "Botswana", Lat: -22.3, Lng: 24.4},
{Code: "NA", Name: "Namibia", Lat: -22.2, Lng: 17.2},
{Code: "LS", Name: "Lesotho", Lat: -29.5, Lng: 28.2},
{Code: "SZ", Name: "Eswatini", Lat: -26.5, Lng: 31.5},
{Code: "KM", Name: "Comoros", Lat: -11.9, Lng: 43.5},
{Code: "CV", Name: "Cape Verde", Lat: 16, Lng: -24},
{Code: "ID", Name: "Indonesia", Lat: -0.8, Lng: 117.1},
{Code: "EU", Name: "Europe", Lat: 50.0, Lng: 10.0},
}
var countryByCode map[string]Country
func init() {
countryByCode = make(map[string]Country, len(Countries))
for _, c := range Countries {
countryByCode[c.Code] = c
}
}
// Lookup returns a country by ISO-2 code (case-insensitive).
func Lookup(code string) (Country, bool) {
if code == "" {
return Country{}, false
}
c := NormalizeCode(code)
if c == "" || c == UnknownGeo || c == "XX" || c == "ZZ" {
return Country{}, false
}
co, ok := countryByCode[c]
return co, ok
}
// DisplayName returns a friendly label for a geo code.
func DisplayName(code string) string {
raw := stringsTrimLower(code)
if raw == "" || raw == UnknownGeo || raw == "xx" || raw == "zz" {
return "Unknown"
}
c := NormalizeCode(code)
if c == "" {
return "Unknown"
}
if co, ok := countryByCode[c]; ok {
return co.Name
}
return c
}
func stringsTrimLower(s string) string {
b := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
ch := s[i]
if ch >= 'A' && ch <= 'Z' {
ch += 'a' - 'A'
}
if ch == ' ' || ch == '\t' {
continue
}
b = append(b, ch)
}
return string(b)
}
// NormalizeCode uppercases and strips non-letters from a geo token.
func NormalizeCode(code string) string {
b := make([]byte, 0, len(code))
for i := 0; i < len(code); i++ {
ch := code[i]
if ch >= 'a' && ch <= 'z' {
ch -= 'a' - 'A'
}
if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') {
b = append(b, ch)
}
}
s := string(b)
if s == "UK" {
return "GB"
}
return s
}
+107
View File
@@ -0,0 +1,107 @@
package geomap
import (
"strings"
"testing"
"time"
)
func TestLookupAndNormalize(t *testing.T) {
c, ok := Lookup("us")
if !ok || c.Code != "US" || c.Name == "" {
t.Fatalf("lookup US: %+v ok=%v", c, ok)
}
if NormalizeCode("uk") != "GB" {
t.Fatal("UK should map to GB")
}
if DisplayName("CN") != "China" {
t.Fatalf("name CN: %s", DisplayName("CN"))
}
if DisplayName("unknown") != "Unknown" {
t.Fatal("unknown display")
}
}
func TestProjectInBounds(t *testing.T) {
cam := DefaultCamera()
x, y, ok := Project(0, 0, cam, 80, 24) // Gulf of Guinea-ish
if !ok {
t.Fatal("expected on-map")
}
if x < 0 || x >= 80 || y < 0 || y >= 24 {
t.Fatalf("out of bounds %d,%d", x, y)
}
// Extreme lat should often still project near edges
_, _, okN := Project(85, 0, cam, 80, 24)
_ = okN
}
func TestZoomClamp(t *testing.T) {
c := Camera{Zoom: 0.01}
c.ClampZoom()
if c.Zoom != MinZoom {
t.Fatalf("min zoom %v", c.Zoom)
}
c.Zoom = 99
c.ClampZoom()
if c.Zoom != MaxZoom {
t.Fatalf("max zoom %v", c.Zoom)
}
}
func TestStateRankedAndPulse(t *testing.T) {
s := NewState()
s.SetStats(map[string]int{"CN": 100, "US": 50, "BR": 10}, map[string]int{"CN": 5}, map[string]int{"US": 2})
ranked := s.Ranked()
if len(ranked) < 3 || ranked[0].Code != "CN" {
t.Fatalf("ranked: %+v", ranked)
}
s.SetRange(RangeHour)
ranked = s.Ranked()
if len(ranked) != 1 || ranked[0].Code != "CN" {
t.Fatalf("hour ranked: %+v", ranked)
}
s.SetReducedMotion(false)
s.TriggerPulse("BR", "SSH")
if !s.HasPulses() {
t.Fatal("expected pulse")
}
// Force expire
s.mu.Lock()
s.pulses[0].Born = time.Now().Add(-2 * time.Second)
s.mu.Unlock()
if s.Tick() {
t.Fatal("pulse should expire")
}
}
func TestRenderProducesOutput(t *testing.T) {
s := NewState()
s.SetStats(map[string]int{"CN": 100, "US": 40}, nil, map[string]int{"US": 1})
s.TriggerPulse("CN", "HTTP")
m := s.RenderModel(60, 18)
var hits []HitRegion
out := Render(m, DefaultPalette(), nil, &hits)
if !strings.Contains(out, "\n") {
t.Fatal("expected multi-line map")
}
if len(hits) == 0 {
t.Fatal("expected hit regions")
}
s.SetHits(hits)
// Click first hit center
h := hits[0]
code, ok := s.HitTest(h.X, h.Y)
if !ok || code != h.Code {
t.Fatalf("hit test got %q ok=%v want %s", code, ok, h.Code)
}
}
func TestEmptyRenderMessage(t *testing.T) {
m := RenderModel{CharsW: 40, CharsH: 10, Cam: DefaultCamera()}
out := Render(m, DefaultPalette(), nil, nil)
if !strings.Contains(out, "No attack geo data yet") {
t.Fatalf("empty state: %q", out)
}
}
+125
View File
@@ -0,0 +1,125 @@
package geomap
import "math"
// Camera controls pan/zoom over an equirectangular world map.
// Matches web viz defaults: zoom 0.453.5.
type Camera struct {
PanX, PanY float64
Zoom float64
}
const (
MinZoom = 0.45
MaxZoom = 3.5
)
// DefaultCamera returns a fitted world view.
func DefaultCamera() Camera {
return Camera{Zoom: 1}
}
// Reset restores default pan/zoom.
func (c *Camera) Reset() {
*c = DefaultCamera()
}
// ClampZoom keeps zoom within web-compatible bounds.
func (c *Camera) ClampZoom() {
if c.Zoom < MinZoom {
c.Zoom = MinZoom
}
if c.Zoom > MaxZoom {
c.Zoom = MaxZoom
}
}
// ZoomAt multiplies zoom, keeping the given cell roughly under the cursor.
func (c *Camera) ZoomAt(factor float64, cellX, cellY, charsW, charsH int) {
if factor <= 0 || charsW < 1 || charsH < 1 {
return
}
old := c.Zoom
if old < MinZoom {
old = MinZoom
}
// World coords under cursor before zoom.
wx := (float64(cellX)-float64(charsW)/2)/old - c.PanX
wy := (float64(cellY)-float64(charsH)/2)/old - c.PanY
c.Zoom = old * factor
c.ClampZoom()
// Adjust pan so the same world point stays under cursor.
c.PanX = (float64(cellX)-float64(charsW)/2)/c.Zoom - wx
c.PanY = (float64(cellY)-float64(charsH)/2)/c.Zoom - wy
}
// PanBy shifts the camera in cell units (screen space).
func (c *Camera) PanBy(dx, dy float64) {
if c.Zoom < MinZoom {
c.Zoom = MinZoom
}
c.PanX += dx / c.Zoom
c.PanY += dy / c.Zoom
}
// Project maps lat/lng to character-cell coordinates.
// Equirectangular: x ∝ lng, y ∝ -lat, centered, scaled by zoom + pan.
func Project(lat, lng float64, cam Camera, charsW, charsH int) (cellX, cellY int, ok bool) {
if charsW < 1 || charsH < 1 {
return 0, 0, false
}
zoom := cam.Zoom
if zoom < MinZoom {
zoom = MinZoom
}
// Normalized world coords in [-0.5, 0.5] for full globe.
nx := lng / 360.0
ny := -lat / 180.0
// Fit globe to ~90% of the smaller dimension so poles have margin.
base := float64(charsW)
if float64(charsH)*2 < base {
base = float64(charsH) * 2
}
scale := base * 0.92 * zoom
cx := float64(charsW)/2 + (nx+cam.PanX/float64(charsW)*scale)*scale
cy := float64(charsH)/2 + (ny+cam.PanY/float64(charsH)*scale)*scale
x := int(math.Round(cx))
y := int(math.Round(cy))
if x < 0 || y < 0 || x >= charsW || y >= charsH {
return x, y, false
}
return x, y, true
}
// Unproject maps a cell back to approximate lat/lng (for hit-testing helpers).
func Unproject(cellX, cellY int, cam Camera, charsW, charsH int) (lat, lng float64) {
if charsW < 1 || charsH < 1 {
return 0, 0
}
zoom := cam.Zoom
if zoom < MinZoom {
zoom = MinZoom
}
base := float64(charsW)
if float64(charsH)*2 < base {
base = float64(charsH) * 2
}
scale := base * 0.92 * zoom
if scale == 0 {
return 0, 0
}
nx := (float64(cellX)-float64(charsW)/2)/scale - cam.PanX/float64(charsW)*scale/scale
ny := (float64(cellY)-float64(charsH)/2)/scale - cam.PanY/float64(charsH)*scale/scale
// cam.Pan is in cell units divided by zoom in PanBy — keep consistent with Project:
nx = (float64(cellX) - float64(charsW)/2) / scale
ny = (float64(cellY) - float64(charsH)/2) / scale
// Subtract pan contribution as used in Project:
// cx = W/2 + (nx + panX/W*scale) * scale
// => (cx - W/2)/scale = nx + panX/W*scale
// => nx = (cx - W/2)/scale - panX/W*scale
nx = (float64(cellX)-float64(charsW)/2)/scale - cam.PanX/float64(charsW)*scale
ny = (float64(cellY)-float64(charsH)/2)/scale - cam.PanY/float64(charsH)*scale
lng = nx * 360.0
lat = -ny * 180.0
return lat, lng
}
+297
View File
@@ -0,0 +1,297 @@
package geomap
import (
"fmt"
"math"
"strings"
)
// StyleFunc colors a string with a CSS/hex color (supplied by UI/lipgloss).
type StyleFunc func(color, s string) string
// Palette for map rendering.
type Palette struct {
Land string
Grid string
Muted string
HeatLow string
HeatMid string
HeatHigh string
Pulse string
Peer string
Selected string
Honey string
Border string
}
// DefaultPalette matches hp-viz dark theme accents.
func DefaultPalette() Palette {
return Palette{
Land: "#3d3520",
Grid: "#2a2520",
Muted: "#a8a29e",
HeatLow: "#78350f",
HeatMid: "#eab84d",
HeatHigh: "#f87171",
Pulse: "#2fe8da",
Peer: "#2fe8da",
Selected: "#fbbf24",
Honey: "#eab84d",
Border: "#3d3520",
}
}
// Render draws the world map into a multi-line string of size charsW×charsH.
// style may be nil (plain text). hits is filled for click testing.
func Render(m RenderModel, p Palette, style StyleFunc, hits *[]HitRegion) string {
if style == nil {
style = func(_, s string) string { return s }
}
w, h := m.CharsW, m.CharsH
if w < 8 || h < 4 {
return style(p.Muted, "map too small")
}
// Character buffer + optional color tags (store raw then style on emit).
type cell struct {
ch rune
color string
pri int // higher wins when overwriting
}
buf := make([]cell, w*h)
for i := range buf {
buf[i] = cell{ch: ' ', color: p.Muted, pri: 0}
}
set := func(x, y int, ch rune, color string, pri int) {
if x < 0 || y < 0 || x >= w || y >= h {
return
}
i := y*w + x
if pri < buf[i].pri {
return
}
buf[i] = cell{ch: ch, color: color, pri: pri}
}
// Subtle equator / prime meridian grid.
for x := 0; x < w; x++ {
_, ey, ok := Project(0, -180+360*float64(x)/float64(w), m.Cam, w, h)
if ok {
set(x, ey, '·', p.Grid, 1)
}
}
// Longitude ticks at 0° / ±90
for _, lng := range []float64{-180, -90, 0, 90, 180} {
for lat := -60.0; lat <= 60; lat += 15 {
x, y, ok := Project(lat, lng, m.Cam, w, h)
if ok {
set(x, y, '·', p.Grid, 1)
}
}
}
// Soft land hints: small markers for every known centroid (very dim).
for _, c := range Countries {
x, y, ok := Project(c.Lat, c.Lng, m.Cam, w, h)
if ok {
set(x, y, '·', p.Land, 2)
}
}
maxCount := 1
for _, n := range m.Counts {
if n > maxCount {
maxCount = n
}
}
localHits := make([]HitRegion, 0, len(m.Counts)+8)
// Heat markers.
for code, n := range m.Counts {
if n <= 0 {
continue
}
co, ok := Lookup(code)
if !ok {
continue
}
x, y, on := Project(co.Lat, co.Lng, m.Cam, w, h)
if !on {
continue
}
t := math.Log1p(float64(n)) / math.Log1p(float64(maxCount))
ch, col := heatGlyph(t, p)
pri := 5
if code == m.Selected {
ch = '◉'
col = p.Selected
pri = 20
} else if code == m.Hover {
pri = 15
col = p.Honey
}
set(x, y, ch, col, pri)
// Size ring for large counts.
if t > 0.55 {
for _, d := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} {
set(x+d[0], y+d[1], '·', col, pri-1)
}
}
r := 1
if t > 0.4 {
r = 2
}
if t > 0.75 {
r = 3
}
localHits = append(localHits, HitRegion{Code: code, X: x, Y: y, R: r})
}
// Peer markers (outline-ish).
for code, n := range m.Peers {
if n <= 0 {
continue
}
co, ok := Lookup(code)
if !ok {
continue
}
x, y, on := Project(co.Lat, co.Lng, m.Cam, w, h)
if !on {
continue
}
// Offset so heat and peer can coexist.
set(x+1, y-1, '◆', p.Peer, 12)
localHits = append(localHits, HitRegion{Code: code, X: x + 1, Y: y - 1, R: 1})
}
// Pulses.
for _, pulse := range m.Pulses {
co, ok := Lookup(pulse.Code)
if !ok {
continue
}
x, y, on := Project(co.Lat, co.Lng, m.Cam, w, h)
if !on {
continue
}
ch := '●'
if pulse.Intensity < 0.4 {
ch = '○'
} else if pulse.Intensity < 0.7 {
ch = '◎'
}
set(x, y, ch, p.Pulse, 25)
if pulse.Intensity > 0.5 {
rad := 1 + int((1-pulse.Intensity)*2)
for _, d := range [][2]int{{-rad, 0}, {rad, 0}, {0, -rad}, {0, rad}} {
set(x+d[0], y+d[1], '·', p.Pulse, 18)
}
}
}
if hits != nil {
*hits = localHits
}
// Empty state message if no counts.
if len(m.Counts) == 0 {
msg := "No attack geo data yet"
start := (w - len(msg)) / 2
if start < 0 {
start = 0
}
row := h / 2
for i, r := range msg {
if start+i < w {
set(start+i, row, r, p.Muted, 30)
}
}
}
var b strings.Builder
for y := 0; y < h; y++ {
if y > 0 {
b.WriteByte('\n')
}
// Group runs of same color for fewer style calls.
runStart := 0
for x := 0; x <= w; x++ {
flush := x == w
if !flush && x > runStart {
if buf[y*w+x].color != buf[y*w+runStart].color {
flush = true
}
}
if flush {
if runStart < x {
var run strings.Builder
for i := runStart; i < x; i++ {
run.WriteRune(buf[y*w+i].ch)
}
col := p.Muted
if runStart < w {
col = buf[y*w+runStart].color
}
b.WriteString(style(col, run.String()))
}
runStart = x
}
}
}
return b.String()
}
func heatGlyph(t float64, p Palette) (rune, string) {
switch {
case t > 0.75:
return '●', p.HeatHigh
case t > 0.45:
return '◎', p.HeatMid
case t > 0.2:
return '•', p.HeatMid
default:
return '·', p.HeatLow
}
}
// FormatLegend returns a one-line legend.
func FormatLegend(style StyleFunc, p Palette) string {
if style == nil {
style = func(_, s string) string { return s }
}
parts := []string{
style(p.HeatHigh, "●") + style(p.Muted, " heat"),
style(p.Pulse, "◎") + style(p.Muted, " pulse"),
style(p.Peer, "◆") + style(p.Muted, " peer"),
style(p.Selected, "◉") + style(p.Muted, " selected"),
}
return strings.Join(parts, style(p.Muted, " · "))
}
// FormatStatus builds the control/status line under the map.
func FormatStatus(rangeMode Range, selected string, count, peers int, cam Camera) string {
sel := "none"
if selected != "" {
sel = fmt.Sprintf("%s (%s)", DisplayName(selected), NormalizeCode(selected))
if count > 0 {
sel += fmt.Sprintf(" %s", formatCount(count))
}
if peers > 0 {
sel += fmt.Sprintf(" · %d peers", peers)
}
}
return fmt.Sprintf("range [%s] · zoom %.2f · select: %s · [ ] range 0 reset click/j k select enter filter",
rangeMode.Label(), cam.Zoom, sel)
}
func formatCount(n int) string {
if n < 1000 {
return fmt.Sprintf("%d", n)
}
if n < 1_000_000 {
return fmt.Sprintf("%.1fk", float64(n)/1000)
}
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
}
+383
View File
@@ -0,0 +1,383 @@
package geomap
import (
"sort"
"sync"
"time"
)
// Range selects which geo breakdown to display.
type Range int
const (
RangeAll Range = iota
RangeHour
)
func (r Range) Label() string {
if r == RangeHour {
return "1h"
}
return "all"
}
func (r Range) Next() Range {
if r == RangeAll {
return RangeHour
}
return RangeAll
}
// Pulse is a short-lived attack flash on a country.
type Pulse struct {
Code string
Service string
Born time.Time
TTL time.Duration
Intensity float64 // 01 remaining
}
// RankedGeo is one row in the side list.
type RankedGeo struct {
Code string
Name string
Count int
Peers int
HasPos bool // has centroid for map marker
}
// HitRegion is a clickable country marker in cell space.
type HitRegion struct {
Code string
X, Y int
R int // radius in cells
}
// State holds map camera, data, pulses, and selection.
type State struct {
mu sync.Mutex
cam Camera
rangeMode Range
selected string
hover string
reducedMotion bool
allCounts map[string]int
hourCounts map[string]int
peersByGeo map[string]int
pulses []Pulse
hits []HitRegion
// last render size for hit tests
charsW, charsH int
}
// NewState creates an empty map state.
func NewState() *State {
return &State{
cam: DefaultCamera(),
allCounts: map[string]int{},
hourCounts: map[string]int{},
peersByGeo: map[string]int{},
}
}
func (s *State) SetReducedMotion(v bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.reducedMotion = v
}
func (s *State) Camera() Camera {
s.mu.Lock()
defer s.mu.Unlock()
return s.cam
}
func (s *State) SetCamera(c Camera) {
s.mu.Lock()
defer s.mu.Unlock()
c.ClampZoom()
s.cam = c
}
func (s *State) ResetCamera() {
s.mu.Lock()
defer s.mu.Unlock()
s.cam.Reset()
}
func (s *State) Range() Range {
s.mu.Lock()
defer s.mu.Unlock()
return s.rangeMode
}
func (s *State) CycleRange() Range {
s.mu.Lock()
defer s.mu.Unlock()
s.rangeMode = s.rangeMode.Next()
return s.rangeMode
}
func (s *State) SetRange(r Range) {
s.mu.Lock()
defer s.mu.Unlock()
s.rangeMode = r
}
func (s *State) Selected() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.selected
}
func (s *State) SetSelected(code string) {
s.mu.Lock()
defer s.mu.Unlock()
if code == "" {
s.selected = ""
return
}
s.selected = NormalizeCode(code)
}
func (s *State) ClearSelected() {
s.SetSelected("")
}
func (s *State) SetHover(code string) {
s.mu.Lock()
defer s.mu.Unlock()
if code == "" {
s.hover = ""
return
}
s.hover = NormalizeCode(code)
}
func (s *State) Hover() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.hover
}
// SetStats replaces aggregate geo maps from viz.Stats.
func (s *State) SetStats(all, hour, peers map[string]int) {
s.mu.Lock()
defer s.mu.Unlock()
s.allCounts = cloneCounts(all)
s.hourCounts = cloneCounts(hour)
s.peersByGeo = cloneCounts(peers)
}
// BumpGeo increments live counters when an attack arrives (optimistic).
func (s *State) BumpGeo(code string) {
code = NormalizeCode(code)
if code == "" || code == UnknownGeo {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.allCounts[code]++
s.hourCounts[code]++
}
// TriggerPulse adds a visual attack flash.
func (s *State) TriggerPulse(code, service string) {
code = NormalizeCode(code)
if code == "" || code == UnknownGeo {
return
}
if _, ok := Lookup(code); !ok {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.reducedMotion {
return
}
const maxPulses = 48
s.pulses = append(s.pulses, Pulse{
Code: code,
Service: service,
Born: time.Now(),
TTL: 900 * time.Millisecond,
Intensity: 1,
})
if len(s.pulses) > maxPulses {
s.pulses = s.pulses[len(s.pulses)-maxPulses:]
}
}
// Tick advances pulse intensities; returns whether any pulses remain.
func (s *State) Tick() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.pulses) == 0 {
return false
}
now := time.Now()
out := s.pulses[:0]
for _, p := range s.pulses {
age := now.Sub(p.Born)
if age >= p.TTL {
continue
}
p.Intensity = 1 - float64(age)/float64(p.TTL)
out = append(out, p)
}
s.pulses = out
return len(s.pulses) > 0
}
// HasPulses reports active animations.
func (s *State) HasPulses() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pulses) > 0
}
// ActiveCounts returns the breakdown for the current range.
func (s *State) ActiveCounts() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
if s.rangeMode == RangeHour {
return cloneCounts(s.hourCounts)
}
return cloneCounts(s.allCounts)
}
// Ranked returns sorted geos for the side list.
func (s *State) Ranked() []RankedGeo {
s.mu.Lock()
defer s.mu.Unlock()
counts := s.allCounts
if s.rangeMode == RangeHour {
counts = s.hourCounts
}
out := make([]RankedGeo, 0, len(counts))
for code, n := range counts {
if n <= 0 {
continue
}
nc := NormalizeCode(code)
name := DisplayName(nc)
_, has := Lookup(nc)
out = append(out, RankedGeo{
Code: nc,
Name: name,
Count: n,
Peers: s.peersByGeo[nc],
HasPos: has,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Code < out[j].Code
})
return out
}
// Snapshot for rendering (copies under lock).
type RenderModel struct {
Cam Camera
Range Range
Selected string
Hover string
Counts map[string]int
Peers map[string]int
Pulses []Pulse
ReducedMotion bool
CharsW int
CharsH int
}
func (s *State) RenderModel(charsW, charsH int) RenderModel {
s.mu.Lock()
defer s.mu.Unlock()
s.charsW, s.charsH = charsW, charsH
counts := s.allCounts
if s.rangeMode == RangeHour {
counts = s.hourCounts
}
pulses := make([]Pulse, len(s.pulses))
copy(pulses, s.pulses)
return RenderModel{
Cam: s.cam,
Range: s.rangeMode,
Selected: s.selected,
Hover: s.hover,
Counts: cloneCounts(counts),
Peers: cloneCounts(s.peersByGeo),
Pulses: pulses,
ReducedMotion: s.reducedMotion,
CharsW: charsW,
CharsH: charsH,
}
}
// SetHits stores the last frame's hit regions.
func (s *State) SetHits(hits []HitRegion) {
s.mu.Lock()
defer s.mu.Unlock()
s.hits = hits
}
// HitTest returns the country under cell (x,y), if any.
func (s *State) HitTest(x, y int) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
best := ""
bestD := 1 << 30
for _, h := range s.hits {
dx := x - h.X
dy := y - h.Y
d := dx*dx + dy*dy
r := h.R
if r < 1 {
r = 1
}
if d <= r*r && d < bestD {
bestD = d
best = h.Code
}
}
if best == "" {
return "", false
}
return best, true
}
// CountFor returns the active-range count for a code.
func (s *State) CountFor(code string) int {
s.mu.Lock()
defer s.mu.Unlock()
code = NormalizeCode(code)
if s.rangeMode == RangeHour {
return s.hourCounts[code]
}
return s.allCounts[code]
}
// PeersFor returns peer count in a geo.
func (s *State) PeersFor(code string) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.peersByGeo[NormalizeCode(code)]
}
func cloneCounts(m map[string]int) map[string]int {
if m == nil {
return map[string]int{}
}
out := make(map[string]int, len(m))
for k, v := range m {
out[NormalizeCode(k)] = v
}
return out
}
+17 -4
View File
@@ -354,7 +354,7 @@ func renderFooter(t Theme, filtering, searching, operatorSigning bool, filter st
} else if filtering {
hints = []string{"type filter…", "Enter apply", "Esc clear"}
} else {
hints = []string{"1-6 views", "Tab/S-Tab", "j/k", "Enter", "o", "s", "/", "?", "q"}
hints = []string{"1-7 views", "Tab/S-Tab", "j/k", "Enter", "o", "s", "/", "?", "q"}
if mouseEnabled {
hints = append([]string{"click", "wheel"}, hints...)
}
@@ -364,6 +364,9 @@ func renderFooter(t Theme, filtering, searching, operatorSigning bool, filter st
if tab == TabHistory {
hints = append(hints, "] sub", "[ range")
}
if tab == TabMap {
hints = append(hints, "[/] range", "←/→ pan", "0 reset", "enter filter geo")
}
hints = append(hints, "e sound")
if soundEnabled {
hints = append(hints, "-/= vol")
@@ -405,8 +408,8 @@ func renderHelp(t Theme) string {
Navigation
j / k ↑ / ↓ Move selection
Tab / Shift+Tab Next / previous view
16 Jump to Feed · Incidents · Peers · Stats · History · Blog
Enter Open detail (or blog post)
17 Feed · Incidents · Peers · Stats · History · Map · Blog
Enter Open detail (or blog post / map geo filter)
f Toggle follow (auto-scroll to newest)
PgUp / PgDn Page list · load more history/blog
@@ -415,6 +418,16 @@ Feed
e Toggle event sounds
- / = Volume down / up (while sound is on)
Map (global attack map — web-parity)
[ / ] Time range: all ↔ last hour
j / k Move country list selection
Enter Apply geo:XX filter and jump to Feed
← / → Pan map
0 Reset pan/zoom
c Clear country selection
Wheel Zoom (over map) · scroll list (over regions)
Click country Select region
Filter & search
/ Filter — text or service:ssh geo:cn peer:id tier:block since:1h
s Search by attack_… or mod_… ID
@@ -442,7 +455,7 @@ General
Environment
HP_VIZ_URL Master base URL
HP_VIZ_MOUSE=1 Mouse on at startup
HP_VIZ_REDUCED_MOTION=1 Static mesh, no sounds
HP_VIZ_REDUCED_MOTION=1 Static mesh/map, no sounds
HP_VIZ_BELL=1 Terminal bell on block events
HP_VIZ_NO_UPDATE=1 Skip auto-update checks
`
+3
View File
@@ -24,6 +24,7 @@ const (
TabPeers
TabStats
TabHistory
TabMap
TabBlog
)
@@ -39,6 +40,8 @@ func tabLabel(tab ViewTab) string {
return "Stats"
case TabHistory:
return "History"
case TabMap:
return "Map"
case TabBlog:
return "Blog"
default:
+25
View File
@@ -0,0 +1,25 @@
package ui
import (
"strings"
"testing"
"github.com/honeypeer/cli-viz/internal/config"
"github.com/honeypeer/cli-viz/internal/viz"
)
func TestHeaderHasNoURL(t *testing.T) {
m := NewModel(config.Config{URL: "https://viz.honeypeer.com", Buffer: 100})
m.width = 120
m.height = 40
m.ready = true
m.stats = &viz.Stats{AttacksLastHour: 1, TotalAttacks: 2, ConnectedPeers: 3, BlockedIPs: 4}
out := m.renderBaseView()
if strings.Contains(out, "viz.honeypeer.com") || strings.Contains(out, "https://") {
t.Fatalf("header/view still contains URL:\n%s", out[:min(500, len(out))])
}
top := m.chromeTop()
if strings.Contains(top, "https://") || strings.Contains(top, "viz.honeypeer") {
t.Fatalf("chromeTop still contains URL: %q", top)
}
}
+6
View File
@@ -86,6 +86,12 @@ func (m Model) viewLayout() viewLayout {
ly.ListH = feedH
ly.MeshTop = ly.ListTop + feedH + 1
ly.MeshH = meshH
} else if m.tab == TabMap {
mapH, listH := mapPanelHeights(mainH)
ly.MeshTop = ly.MainInnerTop
ly.MeshH = mapH
ly.ListTop = ly.MeshTop + mapH + 1
ly.ListH = listH
}
return ly
}
+7 -4
View File
@@ -46,14 +46,17 @@ func TestTabHitZones(t *testing.T) {
theme := NewTheme(false)
for active := ViewTab(0); active <= TabBlog; active++ {
zones := tabHitZones(active)
if len(zones) != 6 {
t.Fatalf("active=%d: expected 6 tabs, got %d", active, len(zones))
if len(zones) != 7 {
t.Fatalf("active=%d: expected 7 tabs, got %d", active, len(zones))
}
if zones[0].Tab != TabFeed || zones[0].StartX != 0 {
t.Fatalf("active=%d: unexpected first zone: %+v", active, zones[0])
}
if zones[5].Tab != TabBlog {
t.Fatalf("active=%d: expected Blog tab last, got %+v", active, zones[5])
if zones[5].Tab != TabMap {
t.Fatalf("active=%d: expected Map before Blog, got %+v", active, zones[5])
}
if zones[6].Tab != TabBlog {
t.Fatalf("active=%d: expected Blog tab last, got %+v", active, zones[6])
}
// Hit ranges must match the rendered tab row width.
+220
View File
@@ -0,0 +1,220 @@
package ui
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/geomap"
"github.com/honeypeer/cli-viz/internal/viz"
)
type mapTickMsg struct{}
const mapFrameMs = 80
func mapPanelHeights(totalH int) (mapH, listH int) {
return splitPanelHeights(totalH, 68, 8, 5)
}
func (m *Model) syncMapFromStats() {
if m.geoMap == nil {
return
}
var all, hour, peers map[string]int
if m.stats != nil {
all = m.stats.GeoBreakdown
hour = m.stats.GeoBreakdownLastHour
peers = m.stats.PeersByGeo
}
// Also derive peers-by-geo from live roster when API omits peersByGeo.
if len(peers) == 0 && m.network != nil {
peers = map[string]int{}
for _, p := range m.network.Peers {
if g := geomap.NormalizeCode(p.Geo); g != "" {
peers[g]++
}
}
}
m.geoMap.SetStats(all, hour, peers)
}
func (m *Model) mapTickCmd() tea.Cmd {
return tea.Tick(mapFrameMs*time.Millisecond, func(time.Time) tea.Msg { return mapTickMsg{} })
}
func (m *Model) startMapTick() tea.Cmd {
if m.mapTickActive {
return nil
}
if m.geoMap == nil || m.tab != TabMap {
return nil
}
m.mapTickActive = true
return m.mapTickCmd()
}
func (m *Model) onMapGeoEvent(geo, service string) tea.Cmd {
if m.geoMap == nil || geo == "" {
return nil
}
m.geoMap.BumpGeo(geo)
m.geoMap.TriggerPulse(geo, service)
if m.tab == TabMap {
return m.startMapTick()
}
return nil
}
func (m Model) renderMapView(height, width int) string {
if m.geoMap == nil {
return m.theme.MutedStyle().Render("Map unavailable")
}
mapH, listH := mapPanelHeights(height)
listW := width
if listW < 20 {
listW = 20
}
style := func(color, s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(s)
}
pal := geomap.DefaultPalette()
pal.Honey = string(m.theme.Honey)
pal.HeatHigh = string(m.theme.Attack)
pal.Pulse = string(m.theme.Teal)
pal.Peer = string(m.theme.Teal)
pal.Selected = "#fbbf24"
pal.Muted = string(m.theme.Muted)
rm := m.geoMap.RenderModel(listW, maxInt(4, mapH-1))
var hits []geomap.HitRegion
mapBody := geomap.Render(rm, pal, style, &hits)
m.geoMap.SetHits(hits)
legend := geomap.FormatLegend(style, pal)
mapBlock := padToLines(mapBody+"\n"+legend, mapH)
sel := m.geoMap.Selected()
status := geomap.FormatStatus(m.geoMap.Range(), sel, m.geoMap.CountFor(sel), m.geoMap.PeersFor(sel), m.geoMap.Camera())
statusLine := m.theme.MutedStyle().Render(status)
ranked := m.geoMap.Ranked()
cursor := m.mapListCursor
if cursor >= len(ranked) {
if len(ranked) == 0 {
cursor = 0
} else {
cursor = len(ranked) - 1
}
}
var listLines []string
listLines = append(listLines, lipgloss.NewStyle().Bold(true).Foreground(m.theme.Honey).Render("── Top regions ──"))
hdr := m.theme.MutedStyle().Bold(true)
listLines = append(listLines, joinRow(
hdr.Render(padCellLeft("GEO", 4)),
hdr.Render(padCellRight("COUNT", 8)),
hdr.Render(padCellRight("PEERS", 6)),
hdr.Render("NAME"),
))
bottomH := listH
listInner := bottomH - 1
if listInner < 3 {
listInner = 3
}
start := listScrollStart(cursor, len(ranked), listInner-1)
end := start + listInner - 1
if end > len(ranked) {
end = len(ranked)
}
if len(ranked) == 0 {
listLines = append(listLines, m.theme.MutedStyle().Render(" No geo breakdown yet"))
} else {
for i := start; i < end; i++ {
r := ranked[i]
row := joinRow(
padCellLeft(r.Code, 4),
padCellRight(viz.FormatCount(r.Count), 8),
padCellRight(fmt.Sprintf("%d", r.Peers), 6),
padCellLeft(r.Name, maxInt(8, listW-24)),
)
if i == cursor {
listLines = append(listLines, m.theme.SelectedStyle().Render(row))
} else if r.Code == sel {
listLines = append(listLines, lipgloss.NewStyle().Foreground(m.theme.Honey).Render(row))
} else {
listLines = append(listLines, row)
}
}
}
bottom := statusLine + "\n" + strings.Join(listLines, "\n")
bottom = padToLines(bottom, bottomH)
sep := m.theme.MutedStyle().Render(strings.Repeat("─", min(listW, 48)))
return padToLines(mapBlock+"\n"+sep+"\n"+bottom, height)
}
func (m *Model) mapSelectCursor() {
ranked := m.geoMap.Ranked()
if m.mapListCursor < 0 || m.mapListCursor >= len(ranked) {
return
}
m.geoMap.SetSelected(ranked[m.mapListCursor].Code)
}
func (m *Model) mapApplyGeoFilter() tea.Cmd {
code := m.geoMap.Selected()
if code == "" {
ranked := m.geoMap.Ranked()
if m.mapListCursor >= 0 && m.mapListCursor < len(ranked) {
code = ranked[m.mapListCursor].Code
m.geoMap.SetSelected(code)
}
}
if code == "" {
return nil
}
m.filterQuery = "geo:" + strings.ToLower(code)
m.filterInput.SetValue(m.filterQuery)
m.statusErr = ""
// Jump to feed with filter applied (web opens live feed filtered by geo).
return tea.Batch(m.switchTab(TabFeed)...)
}
// handleMapExtraKeys handles Map-tab-only keys (pan/zoom/reset/clear).
// Returns true if the key was consumed.
func (m *Model) handleMapExtraKeys(msg tea.KeyMsg) (bool, tea.Cmd) {
if m.tab != TabMap || m.geoMap == nil {
return false, nil
}
switch msg.String() {
case "left", "h":
cam := m.geoMap.Camera()
cam.PanBy(-3, 0)
m.geoMap.SetCamera(cam)
return true, nil
case "right", "l":
// Avoid stealing `l` if we need it later; only pan with arrows by default.
if msg.String() == "l" {
return false, nil
}
cam := m.geoMap.Camera()
cam.PanBy(3, 0)
m.geoMap.SetCamera(cam)
return true, nil
case "0":
m.geoMap.ResetCamera()
return true, nil
case "c":
m.geoMap.ClearSelected()
return true, nil
default:
return false, nil
}
}
+82 -12
View File
@@ -13,6 +13,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/honeypeer/cli-viz/internal/config"
"github.com/honeypeer/cli-viz/internal/geomap"
"github.com/honeypeer/cli-viz/internal/mesh"
"github.com/honeypeer/cli-viz/internal/sound"
"github.com/honeypeer/cli-viz/internal/transport"
@@ -61,7 +62,7 @@ type keyMap struct {
Sound key.Binding
VolDown key.Binding
VolUp key.Binding
Tab1, Tab2, Tab3, Tab4, Tab5, Tab6 key.Binding
Tab1, Tab2, Tab3, Tab4, Tab5, Tab6, Tab7 key.Binding
}
func defaultKeyMap() keyMap {
@@ -93,7 +94,8 @@ func defaultKeyMap() keyMap {
Tab3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Peers")),
Tab4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Stats")),
Tab5: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "History")),
Tab6: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Blog")),
Tab6: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Map")),
Tab7: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "Blog")),
}
}
@@ -178,6 +180,10 @@ type Model struct {
meshTickActive bool
feedMeshVisible bool
sound *sound.Engine
geoMap *geomap.State
mapTickActive bool
mapListCursor int
}
func NewModel(cfg config.Config) Model {
@@ -223,6 +229,9 @@ func NewModel(cfg config.Config) Model {
pm := mesh.NewState()
pm.SetReducedMotion(cfg.NoColor || envTruthy("HP_VIZ_REDUCED_MOTION"))
m.peerMesh = pm
gm := geomap.NewState()
gm.SetReducedMotion(cfg.NoColor || envTruthy("HP_VIZ_REDUCED_MOTION"))
m.geoMap = gm
m.sound = sound.New()
return m
}
@@ -282,6 +291,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.operatorSigning {
return m.updateOperatorSignIn(msg)
}
if handled, cmd := m.handleMapExtraKeys(msg); handled {
return m, cmd
}
if m.detailOpen {
switch {
case key.Matches(msg, m.keys.Close):
@@ -372,6 +384,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Tab5):
cmds = append(cmds, m.switchTab(TabHistory)...)
case key.Matches(msg, m.keys.Tab6):
cmds = append(cmds, m.switchTab(TabMap)...)
case key.Matches(msg, m.keys.Tab7):
cmds = append(cmds, m.switchTab(TabBlog)...)
case key.Matches(msg, m.keys.Follow):
m.follow = !m.follow
@@ -381,10 +395,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.HistSub):
if m.tab == TabHistory {
cmds = append(cmds, m.cycleHistorySubTab())
} else if m.tab == TabMap && m.geoMap != nil {
m.geoMap.CycleRange()
m.mapListCursor = 0
}
case key.Matches(msg, m.keys.HistRange):
if m.tab == TabHistory {
cmds = append(cmds, m.cycleHistoryRange())
} else if m.tab == TabMap && m.geoMap != nil {
m.geoMap.CycleRange()
m.mapListCursor = 0
}
case key.Matches(msg, m.keys.MeshView):
if m.tab == TabFeed {
@@ -417,6 +437,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Enter):
if m.tab == TabBlog && !m.blogReading {
cmds = append(cmds, m.openBlogPost())
} else if m.tab == TabMap {
cmds = append(cmds, m.mapApplyGeoFilter())
} else {
cmds = append(cmds, m.openDetail())
}
@@ -430,18 +452,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusErr = ""
cmds = append(cmds, waitSSE(m.sse), waitConnState(m.sse), waitSSEErr(m.sse))
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
if m.tab == TabMap {
if m.mapListCursor > 0 {
m.mapListCursor--
}
m.mapSelectCursor()
} else {
if m.cursor > 0 {
m.cursor--
}
m.follow = false
}
m.follow = false
case key.Matches(msg, m.keys.Down):
max := m.listLen() - 1
if m.cursor < max {
m.cursor++
}
m.follow = false
if m.tab == TabHistory {
cmds = append(cmds, m.maybeLoadMoreHistory())
if m.tab == TabMap {
max := m.listLen() - 1
if m.mapListCursor < max {
m.mapListCursor++
}
m.mapSelectCursor()
} else {
max := m.listLen() - 1
if m.cursor < max {
m.cursor++
}
m.follow = false
if m.tab == TabHistory {
cmds = append(cmds, m.maybeLoadMoreHistory())
}
}
case key.Matches(msg, m.keys.PgUp):
switch m.tab {
@@ -467,6 +504,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.meshShouldAnimate() && m.peerMesh != nil && m.peerMesh.HasPulses() {
cmds = append(cmds, m.startMeshTick())
}
if m.tab == TabMap && m.geoMap != nil && m.geoMap.HasPulses() {
cmds = append(cmds, m.startMapTick())
}
case snapshotLoadedMsg:
snap := viz.Snapshot(msg)
@@ -480,6 +520,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statusErr = ""
m.invalidateModalCache()
m.syncMeshFromNetwork()
m.syncMapFromStats()
case operatorClaimMsg:
if msg.err != nil {
@@ -634,6 +675,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.meshTickActive = false
return m, nil
case mapTickMsg:
if m.tab == TabMap && m.geoMap != nil {
if m.geoMap.Tick() {
return m, m.mapTickCmd()
}
}
m.mapTickActive = false
return m, nil
}
return m, tea.Batch(cmds...)
@@ -678,6 +728,7 @@ func (m *Model) handleSSE(evt transport.Event) {
m.cursor = 0
}
m.syncMeshFromNetwork()
m.syncMapFromStats()
case "attack":
a := data.(viz.AttackEvent)
item := viz.FeedFromAttack(a)
@@ -693,6 +744,7 @@ func (m *Model) handleSSE(evt transport.Event) {
}
m.peerMesh.TriggerAttack(a.PeerID, a.Service, m.meshPalette())
m.sound.PlayAttack()
m.onMapGeoEvent(a.Geo, a.Service)
case "peer":
p := data.(viz.PeerEvent)
m.store.Push(viz.FeedFromPeer(p))
@@ -731,6 +783,7 @@ func (m *Model) handleSSE(evt transport.Event) {
}
m.stats = viz.BumpStatsOnBlock(m.stats, b)
m.peerMesh.TriggerBlock(m.meshPalette())
m.onMapGeoEvent(b.Geo, "BLOCK")
m.sound.PlayBlock()
case "moderation":
mod := data.(viz.ModerationEvent)
@@ -746,10 +799,12 @@ func (m *Model) handleSSE(evt transport.Event) {
// Match web: block KPI bumps come from dedicated block SSE; mesh pulse only here.
m.peerMesh.TriggerBlock(m.meshPalette())
}
m.onMapGeoEvent(mod.Geo, "MOD")
m.sound.PlayModeration()
case "stats":
s := data.(viz.Stats)
m.stats = viz.MergeAuthoritativeStats(m.stats, s, viz.MergeStatsOpts{Absolute: true})
m.syncMapFromStats()
}
}
@@ -845,6 +900,14 @@ func (m *Model) switchTab(tab ViewTab) []tea.Cmd {
if m.tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
if m.tab == TabMap {
m.syncMapFromStats()
if m.geoMap != nil && m.geoMap.HasPulses() {
cmds = append(cmds, m.startMapTick())
}
} else {
m.mapTickActive = false
}
if cmd := m.onTabChanged(); cmd != nil {
cmds = append(cmds, cmd)
}
@@ -922,6 +985,11 @@ func (m Model) listLen() int {
return len(m.peerRoster()) + len(m.visiblePeers())
case TabHistory:
return len(m.visibleHistoryItems())
case TabMap:
if m.geoMap == nil {
return 0
}
return len(m.geoMap.Ranked())
case TabBlog:
if m.blogReading {
return 1
@@ -984,6 +1052,8 @@ func (m Model) renderMain(height, width int) string {
return renderStatsView(m.theme, m.stats, m.network, width)
case TabHistory:
return m.renderHistoryView(height, listW)
case TabMap:
return m.renderMapView(height, listW)
case TabBlog:
if m.blogReading {
if m.blogLoading {
+80 -12
View File
@@ -302,7 +302,7 @@ func (m *Model) handleBlogReaderMouse(ev tea.MouseEvent) []tea.Cmd {
}
func (m Model) isListTab() bool {
if m.tab == TabStats {
if m.tab == TabStats || m.tab == TabMap {
return false
}
if m.tab == TabBlog && m.blogReading {
@@ -312,6 +312,37 @@ func (m Model) isListTab() bool {
}
func (m *Model) handleMouseWheel(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
// Map: wheel over map canvas zooms; over list scrolls regions.
if m.tab == TabMap && m.geoMap != nil {
if ly.MeshH > 0 && ev.Y >= ly.MeshTop && ev.Y < ly.MeshTop+ly.MeshH {
cam := m.geoMap.Camera()
cx := ev.X - 2
cy := ev.Y - ly.MeshTop
factor := 1.15
if ev.Button == tea.MouseButtonWheelDown {
factor = 1 / 1.15
}
cam.ZoomAt(factor, cx, cy, maxInt(8, m.width-4), maxInt(4, ly.MeshH))
m.geoMap.SetCamera(cam)
return nil
}
if ev.Y >= ly.ListTop {
switch ev.Button {
case tea.MouseButtonWheelUp:
if m.mapListCursor > 0 {
m.mapListCursor--
}
case tea.MouseButtonWheelDown:
max := m.listLen() - 1
if m.mapListCursor < max {
m.mapListCursor++
}
}
m.mapSelectCursor()
}
return nil
}
if !inMainList(ev.Y, ly) || !m.isListTab() {
return nil
}
@@ -352,17 +383,7 @@ func (m *Model) handleMouseClick(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
if tab, ok := tabAt(ev.X, ev.Y, ly, m.tab); ok {
if tab != m.tab {
m.tab = tab
m.cursor = 0
m.closeDetail()
m.closeBlogReader()
if tab == TabBlog {
cmds = append(cmds, m.requestBlogList())
}
if tab == TabHistory {
cmds = append(cmds, m.ensureHistoryTabLoaded())
}
cmds = append(cmds, m.onTabChanged())
cmds = append(cmds, m.switchTab(tab)...)
}
return cmds
}
@@ -391,6 +412,32 @@ func (m *Model) handleMouseClick(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
}
}
}
if m.tab == TabMap && m.geoMap != nil {
cellX := ev.X - 2
if cellX < 0 {
cellX = 0
}
cellY := ev.Y - ly.MeshTop
if code, ok := m.geoMap.HitTest(cellX, cellY); ok {
m.geoMap.SetSelected(code)
// Sync list cursor to selected geo.
for i, r := range m.geoMap.Ranked() {
if r.Code == code {
m.mapListCursor = i
break
}
}
}
}
return cmds
}
if m.tab == TabMap {
if idx, ok := mapListIndexAt(ev.Y, ly, m.mapListCursor, m.listLen()); ok {
m.mapListCursor = idx
m.mapSelectCursor()
return cmds
}
return cmds
}
@@ -430,6 +477,27 @@ func (m *Model) handleMouseClick(ev tea.MouseEvent, ly viewLayout) []tea.Cmd {
return cmds
}
func mapListIndexAt(y int, ly viewLayout, cursor, itemCount int) (int, bool) {
if itemCount == 0 || ly.ListH < 2 {
return 0, false
}
// List region under map: title + header take ~2 lines after status.
rel := y - ly.ListTop
if rel < 3 {
return 0, false
}
dataRows := ly.ListH - 3
if dataRows < 1 {
dataRows = 1
}
start := listScrollStart(cursor, itemCount, dataRows)
idx := start + (rel - 3)
if idx < 0 || idx >= itemCount {
return 0, false
}
return idx, true
}
func (m *Model) handleHistoryChromeClick(ev tea.MouseEvent, ly viewLayout) ([]tea.Cmd, bool) {
if ev.Y != ly.MainInnerTop {
return nil, false