owpengram-server/internal/app/files/maptile.go

183 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package files
import (
"bytes"
"image"
"image/color"
"image/png"
"math"
"go.uber.org/zap"
)
// 本文件实现 geo 消息地图缩略图upload.getWebFile / inputWebFileGeoPointLocation
// 本地占位渲染:服务端按坐标确定性合成一张「街区网格 + 定位针」风格的静态图,
// 同一 (lat,long,zoom,w,h,scale) 输入字节级可重现,保证客户端分片续传一致。
// 配置了 Mapbox 代理maptile_proxy.go时优先返回真实地图本渲染降级为故障回退。
const (
mapTileMinEdge = 16
mapTileMaxEdge = 1024
mapTileMinZoom = 13
mapTileMaxZoom = 20
mapTileMaxScale = 3
)
// GeoMapTile 返回一张 w×h逻辑像素输出按 scale 放大)的静态地图与 mime。
// 入参越界时按协议约束 clampw/h 16-1024、zoom 13-20、scale 1-3不报错。
// 配置了 Mapbox 代理时优先真实地图(落盘缓存),抓取失败回退确定性占位渲染。
func (s *Service) GeoMapTile(lat, long float64, w, h, zoom, scale int) ([]byte, string) {
w = clampInt(w, mapTileMinEdge, mapTileMaxEdge)
h = clampInt(h, mapTileMinEdge, mapTileMaxEdge)
zoom = clampInt(zoom, mapTileMinZoom, mapTileMaxZoom)
scale = clampInt(scale, 1, mapTileMaxScale)
// nil receiver 合法:占位渲染是纯函数(既有调用方/测试依赖这一点),代理仅在配置后启用。
if s != nil && s.mapTiles != nil {
if data, mime, err := s.mapTiles.tile(lat, long, w, h, zoom, scale); err == nil {
return data, mime
} else if s.log != nil {
s.log.Warn("map tile proxy failed, fallback to placeholder",
zap.Error(err), zap.Float64("lat", lat), zap.Float64("long", long), zap.Int("zoom", zoom))
}
}
pw, ph := w*scale, h*scale
img := image.NewRGBA(image.Rect(0, 0, pw, ph))
background := color.RGBA{R: 0xEB, G: 0xE7, B: 0xDE, A: 0xFF}
park := color.RGBA{R: 0xCF, G: 0xE4, B: 0xC2, A: 0xFF}
road := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
roadEdge := color.RGBA{R: 0xD9, G: 0xD3, B: 0xC7, A: 0xFF}
fillRect(img, 0, 0, pw, ph, background)
rng := newMapTileRNG(lat, long, zoom)
// 街区网格:间距与抖动由坐标种子决定,平移随经纬度连续变化,避免所有地点一张脸。
spacing := (48 + int(rng.next()%32)) * scale
offX := int(math.Abs(long*1e4)) % spacing
offY := int(math.Abs(lat*1e4)) % spacing
for x := -offX; x < pw; x += spacing {
major := ((x+offX)/spacing)%3 == int(rng.next()%3)
drawVerticalRoad(img, x+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge)
}
for y := -offY; y < ph; y += spacing {
major := ((y+offY)/spacing)%3 == int(rng.next()%3)
drawHorizontalRoad(img, y+int(rng.next()%uint64(spacing/3)), pw, ph, scale, major, road, roadEdge)
}
// 两块「绿地」:取网格内随机街块,铺底色之上、道路之下的视觉层级太复杂,
// 这里直接半覆盖即可(占位图不追求制图精度)。
for i := 0; i < 2; i++ {
bx := int(rng.next() % uint64(pw))
by := int(rng.next() % uint64(ph))
bw := (spacing * 3) / 4
fillRect(img, bx, by, minInt(bx+bw, pw), minInt(by+bw, ph), park)
}
drawCenterPin(img, pw, ph, scale)
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes(), "image/png"
}
// mapTileRNG 是确定性 xorshift64种子来自量化坐标与 zoom。
type mapTileRNG struct{ state uint64 }
func newMapTileRNG(lat, long float64, zoom int) *mapTileRNG {
seed := uint64(int64(lat*1e5))*1000003 ^ uint64(int64(long*1e5))*998244353 ^ uint64(zoom)*0x9E3779B97F4A7C15
if seed == 0 {
seed = 0x9E3779B97F4A7C15
}
return &mapTileRNG{state: seed}
}
func (r *mapTileRNG) next() uint64 {
r.state ^= r.state << 13
r.state ^= r.state >> 7
r.state ^= r.state << 17
return r.state
}
func fillRect(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
bounds := img.Bounds()
x0, y0 = maxInt(x0, bounds.Min.X), maxInt(y0, bounds.Min.Y)
x1, y1 = minInt(x1, bounds.Max.X), minInt(y1, bounds.Max.Y)
for y := y0; y < y1; y++ {
for x := x0; x < x1; x++ {
img.SetRGBA(x, y, c)
}
}
}
func drawVerticalRoad(img *image.RGBA, x, pw, ph, scale int, major bool, road, edge color.RGBA) {
width := 2 * scale
if major {
width = 4 * scale
}
fillRect(img, x-width/2-1, 0, x+width/2+1, ph, edge)
fillRect(img, x-width/2, 0, x+width/2, ph, road)
}
func drawHorizontalRoad(img *image.RGBA, y, pw, ph, scale int, major bool, road, edge color.RGBA) {
width := 2 * scale
if major {
width = 4 * scale
}
fillRect(img, 0, y-width/2-1, pw, y+width/2+1, edge)
fillRect(img, 0, y-width/2, pw, y+width/2, road)
}
// drawCenterPin 在图中心画红色定位针(圆头 + 下尖三角 + 白色内点),中心即坐标点。
func drawCenterPin(img *image.RGBA, pw, ph, scale int) {
pin := color.RGBA{R: 0xE5, G: 0x39, B: 0x35, A: 0xFF}
pinDark := color.RGBA{R: 0xB7, G: 0x1C, B: 0x1C, A: 0xFF}
white := color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}
cx, cy := pw/2, ph/2
headR := 9 * scale
headCY := cy - 14*scale
// 尖角三角形:从圆头两侧收敛到坐标点。
for y := headCY; y <= cy; y++ {
t := float64(y-headCY) / float64(cy-headCY)
half := int(float64(headR) * (1 - t) * 0.82)
for x := cx - half; x <= cx+half; x++ {
img.SetRGBA(x, y, pin)
}
}
// 圆头(带一圈深色描边)。
for dy := -headR - scale; dy <= headR+scale; dy++ {
for dx := -headR - scale; dx <= headR+scale; dx++ {
d2 := dx*dx + dy*dy
switch {
case d2 <= headR*headR:
img.SetRGBA(cx+dx, headCY+dy, pin)
case d2 <= (headR+scale)*(headR+scale):
img.SetRGBA(cx+dx, headCY+dy, pinDark)
}
}
}
innerR := 3 * scale
for dy := -innerR; dy <= innerR; dy++ {
for dx := -innerR; dx <= innerR; dx++ {
if dx*dx+dy*dy <= innerR*innerR {
img.SetRGBA(cx+dx, headCY+dy, white)
}
}
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}