// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package report import ( "bytes" "fmt" "html" "image" "image/color" "image/draw" "image/png" "math" "sort" "golang.org/x/image/font" "golang.org/x/image/font/basicfont" "golang.org/x/image/math/fixed" ) // 画布尺寸。2:1 是等距圆柱(equirectangular)投影的天然比例: // 经度 360°、纬度 180°,正好铺满矩形。 const ( mapWidth = 960 mapHeight = 480 ) // 配色。刻意用浅色:邮件模板是白底,深色地图嵌进去像个补丁。 var ( colOcean = color.RGBA{0xF1, 0xF5, 0xF9, 0xFF} colGrid = color.RGBA{0xCB, 0xD5, 0xE1, 0xFF} colGridKey = color.RGBA{0x94, 0xA3, 0xB8, 0xFF} colBubble = color.RGBA{0xEF, 0x44, 0x44, 0xFF} colRim = color.RGBA{0xB9, 0x1C, 0x1C, 0xFF} colText = color.RGBA{0x33, 0x41, 0x55, 0xFF} ) // project 把经纬度映射到画布坐标(等距圆柱投影)。 // // ⚠️ 这里不画任何海岸线或国界。原因不是偷懒:国界、台湾与南海诸岛的绘制 // 必须符合国家标准,自绘底图极易画错,而画错地图是比「图不好看」严重得多 // 的问题。需要真实底图时用腾讯地图(本包另出的 HTML 版带它)。 // 经纬网 + 气泡已经足以回答「攻击主要来自哪个半球/哪个区域」。 func project(lat, lon float64) (x, y float64) { x = (lon + 180) / 360 * mapWidth y = (90 - lat) / 180 * mapHeight return x, y } // bubbleRadius 按命中数相对最大值的平方根缩放。 // // 用 sqrt 而不是线性:几次命中和上千次命中如果按线性画,前者会小到看不见; // 面积正比于数值是气泡图的通行做法(人眼比较的是面积而不是半径)。 func bubbleRadius(hits, maxHits int64) float64 { if maxHits <= 0 { return 4 } ratio := math.Sqrt(float64(hits) / float64(maxHits)) return 3.5 + 17*ratio } // bubbles 从城市聚合里挑出可画的点,按命中数降序。 func bubbles(sum *Summary) []CityRow { pts := make([]CityRow, 0, len(sum.Cities)) for _, c := range sum.Cities { // 坐标越界(IP 库偶尔给出 0,0 或越界值)直接丢弃: // 画到几内亚湾比不画更误导。 if math.IsNaN(c.Lat) || math.IsNaN(c.Lon) || c.Lat < -90 || c.Lat > 90 || c.Lon < -180 || c.Lon > 180 { continue } if c.Lat == 0 && c.Lon == 0 { continue } pts = append(pts, c) } sort.Slice(pts, func(i, j int) bool { if pts[i].Hits != pts[j].Hits { return pts[i].Hits > pts[j].Hits } return pts[i].City < pts[j].City }) return pts } // maxCityHits 返回城市命中数的最大值(气泡缩放基准)。 func maxCityHits(pts []CityRow) int64 { var m int64 for _, p := range pts { if p.Hits > m { m = p.Hits } } return m } // graticule 经纬网:经度每 30°、纬度每 30° 一条,赤道与本初子午线加粗。 func graticule() (xs, ys []float64) { for lon := -180.0; lon <= 180; lon += 30 { xs = append(xs, lon) } for lat := -90.0; lat <= 90; lat += 30 { ys = append(ys, lat) } return xs, ys } // labelBox 已放好的标签占位框。 type labelBox struct { x0, y0, x1, y1 float64 } // labelPacker 给标签找不重叠的位置。 // // 为什么必须做:同一个都市圈的不同城市,经纬度只差 0.01°,标签会精确地压在 // 一起,两行字叠成一串谁也读不出的字符。实测出现过「AU 305」这种由 // 「AU 55」与「AU 30」叠出来的假数字 —— 这比不标更糟:读者会把它当真实数据。 // 宁可少标几个,也不能让图上出现一个读错就是错的数字。 type labelPacker struct { placed []labelBox width, height float64 } func newLabelPacker(width, height float64) *labelPacker { return &labelPacker{width: width, height: height} } // place 在气泡右侧找位置,放不下就换左侧,两侧都占则放弃。 // 返回标签框左上角坐标。 func (p *labelPacker) place(bx, by, r, textW, textH float64) (x, y float64, ok bool) { top := by - textH/2 for _, cx := range []float64{bx + r + 3, bx - r - 3 - textW} { if cx < 2 || cx+textW > p.width-2 || top < 1 || top+textH > p.height-1 { continue } box := labelBox{cx - 2, top - 1, cx + textW + 2, top + textH + 1} if p.collides(box) { continue } p.placed = append(p.placed, box) return cx, top, true } return 0, 0, false } func (p *labelPacker) collides(b labelBox) bool { for _, o := range p.placed { if b.x0 < o.x1 && o.x0 < b.x1 && b.y0 < o.y1 && o.y0 < b.y1 { return true } } return false } // 经纬度刻度的取值。只标每 60° / 30°,标满会把气泡和标签挤没。 var ( tickLons = []float64{-180, -120, -60, 0, 60, 120, 180} tickLats = []float64{-60, -30, 0, 30, 60} ) // latLabel 把纬度格式化成 30N / 30S 这样的刻度。 func latLabel(lat float64) string { switch { case lat == 0: return "0" case lat > 0: return fmt.Sprintf("%.0fN", lat) default: return fmt.Sprintf("%.0fS", -lat) } } // RenderSVG 生成内联 SVG 世界气泡图。用于 HTML 报告附件(浏览器里看)。 func RenderSVG(sum *Summary) string { pts := bubbles(sum) maxHits := maxCityHits(pts) lons, lats := graticule() var b bytes.Buffer fmt.Fprintf(&b, ``, mapWidth, mapHeight) fmt.Fprintf(&b, ``, mapWidth, mapHeight) // 经纬网 for _, lon := range lons { x, _ := project(0, lon) stroke := "#CBD5E1" w := "0.6" if lon == 0 { stroke = "#94A3B8" w = "1" } fmt.Fprintf(&b, ``, x, x, mapHeight, stroke, w) } for _, lat := range lats { _, y := project(lat, 0) stroke := "#CBD5E1" w := "0.6" if lat == 0 { stroke = "#94A3B8" w = "1" } fmt.Fprintf(&b, ``, y, mapWidth, y, stroke, w) } // 经纬度刻度:没有底图时这是读者唯一的方向参照,不能省。 for _, lon := range tickLons { x, _ := project(0, lon) if x < 12 { x = 12 } if x > mapWidth-12 { x = mapWidth - 12 } fmt.Fprintf(&b, `%s`, x, mapHeight-4, lonLabel(lon)) } for _, lat := range tickLats { _, y := project(lat, 0) fmt.Fprintf(&b, `%s`, y+3, latLabel(lat)) } // 气泡 for _, p := range pts { x, y := project(p.Lat, p.Lon) r := bubbleRadius(p.Hits, maxHits) fmt.Fprintf(&b, `%s · %s:%d 次命中(%d 个来源)`, x, y, r, html.EscapeString(p.City), html.EscapeString(p.Country), p.Hits, p.Sources) } // 标注前 8 个城市:放过大的气泡,标签不重叠才画(理由见 labelPacker)。 packer := newLabelPacker(mapWidth, mapHeight) for i, p := range pts { if i >= 8 { break } x, y := project(p.Lat, p.Lon) r := bubbleRadius(p.Hits, maxHits) label := cityLabel(p) tx, ty, ok := packer.place(x, y, r, svgTextWidth(label), 12) if !ok { continue } fmt.Fprintf(&b, `%s`, tx, ty+10, html.EscapeString(label)) } b.WriteString(``) return b.String() } // cityLabel 优先用城市名,退到省/州,最后退到国家名。 func cityLabel(p CityRow) string { if p.City != "" { return fmt.Sprintf("%s %d", p.City, p.Hits) } if p.Region != "" { return fmt.Sprintf("%s %d", p.Region, p.Hits) } return fmt.Sprintf("%s %d", p.Country, p.Hits) } // svgTextWidth 粗估一行文字在 11px 下的宽度。 // CJK 按全宽算、其余按 0.62 倍字宽算 —— 只用于判断是否会撞在一起, // 精确到像素没有意义(何况最终字体由浏览器决定)。 func svgTextWidth(s string) float64 { var w float64 for _, r := range s { if r > 0x2E80 { w += 11 } else { w += 6.8 } } return w } // lonLabel 把经度格式化成 90°E / 120°W 这样的刻度。 func lonLabel(lon float64) string { switch { case lon == 0: return "0" case lon > 0: return fmt.Sprintf("%.0fE", lon) default: return fmt.Sprintf("%.0fW", -lon) } } // RenderPNG 生成邮件正文用的位图世界气泡图。 // // 为什么要位图:邮件客户端不执行 JS,且 Gmail / Outlook 会剥离内联 SVG。 // 只有以 CID 内嵌的 PNG 能保证「打开邮件就看得到地图」。 // // 图上文字只能是 ASCII(Go 标准库里唯一的无依赖位图字体 basicfont 只有 // 7x13 的拉丁字形),所以标签用国家码 + 次数,中文说明放在邮件正文里。 func RenderPNG(sum *Summary) ([]byte, error) { img := image.NewRGBA(image.Rect(0, 0, mapWidth, mapHeight)) draw.Draw(img, img.Bounds(), &image.Uniform{colOcean}, image.Point{}, draw.Src) lons, lats := graticule() for _, lon := range lons { x, _ := project(0, lon) c := colGrid if lon == 0 { c = colGridKey } drawVLine(img, x, c) } for _, lat := range lats { _, y := project(lat, 0) c := colGrid if lat == 0 { c = colGridKey } drawHLine(img, y, c) } pts := bubbles(sum) maxHits := maxCityHits(pts) // 从小到大画:大气泡后画才能盖住小气泡,否则重要的点被长尾挡住。 for i := len(pts) - 1; i >= 0; i-- { p := pts[i] x, y := project(p.Lat, p.Lon) r := bubbleRadius(p.Hits, maxHits) fillCircle(img, x, y, r, colBubble, 0.35) strokeCircle(img, x, y, r, colRim, 0.8) } // 刻度先画:气泡后画会盖住刻度,保证气泡在视觉上优先。 for _, lon := range tickLons { x, _ := project(0, lon) s := lonLabel(lon) tx := x - basicfontWidth(s)/2 if tx < 2 { tx = 2 } if tx+basicfontWidth(s) > mapWidth-2 { tx = mapWidth - 2 - basicfontWidth(s) } drawText(img, tx, mapHeight-15, s, colGridKey, false) } for _, lat := range tickLats { _, y := project(lat, 0) drawText(img, 3, y-7, latLabel(lat), colGridKey, false) } // 前 8 个城市标国家码 + 次数,重叠的跳过(理由见 labelPacker)。 packer := newLabelPacker(mapWidth, mapHeight) for i, p := range pts { if i >= 8 { break } x, y := project(p.Lat, p.Lon) r := bubbleRadius(p.Hits, maxHits) label := fmt.Sprintf("%s %d", p.CountryCode, p.Hits) tx, ty, ok := packer.place(x, y, r, basicfontWidth(label), 13) if !ok { continue } drawText(img, tx, ty, label, colText, true) } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, err } return buf.Bytes(), nil } // basicfontWidth 7x13 位图字体的定宽推进值:每个字符正好 7px。 func basicfontWidth(s string) float64 { return float64(len(s)) * 7 } // drawText 用 7x13 位图字体写一行 ASCII 文字,(x, top) 是文本框左上角。 // // outline=true 时先描一圈白,避免文字压在气泡或网格上糊成一片。 func drawText(img *image.RGBA, x, top float64, s string, c color.RGBA, outline bool) { dot := fixed.P(int(x), int(top)+11) if outline { for _, off := range [][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} { h := &font.Drawer{ Dst: img, Src: &image.Uniform{color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}}, Face: basicfont.Face7x13, Dot: fixed.P(int(x)+off[0], int(top)+11+off[1]), } h.DrawString(s) } } d := &font.Drawer{Dst: img, Src: &image.Uniform{c}, Face: basicfont.Face7x13, Dot: dot} d.DrawString(s) } // fillCircle 画一个带 1px 抗锯齿边缘的实心圆(alpha 混合)。 func fillCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) { drawCircleRing(img, cx, cy, 0, r, c, alpha) } // strokeCircle 画一个 1px 宽的圆环。 func strokeCircle(img *image.RGBA, cx, cy, r float64, c color.RGBA, alpha float64) { drawCircleRing(img, cx, cy, r-0.5, r+0.5, c, alpha) } // drawCircleRing 填充内半径 inner 到外半径 outer 之间的环(inner=0 即实心圆)。 func drawCircleRing(img *image.RGBA, cx, cy, inner, outer float64, c color.RGBA, alpha float64) { x0 := int(math.Floor(cx - outer - 1)) x1 := int(math.Ceil(cx + outer + 1)) y0 := int(math.Floor(cy - outer - 1)) y1 := int(math.Ceil(cy + outer + 1)) if x0 < 0 { x0 = 0 } if y0 < 0 { y0 = 0 } if x1 > img.Bounds().Dx() { x1 = img.Bounds().Dx() } if y1 > img.Bounds().Dy() { y1 = img.Bounds().Dy() } for y := y0; y < y1; y++ { for x := x0; x < x1; x++ { dx := float64(x) + 0.5 - cx dy := float64(y) + 0.5 - cy d := math.Hypot(dx, dy) // 与圆环相交的覆盖率:边缘 1px 内线性过渡,起到抗锯齿作用。 cov := math.Min(outer-d, d-inner) + 0.5 if cov <= 0 { continue } if cov > 1 { cov = 1 } blendPixel(img, x, y, c, alpha*cov) } } } // drawVLine / drawHLine 画 1px 网格线(半透明,不抢气泡的视觉权重)。 func drawVLine(img *image.RGBA, x float64, c color.RGBA) { xi := int(math.Round(x)) if xi < 0 || xi >= img.Bounds().Dx() { return } for y := 0; y < img.Bounds().Dy(); y++ { blendPixel(img, xi, y, c, 0.9) } } func drawHLine(img *image.RGBA, y float64, c color.RGBA) { yi := int(math.Round(y)) if yi < 0 || yi >= img.Bounds().Dy() { return } for x := 0; x < img.Bounds().Dx(); x++ { blendPixel(img, x, yi, c, 0.9) } } // blendPixel 把颜色以 alpha 混合到目标像素上。 func blendPixel(img *image.RGBA, x, y int, c color.RGBA, alpha float64) { if alpha <= 0 { return } if alpha > 1 { alpha = 1 } i := img.PixOffset(x, y) dst := img.Pix[i : i+4 : i+4] img.Pix[i+0] = uint8(float64(c.R)*alpha + float64(dst[0])*(1-alpha)) img.Pix[i+1] = uint8(float64(c.G)*alpha + float64(dst[1])*(1-alpha)) img.Pix[i+2] = uint8(float64(c.B)*alpha + float64(dst[2])*(1-alpha)) img.Pix[i+3] = 0xFF }