first release
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
// Package fog 解析与生成世界迷雾(Fog of World)的快照数据。
|
||||
//
|
||||
// 快照是一个 zip,其中 Model/*/ 下的每个文件是一块「瓦片」:全球 512x512 块瓦片,
|
||||
// 每块瓦片 128x128 个块,每块是 64x64 的位图,一个位对应全球 2^22 x 2^22 网格里
|
||||
// 约 9.55m*cos(纬度) 宽的一格。网格走网页墨卡托,经纬度即 WGS-84(已实测确认)。
|
||||
// 文件内容 zlib 压缩:头 32768 字节是 16384 个 uint16(LE),值 = 该块在块体里的
|
||||
// 序号(1 基,0 表示无数据);块体每块 512 字节位图 + 3 字节附加(地区码与置位校验)。
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MapTiles 全球瓦片数(每边)。
|
||||
MapTiles = 512
|
||||
// TileBlocks 每块瓦片的块数(每边)。
|
||||
TileBlocks = 128
|
||||
// BlockCells 每块的格子数(每边)。
|
||||
BlockCells = 64
|
||||
// WorldCells 全球格子数(每边),2^22。
|
||||
WorldCells = MapTiles * TileBlocks * BlockCells
|
||||
|
||||
bitmapSize = BlockCells * BlockCells / 8 // 512
|
||||
extraSize = 3
|
||||
blockSize = bitmapSize + extraSize // 515
|
||||
headerLen = TileBlocks * TileBlocks
|
||||
headerSize = headerLen * 2 // 32768
|
||||
|
||||
mask1 = "olhwjsktri"
|
||||
mask2 = "eizxdwknmo"
|
||||
)
|
||||
|
||||
// Block 是一片 64x64 的已探索位图。GX/GY 为全局块坐标(0..65535),
|
||||
// Bitmap 固定 512 字节,行优先、每行 8 字节、字节内高位在前。
|
||||
type Block struct {
|
||||
GX, GY int
|
||||
Bitmap []byte
|
||||
}
|
||||
|
||||
// CellCount 返回位图里的置位数(已探索格子数)。
|
||||
func (b *Block) CellCount() int {
|
||||
n := 0
|
||||
for _, v := range b.Bitmap {
|
||||
n += int(popcount[v])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var popcount = func() [256]byte {
|
||||
var t [256]byte
|
||||
for i := range t {
|
||||
t[i] = byte(strings.Count(strconv.FormatUint(uint64(i), 2), "1"))
|
||||
}
|
||||
return t
|
||||
}()
|
||||
|
||||
// tileIDFromName 从文件名还原瓦片编号,并校验 md5 前缀与 M2 尾缀。
|
||||
func tileIDFromName(name string) (int, bool) {
|
||||
if len(name) < 7 || len(name[4:]) < 3 {
|
||||
return 0, false
|
||||
}
|
||||
digits := make([]byte, 0, len(name))
|
||||
for _, c := range name[4 : len(name)-2] {
|
||||
v := strings.IndexRune(mask1, c)
|
||||
if v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
digits = append(digits, byte('0'+v))
|
||||
}
|
||||
id, err := strconv.Atoi(string(digits))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
s := strconv.Itoa(id)
|
||||
if fmt.Sprintf("%x", md5.Sum([]byte(s)))[:4] != name[:4] {
|
||||
return 0, false
|
||||
}
|
||||
tail := name[len(name)-2:]
|
||||
for i := 0; i < 2; i++ {
|
||||
if int(s[len(s)-2+i]-'0') != strings.IndexByte(mask2, tail[i]) {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// tileName 把瓦片编号编码回文件名。
|
||||
func tileName(id int) string {
|
||||
s := strconv.Itoa(id)
|
||||
sum := fmt.Sprintf("%x", md5.Sum([]byte(s)))
|
||||
var b strings.Builder
|
||||
b.WriteString(sum[:4])
|
||||
for i := 0; i < len(s); i++ {
|
||||
b.WriteByte(mask1[s[i]-'0'])
|
||||
}
|
||||
for i := len(s) - 2; i < len(s); i++ {
|
||||
b.WriteByte(mask2[s[i]-'0'])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Decode 读取 .fwss(或任何包含迷雾瓦片文件的 zip),返回去重后的块与格子总数。
|
||||
// 兼容 Model/*/、Sync/、根目录等布局:凡文件名能通过瓦片校验的都算。
|
||||
func Decode(data []byte) ([]Block, int, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("打开迷雾快照: %w", err)
|
||||
}
|
||||
merged := map[[2]int][]byte{}
|
||||
cells := 0
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
id, ok := tileIDFromName(path.Base(f.Name))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
raw, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取 %s: %w", f.Name, err)
|
||||
}
|
||||
zdata, err := zlibDecompress(raw)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("解压 %s: %w", f.Name, err)
|
||||
}
|
||||
if len(zdata) < headerSize {
|
||||
continue
|
||||
}
|
||||
tx, ty := id%MapTiles, id/MapTiles
|
||||
for i := 0; i < headerLen; i++ {
|
||||
idx := int(binary.LittleEndian.Uint16(zdata[2*i:]))
|
||||
if idx == 0 {
|
||||
continue
|
||||
}
|
||||
off := headerSize + (idx-1)*blockSize
|
||||
if off+blockSize > len(zdata) {
|
||||
return nil, 0, fmt.Errorf("%s: 块体越界", f.Name)
|
||||
}
|
||||
bm := make([]byte, bitmapSize)
|
||||
copy(bm, zdata[off:off+bitmapSize])
|
||||
key := [2]int{tx*TileBlocks + i%TileBlocks, ty*TileBlocks + i/TileBlocks}
|
||||
if old, dup := merged[key]; dup {
|
||||
for j := range bm {
|
||||
old[j] |= bm[j]
|
||||
}
|
||||
continue
|
||||
}
|
||||
merged[key] = bm
|
||||
cells += popcountSum(bm)
|
||||
}
|
||||
}
|
||||
blocks := make([]Block, 0, len(merged))
|
||||
for k, bm := range merged {
|
||||
blocks = append(blocks, Block{GX: k[0], GY: k[1], Bitmap: bm})
|
||||
}
|
||||
sortBlocks(blocks)
|
||||
return blocks, cells, nil
|
||||
}
|
||||
|
||||
func popcountSum(bm []byte) int {
|
||||
n := 0
|
||||
for _, v := range bm {
|
||||
n += int(popcount[v])
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sortBlocks(bs []Block) {
|
||||
for i := 1; i < len(bs); i++ {
|
||||
for j := i; j > 0 && (bs[j-1].GY > bs[j].GY || (bs[j-1].GY == bs[j].GY && bs[j-1].GX > bs[j].GX)); j-- {
|
||||
bs[j-1], bs[j] = bs[j], bs[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func zlibDecompress(raw []byte) ([]byte, error) {
|
||||
zr, err := zlib.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zr.Close()
|
||||
return io.ReadAll(zr)
|
||||
}
|
||||
|
||||
// Encode 把块集还原成 .fwss 结构(Model/*/ 由块重建),可用于导出与再导入。
|
||||
// layers 是留存的非已探索层原始文件(Model/~ 与 Model/#),原样写回——
|
||||
// 它们不参与渲染,但少了它们 fog of world 那边看到的就是残缺快照。
|
||||
func Encode(blocks []Block, layers map[string][]byte) ([]byte, error) {
|
||||
byTile := map[[2]int][]Block{}
|
||||
for _, b := range blocks {
|
||||
tx, ty := b.GX/TileBlocks, b.GY/TileBlocks
|
||||
byTile[[2]int{tx, ty}] = append(byTile[[2]int{tx, ty}], b)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for t, list := range byTile {
|
||||
sortByLinear(list)
|
||||
header := make([]byte, headerSize)
|
||||
body := make([]byte, 0, len(list)*blockSize)
|
||||
for k, b := range list {
|
||||
i := (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks
|
||||
binary.LittleEndian.PutUint16(header[2*i:], uint16(k+1))
|
||||
body = append(body, b.Bitmap...)
|
||||
body = append(body, extraBytes(b)...)
|
||||
}
|
||||
var raw bytes.Buffer
|
||||
zc := zlib.NewWriter(&raw)
|
||||
if _, err := zc.Write(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := zc.Write(body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := zc.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := t[0] + t[1]*MapTiles
|
||||
name := "Model/*/" + tileName(id)
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(raw.Bytes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 其它层直接写原始字节,不做二次压缩,免得多一层格式风险
|
||||
for p, data := range layers {
|
||||
w, err := zw.Create(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func sortByLinear(list []Block) {
|
||||
lin := func(b Block) int { return (b.GY%TileBlocks)*TileBlocks + b.GX%TileBlocks }
|
||||
for i := 1; i < len(list); i++ {
|
||||
for j := i; j > 0 && lin(list[j-1]) > lin(list[j]); j-- {
|
||||
list[j-1], list[j] = list[j], list[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extraBytes 生成块尾那 3 字节。真机存的是一个**大端 24 位数**,值为 置位数*2+1:
|
||||
// 实测真机包 8701/8701 个块精确满足(如 118 格 -> 0x0000ED = 237 = 118*2+1)。
|
||||
// 一个 64x64 块最多 4096 格,该值 <= 0x2001,所以最高字节在数学上恒为 0。
|
||||
// (旧实现写的是 0x10 / 0x80|…,纯属臆造,会让导出包里每个块的元信息都与真机对不上。)
|
||||
func extraBytes(b Block) []byte {
|
||||
sum := b.CellCount()*2 + 1
|
||||
return []byte{0x00, byte(sum >> 8), byte(sum & 0xFF)}
|
||||
}
|
||||
|
||||
// CellSideM 是格子在赤道的边长(米)。宽随纬度 cos 收缩,高恒定(网页墨卡托)。
|
||||
const CellSideM = 9.55462861
|
||||
|
||||
// CellOf 把 WGS-84 经纬度投到全球格坐标(网页墨卡托,2^22 网格)。
|
||||
func CellOf(lat, lon float64) (cx, cy int) {
|
||||
cx = int((lon + 180.0) / 360.0 * WorldCells)
|
||||
rad := lat * math.Pi / 180
|
||||
cy = int((math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells)
|
||||
return
|
||||
}
|
||||
|
||||
// AreaKm2 估算块集覆盖的实地面积:每格面积按块中心纬度做 cos 修正。
|
||||
func AreaKm2(blocks []Block) float64 {
|
||||
var m2 float64
|
||||
for _, b := range blocks {
|
||||
m2 += float64(b.CellCount()) * cellAreaM2(b.GX, b.GY)
|
||||
}
|
||||
return m2 / 1e6
|
||||
}
|
||||
|
||||
// cellAreaM2 是某块内单格的面积(m²):一格宽 CellSideM、随纬度乘 cos 收缩,高恒定。
|
||||
func cellAreaM2(gx, gy int) float64 {
|
||||
_, lat := cellLonLat(gx*BlockCells+BlockCells/2, gy*BlockCells+BlockCells/2)
|
||||
return CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNativeSnapshotRoundtrip 守住「真机快照解码再编码后内容不变」。
|
||||
// 需要环境变量 FOG_SRC 指向真机导出的 .fwss;未设置时跳过。
|
||||
//
|
||||
// 重点守三件事:
|
||||
// 1. Model/* 的每块逐字节一致(512 字节位图 + 尾 3 字节元信息)。尾 3 字节曾按臆造的
|
||||
// 0x10/0x80 生成,实测 8701/8701 个块与真机全不符——它其实是一个大端 24 位数,
|
||||
// 值为置位数*2+1;
|
||||
// 2. Model/* 的条目集合与文件名不变(名字是 md5 前缀 + 掩码数字,重建时必须能原样还原);
|
||||
// 3. Model/~ 与 Model/# 两层原样带出,条目名与内容一个不差——少了它们快照就残缺 55%。
|
||||
//
|
||||
// 比对按「格」而不是按块体槽位:槽位序号只是头里的指针,顺序允许不同,指向同一张位图即可。
|
||||
func TestNativeSnapshotRoundtrip(t *testing.T) {
|
||||
src := os.Getenv("FOG_SRC")
|
||||
if src == "" {
|
||||
t.Skip("设置 FOG_SRC 后运行迷雾快照往返检查")
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", src, err)
|
||||
}
|
||||
|
||||
blocks, cells, err := Decode(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解码: %v", err)
|
||||
}
|
||||
layers, err := RawLayers(data)
|
||||
if err != nil {
|
||||
t.Fatalf("读取其它层: %v", err)
|
||||
}
|
||||
out, err := Encode(blocks, layers)
|
||||
if err != nil {
|
||||
t.Fatalf("编码: %v", err)
|
||||
}
|
||||
|
||||
orig, err := unzipAll(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解开原始包: %v", err)
|
||||
}
|
||||
got, err := unzipAll(out)
|
||||
if err != nil {
|
||||
t.Fatalf("解开导出包: %v", err)
|
||||
}
|
||||
|
||||
if len(orig) != len(got) {
|
||||
t.Errorf("条目数不一致: 原始 %d vs 导出 %d", len(orig), len(got))
|
||||
}
|
||||
for name := range orig {
|
||||
if _, ok := got[name]; !ok {
|
||||
t.Errorf("导出包缺少条目 %s", name)
|
||||
}
|
||||
}
|
||||
for name := range got {
|
||||
if _, ok := orig[name]; !ok {
|
||||
t.Errorf("导出包多出条目 %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
checked := 0
|
||||
for name, a := range orig {
|
||||
if !isModelStar(name) {
|
||||
continue
|
||||
}
|
||||
b, ok := got[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pa, err := zlibDecompress(a)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 原始载荷解压失败: %v", name, err)
|
||||
}
|
||||
pb, err := zlibDecompress(b)
|
||||
if err != nil {
|
||||
t.Fatalf("%s 导出载荷解压失败: %v", name, err)
|
||||
}
|
||||
if len(pa) != len(pb) {
|
||||
t.Errorf("%s: 载荷长度 %d vs %d", name, len(pa), len(pb))
|
||||
continue
|
||||
}
|
||||
for cell := 0; cell < headerLen; cell++ {
|
||||
ba, bb := slotAt(pa, cell), slotAt(pb, cell)
|
||||
if (ba == nil) != (bb == nil) {
|
||||
t.Errorf("%s: 格 %d 一边有块一边没有", name, cell)
|
||||
continue
|
||||
}
|
||||
if ba == nil {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(ba[:bitmapSize], bb[:bitmapSize]) {
|
||||
t.Errorf("%s: 格 %d 的块位图不一致", name, cell)
|
||||
}
|
||||
if !bytes.Equal(ba[bitmapSize:], bb[bitmapSize:]) {
|
||||
t.Errorf("%s: 格 %d 的块元信息不一致: 原始 %x vs 导出 %x(置位数 %d)",
|
||||
name, cell, ba[bitmapSize:], bb[bitmapSize:], popcountSum(ba[:bitmapSize]))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Error("没有比对到任何块,样本可能不对")
|
||||
}
|
||||
|
||||
// 其它层:条目内容(zlib 流本身)逐字节一致
|
||||
for name, v := range layers {
|
||||
if !bytes.Equal(got[name], v) {
|
||||
t.Errorf("其它层 %s 未原样带出", name)
|
||||
}
|
||||
}
|
||||
t.Logf("已核对 %d 个块、%d 个其它层条目(%d 格)", checked, len(layers), cells)
|
||||
}
|
||||
|
||||
// TestExtraBytesEncoding 钉住块尾 3 字节的编码:大端 24 位数,值 = 置位数*2+1,最高字节恒为 0。
|
||||
func TestExtraBytesEncoding(t *testing.T) {
|
||||
bm := make([]byte, bitmapSize)
|
||||
for _, c := range []struct{ bit, cells int }{{0, 1}, {7, 2}, {8, 3}, {511, 4}} {
|
||||
bm[c.bit/8] |= 0x80 >> (c.bit % 8)
|
||||
if got := extraBytes(Block{Bitmap: bm}); got[0] != 0 || int(got[1])<<8|int(got[2]) != c.cells*2+1 {
|
||||
t.Errorf("置位数 %d 时得到 %x", c.cells, got)
|
||||
}
|
||||
}
|
||||
// 满块 4096 格:值 8193 = 0x2001,最高字节仍应为 0
|
||||
full := make([]byte, bitmapSize)
|
||||
for i := range full {
|
||||
full[i] = 0xFF
|
||||
}
|
||||
if got := extraBytes(Block{Bitmap: full}); !bytes.Equal(got, []byte{0x00, 0x20, 0x01}) {
|
||||
t.Errorf("满块应为 002001,实际 %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func isModelStar(name string) bool {
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
rest := strings.TrimPrefix(name, "Model/")
|
||||
return rest != name && strings.HasPrefix(rest, "*/")
|
||||
}
|
||||
|
||||
// unzipAll 读出包内全部条目的内容——那是 zlib 流本身,要 zlibDecompress 才是载荷。
|
||||
func unzipAll(data []byte) (map[string][]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))] = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// slotAt 取出某个格子所指向的块(头里存的是 1 基槽位序号,0 表示该格没有数据)。
|
||||
func slotAt(payload []byte, cell int) []byte {
|
||||
idx := int(binary.LittleEndian.Uint16(payload[2*cell:]))
|
||||
if idx == 0 {
|
||||
return nil
|
||||
}
|
||||
off := headerSize + (idx-1)*blockSize
|
||||
if off+blockSize > len(payload) {
|
||||
return nil
|
||||
}
|
||||
return payload[off : off+blockSize]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// layerMaxBytes 是单个留存条目的解压上限(实测最大约 250KB)。
|
||||
const layerMaxBytes = 8 << 20
|
||||
|
||||
// RawLayers 取出快照里「不属于已探索层」的条目(内容原样,条目名也原样)。
|
||||
//
|
||||
// fog of world 的快照里有三类瓦片:
|
||||
//
|
||||
// Model/* 已探索层:128×128 块,每块 512 字节位图 + 3 字节元信息(本包解析的就是它)
|
||||
// Model/~ 另一层位图:每块 512 字节,没有那 3 字节
|
||||
// Model/# 只有元信息:每块 3 字节
|
||||
//
|
||||
// 后两类我们不解析(站点只用已探索层来渲染),但**必须原样留住**——
|
||||
// 否则重新导出的快照会丢掉这 55% 的内容,fog of world 那边看到的就是残缺数据。
|
||||
//
|
||||
// 留存范围是「快照里的全部条目,除去已探索层与被 Decode 解析掉的那些瓦片」:
|
||||
// Decode 认的是「文件名能通过瓦片校验」,不认目录,所以判断也要按文件名来,
|
||||
// 否则同一个文件会被解析一遍、又原样写一遍,包里出现两份。
|
||||
func RawLayers(data []byte) (map[string][]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
p := path.Clean(strings.ReplaceAll(f.Name, "\\", "/"))
|
||||
// Model/* 之下就是已探索层,由 fog_blocks 重建
|
||||
if rest := strings.TrimPrefix(p, "Model/"); rest != p {
|
||||
if i := strings.Index(rest, "/"); i > 0 && rest[:i] == "*" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 文件名能通过瓦片校验的会被 Decode 解析成块,写回时也走 Model/*,这里不重复留存
|
||||
if _, ok := tileIDFromName(path.Base(p)); ok {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 留存的条目不解析、也不该被截断:超限就报错,别把半份数据悄悄带进导出包
|
||||
raw, err := io.ReadAll(io.LimitReader(rc, layerMaxBytes+1))
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > layerMaxBytes {
|
||||
return nil, fmt.Errorf("条目 %s 超过 %d 字节,疑似异常快照", p, layerMaxBytes)
|
||||
}
|
||||
out[p] = raw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
)
|
||||
|
||||
// Mask 是一块「对访客隐藏」的圆形区域:中心(**GCJ-02**,与 places 同系)+ 半径(米)。
|
||||
//
|
||||
// 迷雾网格是 WGS-84、底图可能是 GCJ-02,所以判定前必须把中心换算到网格坐标,
|
||||
// 与渲染瓦片时对 GCJ 底图做的纠偏是同一套变换——不然遮罩会整体偏出几百米。
|
||||
type Mask struct {
|
||||
ID int64
|
||||
Name string
|
||||
Lat float64
|
||||
Lon float64
|
||||
RadiusM float64
|
||||
}
|
||||
|
||||
// maskGrid 是遮罩在基网格里的预计算形式。
|
||||
//
|
||||
// 网格的纵向边长恒为 CellSideM、横向边长随纬度乘 cos(φ) 收缩,
|
||||
// 所以「实地上的圆」在网格里是**椭圆**(横向更宽)。半径换算错这一步,
|
||||
// 高纬度地区会明显偏窄。
|
||||
type maskGrid struct {
|
||||
m Mask
|
||||
gx, gy float64 // 圆心(格坐标,浮点)
|
||||
rx, ry float64 // 横向 / 纵向半径(格)
|
||||
x0, x1 int // 包围盒(格),用于快速排除
|
||||
y0, y1 int
|
||||
}
|
||||
|
||||
func newMaskGrid(m Mask) maskGrid {
|
||||
wlat, wlon := geo.GCJ02ToWGS84(m.Lat, m.Lon)
|
||||
gx, gy := mercX(wlon), mercY(wlat)
|
||||
cos := math.Cos(wlat * math.Pi / 180)
|
||||
if cos < 1e-6 {
|
||||
cos = 1e-6
|
||||
}
|
||||
ry := m.RadiusM / CellSideM
|
||||
rx := ry / cos
|
||||
g := maskGrid{m: m, gx: gx, gy: gy, rx: rx, ry: ry}
|
||||
g.x0 = int(math.Floor(gx-rx)) - 1
|
||||
g.x1 = int(math.Ceil(gx+rx)) + 1
|
||||
g.y0 = int(math.Floor(gy-ry)) - 1
|
||||
g.y1 = int(math.Ceil(gy+ry)) + 1
|
||||
return g
|
||||
}
|
||||
|
||||
func (g maskGrid) hitCell(cx, cy int) bool {
|
||||
if cx < g.x0 || cx > g.x1 || cy < g.y0 || cy > g.y1 {
|
||||
return false
|
||||
}
|
||||
dx := (float64(cx) + 0.5 - g.gx) / g.rx
|
||||
dy := (float64(cy) + 0.5 - g.gy) / g.ry
|
||||
return dx*dx+dy*dy <= 1
|
||||
}
|
||||
|
||||
// MaskSet 是一组遮罩的判定集合。零值/nil 表示不遮任何东西。
|
||||
type MaskSet struct {
|
||||
grids []maskGrid
|
||||
}
|
||||
|
||||
// NewMaskSet 预计算一组遮罩;传空切片会返回 nil。
|
||||
func NewMaskSet(masks []Mask) *MaskSet {
|
||||
if len(masks) == 0 {
|
||||
return nil
|
||||
}
|
||||
s := &MaskSet{grids: make([]maskGrid, 0, len(masks))}
|
||||
for _, m := range masks {
|
||||
if m.RadiusM <= 0 {
|
||||
continue
|
||||
}
|
||||
s.grids = append(s.grids, newMaskGrid(m))
|
||||
}
|
||||
if len(s.grids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Empty 报告集合里有没有遮罩(nil 安全)。
|
||||
func (s *MaskSet) Empty() bool { return s == nil || len(s.grids) == 0 }
|
||||
|
||||
// Hit 报告某个全球格是否被遮罩盖住。nil 安全,未命中时只花一次包围盒比较。
|
||||
func (s *MaskSet) Hit(cx, cy int) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
for _, g := range s.grids {
|
||||
if g.hitCell(cx, cy) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HitLatLon 按经纬度判定(同样传 GCJ-02),用于地点级的过滤。
|
||||
func (s *MaskSet) HitLatLon(lat, lon float64) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
wlat, wlon := geo.GCJ02ToWGS84(lat, lon)
|
||||
return s.Hit(CellOf(wlat, wlon))
|
||||
}
|
||||
|
||||
// maskedStats 统计被遮罩盖住的置位格数与面积(m²),用于从「已探索」的总量里扣掉。
|
||||
//
|
||||
// 三个要点:
|
||||
// - 逐格判一次 masks.Hit,而不是按遮罩逐个累加:遮罩可以互相重叠,按遮罩累加会把
|
||||
// 重叠区域的格子算好几遍(格数扣多,和面积也会对不上);
|
||||
// - 只遍历遮罩包围盒覆盖到的块(块坐标去重),不扫全库;
|
||||
// - 面积用 cellAreaM2,与 AreaKm2 同一套算法。
|
||||
func maskedStats(blocks map[[2]int][]byte, masks *MaskSet) (cells int, areaM2 float64) {
|
||||
if masks.Empty() {
|
||||
return 0, 0
|
||||
}
|
||||
seen := make(map[[2]int]bool)
|
||||
for _, g := range masks.grids {
|
||||
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
|
||||
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
|
||||
key := [2]int{bx, by}
|
||||
if seen[key] {
|
||||
continue // 两块遮罩可能盖到同一块,不能算两遍
|
||||
}
|
||||
seen[key] = true
|
||||
bm, ok := blocks[key]
|
||||
if !ok || len(bm) != bitmapSize {
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
line := bm[row*8 : row*8+8]
|
||||
for colB, v := range line {
|
||||
if v == 0 {
|
||||
continue
|
||||
}
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
if v&(0x80>>bit) == 0 {
|
||||
continue
|
||||
}
|
||||
if masks.Hit(bx*BlockCells+colB*8+bit, by*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(bx, by)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells, areaM2
|
||||
}
|
||||
|
||||
// MaskedStats 在读锁内统计被遮罩盖住的部分。调用方本来拿不到块表(它不导出),
|
||||
// 而走 All() 会为一次统计复制并排序整张块表(上万块),所以这里直接在索引上算。
|
||||
func (ix *Index) MaskedStats(masks *MaskSet) (cells int, areaM2 float64) {
|
||||
if masks.Empty() {
|
||||
return 0, 0
|
||||
}
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return maskedStats(ix.blocks, masks)
|
||||
}
|
||||
|
||||
// MaskFingerprint 返回一组遮罩的短指纹,供前端把它拼进迷雾瓦片的 URL。
|
||||
//
|
||||
// 遮罩不在 fogIndex 里(每次请求都实时读库),所以服务端改完立刻生效;
|
||||
// 但访客的瓦片是允许公共缓存的,URL 不变的话浏览器 / CDN 会继续拿缓存里那份
|
||||
// (最长 1 小时)——设完遮罩看不到变化,很容易被当成「遮罩不生效」。
|
||||
// 指纹变了 URL 就变,缓存自然失效。
|
||||
func MaskFingerprint(masks []Mask) string {
|
||||
if len(masks) == 0 {
|
||||
return "0"
|
||||
}
|
||||
h := fnv.New64a()
|
||||
for _, m := range masks {
|
||||
fmt.Fprintf(h, "%d|%.6f|%.6f|%.1f|", m.ID, m.Lat, m.Lon, m.RadiusM)
|
||||
}
|
||||
return strconv.FormatUint(h.Sum64(), 16)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// allOnesBlock 造一块位图全置位的块(每格都已探索)。
|
||||
func allOnesBlock(gx, gy int) Block {
|
||||
bm := make([]byte, BlockCells*BlockCells/8)
|
||||
for i := range bm {
|
||||
bm[i] = 0xFF
|
||||
}
|
||||
return Block{GX: gx, GY: gy, Bitmap: bm}
|
||||
}
|
||||
|
||||
// blocksAround 造出覆盖这些遮罩包围盒的全部块。
|
||||
func blocksAround(masks ...Mask) []Block {
|
||||
var out []Block
|
||||
seen := map[[2]int]bool{}
|
||||
for _, m := range masks {
|
||||
g := newMaskGrid(m)
|
||||
for bx := g.x0 / BlockCells; bx <= g.x1/BlockCells; bx++ {
|
||||
for by := g.y0 / BlockCells; by <= g.y1/BlockCells; by++ {
|
||||
if seen[[2]int{bx, by}] {
|
||||
continue
|
||||
}
|
||||
seen[[2]int{bx, by}] = true
|
||||
out = append(out, allOnesBlock(bx, by))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMaskedCellsUnion 遮罩重叠时,被遮住的格子只能算一次。
|
||||
// 曾经的实现是「按遮罩逐个累加命中格数」,重叠区域会被统计两三遍,
|
||||
// 于是覆盖率里扣掉的格数偏大、与面积(另一套逐格算法)对不上。
|
||||
func TestMaskedCellsUnion(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
|
||||
|
||||
onlyA, onlyB := NewMaskSet([]Mask{a}), NewMaskSet([]Mask{b})
|
||||
both := NewMaskSet([]Mask{a, b})
|
||||
if onlyA == nil || onlyB == nil || both == nil {
|
||||
t.Fatal("遮罩集合不应为空")
|
||||
}
|
||||
|
||||
blocks := blocksAround(a, b)
|
||||
var want int
|
||||
for _, blk := range blocks {
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if both.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
want++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用例前提:两块遮罩真的有重叠,否则测不出重复计数
|
||||
if sum := countMasked(blocks, onlyA) + countMasked(blocks, onlyB); sum <= want {
|
||||
t.Fatalf("两块遮罩没有重叠(单独 %d + %d,并集 %d),用例无效", sum, want, want)
|
||||
}
|
||||
|
||||
if got := countMasked(blocks, both); got != want {
|
||||
t.Errorf("重叠遮罩下 MaskedCells = %d,应为并集 %d(偏大即重复计数)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskedAreaMatchesCells 面积与格数必须出自同一个集合:
|
||||
// 扣掉的面积换算成格数后要与统计出的格数相等。
|
||||
func TestMaskedAreaMatchesCells(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 2000}
|
||||
set := NewMaskSet([]Mask{a, b})
|
||||
blocks := blocksAround(a, b)
|
||||
|
||||
// 逐块按块中心纬度算格子面积(与 AreaKm2Excluding 内同一套算法)
|
||||
var lostM2 float64
|
||||
for _, blk := range blocks {
|
||||
_, lat := cellLonLat(blk.GX*BlockCells+BlockCells/2, blk.GY*BlockCells+BlockCells/2)
|
||||
cell := CellSideM * math.Cos(lat*math.Pi/180) * CellSideM
|
||||
n := 0
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
lostM2 += float64(n) * cell
|
||||
}
|
||||
|
||||
byKey := map[[2]int][]byte{}
|
||||
for _, blk := range blocks {
|
||||
byKey[[2]int{blk.GX, blk.GY}] = blk.Bitmap
|
||||
}
|
||||
gotCells, gotM2 := maskedStats(byKey, set)
|
||||
if gotCells != countMasked(blocks, set) {
|
||||
t.Errorf("遮罩格数 %d,暴力统计 %d", gotCells, countMasked(blocks, set))
|
||||
}
|
||||
if diff := math.Abs(gotM2 - lostM2); diff > 1e-9 {
|
||||
t.Errorf("遮罩面积 %.6f m²,按格数算应为 %.6f m²(差 %.3f)", gotM2, lostM2, diff)
|
||||
}
|
||||
}
|
||||
|
||||
// countMasked 暴力数一遍被遮住的格子(测试基准)。
|
||||
func countMasked(blocks []Block, set *MaskSet) int {
|
||||
n := 0
|
||||
for _, blk := range blocks {
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
for col := 0; col < BlockCells; col++ {
|
||||
if set.Hit(blk.GX*BlockCells+col, blk.GY*BlockCells+row) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestMaskFingerprint 指纹要能反映遮罩的任何改动,否则访客的瓦片缓存不会失效。
|
||||
func TestMaskFingerprint(t *testing.T) {
|
||||
a := Mask{ID: 1, Lat: 24.70, Lon: 108.03, RadiusM: 2000}
|
||||
b := Mask{ID: 2, Lat: 24.705, Lon: 108.035, RadiusM: 1500}
|
||||
|
||||
if MaskFingerprint(nil) != "0" {
|
||||
t.Error("空遮罩的指纹应为 0")
|
||||
}
|
||||
base := MaskFingerprint([]Mask{a})
|
||||
cases := map[string][]Mask{
|
||||
"多了一块": {a, b},
|
||||
"半径改了": {{ID: 1, Lat: a.Lat, Lon: a.Lon, RadiusM: 3000}},
|
||||
"位置挪了": {{ID: 1, Lat: a.Lat + 0.001, Lon: a.Lon, RadiusM: a.RadiusM}},
|
||||
"换成另一块": {b},
|
||||
}
|
||||
for name, list := range cases {
|
||||
if MaskFingerprint(list) == base {
|
||||
t.Errorf("%s 之后指纹没变,访客缓存不会失效", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
)
|
||||
|
||||
// 迷雾覆盖层配色:填充用偏亮的蓝,边界再描一圈更深的轮廓,
|
||||
// 这样在浅色底图上也能一眼看出「已揭开」的范围。
|
||||
var (
|
||||
FogColor = [4]byte{48, 122, 224, 132}
|
||||
FogEdge = [4]byte{16, 52, 132, 232}
|
||||
)
|
||||
|
||||
// RenderTile 渲染一张 256x256 的迷雾透明瓦片。
|
||||
//
|
||||
// z/x/y 为 slippy 瓦片坐标。crs 指明底图坐标系:
|
||||
// 迷雾网格是 WGS-84,而高德这类底图把 GCJ-02 坐标画在自己的名义位置上,
|
||||
// 所以 gcj02 底图下每个块要先做 WGS84→GCJ02 正变换再投影;
|
||||
// 块只有 600m 见方,偏移场在块内变化不足 1px,按块中心算一次即可。
|
||||
// wgs84 底图(天地图/自备)则不偏移。
|
||||
// masks 是被遮罩盖住、对访客不显示的区域(nil = 全部显示)。被遮住的格子既不画填充,
|
||||
// 也不算作「未探索」的邻居——否则遮罩边缘会被描出一圈轮廓,等于把隐藏范围本身暴露了。
|
||||
func RenderTile(blocks map[[2]int][]byte, z, x, y int, crs string, masks *MaskSet) *image.NRGBA {
|
||||
if z < 0 || z > 22 {
|
||||
return nil
|
||||
}
|
||||
step := WorldCells >> z // 该瓦片每边覆盖的格子数
|
||||
scale := 256.0 / float64(step)
|
||||
ox, oy := float64(x*step), float64(y*step)
|
||||
// 第一遍只画掩码,第二遍再按邻域决定填充色还是轮廓色:
|
||||
// 只有紧邻「未探索」的像素才描边,已探索区域内部保持平涂。
|
||||
var mask [256 * 256]bool
|
||||
// hole 记录「因为遮罩而不画」的像素,第二遍描边时把它当作正常像素跳过
|
||||
var hole []bool
|
||||
if !masks.Empty() {
|
||||
hole = make([]bool, 256*256)
|
||||
}
|
||||
mark := func(px, py int) {
|
||||
if px < 0 || px > 255 || py < 0 || py > 255 {
|
||||
return
|
||||
}
|
||||
mask[py*256+px] = true
|
||||
}
|
||||
fill := func(px, py, w, h int) {
|
||||
for r := 0; r < h; r++ {
|
||||
for c := 0; c < w; c++ {
|
||||
mark(px+c, py+r)
|
||||
}
|
||||
}
|
||||
}
|
||||
// markHole 把一段像素标成「被遮罩隐去」
|
||||
markHole := func(px, py, w, h int) {
|
||||
if hole == nil {
|
||||
return
|
||||
}
|
||||
for r := 0; r < h; r++ {
|
||||
for c := 0; c < w; c++ {
|
||||
x, y := px+c, py+r
|
||||
if x >= 0 && x <= 255 && y >= 0 && y <= 255 {
|
||||
hole[y*256+x] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 直接遍历块表而不是全球块范围:低缩放级下范围循环会膨胀到数十亿次空查询。
|
||||
// GCJ 偏移最多约 700m(74 格),粗裁剪时两侧各放宽两块保证不漏。
|
||||
const margin = 2 * BlockCells
|
||||
lo, hi := float64(x*step-margin), float64((x+1)*step+margin)
|
||||
rlo, rhi := float64(y*step-margin), float64((y+1)*step+margin)
|
||||
shift := crs == "gcj02"
|
||||
|
||||
for key, bm := range blocks {
|
||||
gbx, gby := key[0], key[1]
|
||||
bx, by := float64(gbx*BlockCells), float64(gby*BlockCells)
|
||||
if bx+BlockCells <= lo || bx >= hi || by+BlockCells <= rlo || by >= rhi {
|
||||
continue
|
||||
}
|
||||
ddx, ddy := 0.0, 0.0
|
||||
if shift {
|
||||
ddx, ddy = blockShift(gbx, gby)
|
||||
}
|
||||
u0 := (bx + ddx - ox) * scale
|
||||
v0 := (by + ddy - oy) * scale
|
||||
|
||||
if scale >= 1 {
|
||||
w := int(math.Ceil(scale))
|
||||
for row := 0; row < BlockCells; row++ {
|
||||
line := bm[row*8 : row*8+8]
|
||||
if line[0]|line[1]|line[2]|line[3]|line[4]|line[5]|line[6]|line[7] == 0 {
|
||||
continue
|
||||
}
|
||||
py := int(math.Floor(v0 + float64(row)*scale))
|
||||
for colB, byteVal := range line {
|
||||
if byteVal == 0 {
|
||||
continue
|
||||
}
|
||||
for bit := 0; bit < 8; bit++ {
|
||||
if byteVal&(0x80>>bit) == 0 {
|
||||
continue
|
||||
}
|
||||
px := int(math.Floor(u0 + float64(colB*8+bit)*scale))
|
||||
if masks.Hit(gbx*BlockCells+colB*8+bit, gby*BlockCells+row) {
|
||||
markHole(px, py, w, w)
|
||||
continue
|
||||
}
|
||||
fill(px, py, w, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 格子比像素还小(低缩放级):整块只要有探索就整块涂上。
|
||||
// 这时按块中心判遮罩——缩到这种级别,块本身还不到一个像素。
|
||||
if popcountSum(bm) == 0 {
|
||||
continue
|
||||
}
|
||||
if masks.Hit(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2) {
|
||||
continue
|
||||
}
|
||||
u1 := (bx + BlockCells + ddx - ox) * scale
|
||||
v1 := (by + BlockCells + ddy - oy) * scale
|
||||
px0, py0 := int(math.Floor(u0)), int(math.Floor(v0))
|
||||
px1, py1 := int(math.Ceil(u1)), int(math.Ceil(v1))
|
||||
if px1-px0 < 1 {
|
||||
px1 = px0 + 1
|
||||
}
|
||||
if py1-py0 < 1 {
|
||||
py1 = py0 + 1
|
||||
}
|
||||
fill(px0, py0, px1-px0, py1-py0)
|
||||
}
|
||||
|
||||
// 第二遍着色。瓦片外的邻格按「已探索」处理,避免在瓦片接缝处
|
||||
// 凭空描出一条网格线(真实边界由邻瓦片自己画)。
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 256, 256))
|
||||
for py := 0; py < 256; py++ {
|
||||
for px := 0; px < 256; px++ {
|
||||
if !mask[py*256+px] {
|
||||
continue
|
||||
}
|
||||
// 已探索的邻居:被遮罩隐去的也算「不算未探索」,免得把隐藏范围描出轮廓
|
||||
lit := func(i int) bool { return mask[i] || (hole != nil && hole[i]) }
|
||||
edge := false
|
||||
if px > 0 && !lit(py*256+px-1) {
|
||||
edge = true
|
||||
} else if px < 255 && !lit(py*256+px+1) {
|
||||
edge = true
|
||||
} else if py > 0 && !lit((py-1)*256+px) {
|
||||
edge = true
|
||||
} else if py < 255 && !lit((py+1)*256+px) {
|
||||
edge = true
|
||||
}
|
||||
c := FogColor
|
||||
if edge {
|
||||
c = FogEdge
|
||||
}
|
||||
i := (py*256 + px) * 4
|
||||
img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = c[0], c[1], c[2], c[3]
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
// blockShift 返回该块中心在 GCJ-02 底图下的像素偏移(单位:基网格格子)。
|
||||
func blockShift(gbx, gby int) (float64, float64) {
|
||||
lon, lat := cellLonLat(gbx*BlockCells+BlockCells/2, gby*BlockCells+BlockCells/2)
|
||||
glat, glon := geo.WGS84ToGCJ02(lat, lon)
|
||||
return mercX(glon) - mercX(lon), mercY(glat) - mercY(lat)
|
||||
}
|
||||
|
||||
func mercX(lon float64) float64 { return (lon + 180.0) / 360.0 * WorldCells }
|
||||
|
||||
func mercY(lat float64) float64 {
|
||||
rad := lat * math.Pi / 180
|
||||
return (math.Pi - math.Asinh(math.Tan(rad))) / (2 * math.Pi) * WorldCells
|
||||
}
|
||||
|
||||
func cellLonLat(cx, cy int) (float64, float64) {
|
||||
lon := (float64(cx)+0.5)/WorldCells*360.0 - 180.0
|
||||
n := 2 * math.Pi * (float64(cy) + 0.5) / WorldCells
|
||||
lat := math.Atan(math.Sinh(math.Pi-n)) * 180.0 / math.Pi
|
||||
return lon, lat
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package fog
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Index 是某个用户迷雾位图的内存索引,按块粒度持有,
|
||||
// 供瓦片渲染做范围查询;写路径先落库再刷新内存。
|
||||
type Index struct {
|
||||
mu sync.RWMutex
|
||||
blocks map[[2]int][]byte
|
||||
cells int
|
||||
// areaM2 是全部置位格的实地面积(m²),写入索引时一并算好。
|
||||
// 覆盖率统计每次都要这个数,现算得把上万块的位图全扫一遍。
|
||||
areaM2 float64
|
||||
ver int64 // 最近一次更新的 unix 秒,用作瓦片缓存版本号
|
||||
}
|
||||
|
||||
// NewIndex 建一个空索引。
|
||||
func NewIndex() *Index {
|
||||
return &Index{blocks: map[[2]int][]byte{}}
|
||||
}
|
||||
|
||||
// Load 从库里加载某用户的全部迷雾块。
|
||||
func (ix *Index) Load(db *sql.DB, userID int64) error {
|
||||
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks WHERE user_id=$1`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
blocks := map[[2]int][]byte{}
|
||||
cells, areaM2 := 0, 0.0
|
||||
for rows.Next() {
|
||||
var gx, gy int
|
||||
var bm []byte
|
||||
if err := rows.Scan(&gx, &gy, &bm); err != nil {
|
||||
return err
|
||||
}
|
||||
blocks[[2]int{gx, gy}] = bm
|
||||
n := popcountSum(bm)
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(gx, gy)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ix.mu.Lock()
|
||||
ix.blocks = blocks
|
||||
ix.cells = cells
|
||||
ix.areaM2 = areaM2
|
||||
ix.ver = time.Now().Unix()
|
||||
ix.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll 从库里加载全部迷雾块(站点为单用户展示,公开读路径不区分归属)。
|
||||
func (ix *Index) LoadAll(db *sql.DB) error {
|
||||
rows, err := db.Query(`SELECT gbx, gby, bitmap FROM fog_blocks`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
blocks := map[[2]int][]byte{}
|
||||
cells, areaM2 := 0, 0.0
|
||||
for rows.Next() {
|
||||
var gx, gy int
|
||||
var bm []byte
|
||||
if err := rows.Scan(&gx, &gy, &bm); err != nil {
|
||||
return err
|
||||
}
|
||||
blocks[[2]int{gx, gy}] = bm
|
||||
n := popcountSum(bm)
|
||||
cells += n
|
||||
areaM2 += float64(n) * cellAreaM2(gx, gy)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
ix.mu.Lock()
|
||||
ix.blocks = blocks
|
||||
ix.cells = cells
|
||||
ix.areaM2 = areaM2
|
||||
ix.ver = time.Now().Unix()
|
||||
ix.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats 返回块数与格子数。
|
||||
func (ix *Index) Stats() (blocks, cells int) {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return len(ix.blocks), ix.cells
|
||||
}
|
||||
|
||||
// AreaKm2 返回已探索面积(km²,按块中心纬度做 cos 修正)。写入索引时算好,这里只读。
|
||||
func (ix *Index) AreaKm2() float64 {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return ix.areaM2 / 1e6
|
||||
}
|
||||
|
||||
// Version 返回数据版本号,随每次写入变化。
|
||||
func (ix *Index) Version() int64 {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
return ix.ver
|
||||
}
|
||||
|
||||
// View 在读锁内执行渲染查询,避免渲染中途被写换代。
|
||||
func (ix *Index) View(fn func(blocks map[[2]int][]byte)) {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
fn(ix.blocks)
|
||||
}
|
||||
|
||||
// Import 把解析出的块写入库:merge 模式按位或合并进已有数据,
|
||||
// replace 模式先清空该用户全部迷雾再写入。完成后刷新内存索引。
|
||||
func (ix *Index) Import(db *sql.DB, userID int64, blocks []Block, merge bool) (applied int, err error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if !merge {
|
||||
if _, err := tx.Exec(`DELETE FROM fog_blocks WHERE user_id=$1`, userID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
var upsert *sql.Stmt
|
||||
if upsert, err = tx.Prepare(`INSERT INTO fog_blocks (user_id, gbx, gby, bitmap, updated_at)
|
||||
VALUES ($1,$2,$3,$4,now())
|
||||
ON CONFLICT (user_id, gbx, gby) DO UPDATE SET bitmap=$4, updated_at=now()`); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer upsert.Close()
|
||||
|
||||
for _, b := range blocks {
|
||||
if merge {
|
||||
var old []byte
|
||||
err := tx.QueryRow(`SELECT bitmap FROM fog_blocks WHERE user_id=$1 AND gbx=$2 AND gby=$3`,
|
||||
userID, b.GX, b.GY).Scan(&old)
|
||||
switch {
|
||||
case err == nil && len(old) == len(b.Bitmap):
|
||||
for i := range b.Bitmap {
|
||||
b.Bitmap[i] |= old[i]
|
||||
}
|
||||
case err != nil && !errors.Is(err, sql.ErrNoRows):
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if _, err := upsert.Exec(userID, b.GX, b.GY, b.Bitmap); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
applied++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := ix.Load(db, userID); err != nil {
|
||||
return applied, err
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// CellLit 报告某个全局格是否已探索。
|
||||
func (ix *Index) CellLit(cx, cy int) bool {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
bm, ok := ix.blocks[[2]int{cx / BlockCells, cy / BlockCells}]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
col, row := cx%BlockCells, cy%BlockCells
|
||||
return bm[row*8+col/8]&(0x80>>(col%8)) != 0
|
||||
}
|
||||
|
||||
// All 返回全部块(导出用)。
|
||||
func (ix *Index) All() []Block {
|
||||
ix.mu.RLock()
|
||||
defer ix.mu.RUnlock()
|
||||
out := make([]Block, 0, len(ix.blocks))
|
||||
for k, bm := range ix.blocks {
|
||||
out = append(out, Block{GX: k[0], GY: k[1], Bitmap: bm})
|
||||
}
|
||||
sortBlocks(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LoadOrNewIndex 返回某用户的迷雾索引;首次访问时从库里加载。
|
||||
func LoadOrNewIndex(db *sql.DB, userID int64, existing *Index) (*Index, error) {
|
||||
if existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
ix := NewIndex()
|
||||
if err := ix.Load(db, userID); err != nil {
|
||||
return nil, fmt.Errorf("加载迷雾数据: %w", err)
|
||||
}
|
||||
return ix, nil
|
||||
}
|
||||
|
||||
// SaveRawLayers 保存快照里其它图层的原始条目(path -> zlib 流本身,一个字节都不动)。
|
||||
// replace 为真时先清空(「整体替换」与「清空迷雾」都该把旧的一并带走),
|
||||
// 否则同名覆盖——后传的快照更完整。
|
||||
func (ix *Index) SaveRawLayers(db *sql.DB, userID int64, layers map[string][]byte, replace bool) error {
|
||||
if !replace && len(layers) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if replace {
|
||||
if _, err := tx.Exec(`DELETE FROM fog_raw_files WHERE user_id=$1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for p, data := range layers {
|
||||
if _, err := tx.Exec(`INSERT INTO fog_raw_files (user_id, path, data) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (user_id, path) DO UPDATE SET data=EXCLUDED.data`, userID, p, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DropRawLayers 丢弃留存的其它图层。清空迷雾时调用(与 fog_blocks 一样是站点级,
|
||||
// 不按用户区分),否则导出的快照里会留着已经没有对应 Model/* 瓦片的图层条目。
|
||||
func DropRawLayers(db *sql.DB) error {
|
||||
_, err := db.Exec(`DELETE FROM fog_raw_files`)
|
||||
return err
|
||||
}
|
||||
|
||||
// RawLayers 读出留存的非已探索层文件(path -> 原始字节)。
|
||||
func LoadRawLayers(db *sql.DB, userID int64) (map[string][]byte, error) {
|
||||
rows, err := db.Query(`SELECT path, data FROM fog_raw_files WHERE user_id=$1`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string][]byte{}
|
||||
for rows.Next() {
|
||||
var p string
|
||||
var data []byte
|
||||
if err := rows.Scan(&p, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[p] = data
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- 迷雾遮罩区(对访客隐藏的范围) ----------
|
||||
|
||||
// SaveMask 新增或更新一块遮罩(ID 为 0 时新增),返回其 ID。
|
||||
func SaveMask(db *sql.DB, userID int64, m Mask) (int64, error) {
|
||||
if m.ID > 0 {
|
||||
_, err := db.Exec(`UPDATE fog_masks SET name=$1, lat=$2, lon=$3, radius_m=$4 WHERE id=$5 AND user_id=$6`,
|
||||
m.Name, m.Lat, m.Lon, m.RadiusM, m.ID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return m.ID, nil
|
||||
}
|
||||
var id int64
|
||||
err := db.QueryRow(`INSERT INTO fog_masks (user_id, name, lat, lon, radius_m) VALUES ($1,$2,$3,$4,$5)
|
||||
RETURNING id`, userID, m.Name, m.Lat, m.Lon, m.RadiusM).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteMask 删除一块遮罩。
|
||||
func DeleteMask(db *sql.DB, userID, id int64) error {
|
||||
_, err := db.Exec(`DELETE FROM fog_masks WHERE id=$1 AND user_id=$2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// LoadMasks 读出全部遮罩。遮罩与迷雾一样是站点级数据(单站长的站点),
|
||||
// 展示与渲染时不分归属,与 fog_blocks 的 LoadAll 保持一致。
|
||||
func LoadMasks(db *sql.DB) ([]Mask, error) {
|
||||
rows, err := db.Query(`SELECT id, COALESCE(name,''), lat, lon, radius_m FROM fog_masks
|
||||
WHERE radius_m > 0 ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mask
|
||||
for rows.Next() {
|
||||
var m Mask
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Lat, &m.Lon, &m.RadiusM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user