This commit is contained in:
@@ -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**.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package geomap
|
||||
|
||||
import "math"
|
||||
|
||||
// Camera controls pan/zoom over an equirectangular world map.
|
||||
// Matches web viz defaults: zoom 0.45–3.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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 // 0–1 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
@@ -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
|
||||
1–6 Jump to Feed · Incidents · Peers · Stats · History · Blog
|
||||
Enter Open detail (or blog post)
|
||||
1–7 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
|
||||
`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user