专题显示区改为一组圆(后端与查询层)
把专题的「一个圆」换成「一组圆」(areas jsonb 数组):落在任一圆内即算在 区域内(SQL 里的 OR)。这一批只动后端与查询层,前台渲染与后台地图编辑器 分别跟进;后台表单暂时沿用原来的三个坐标输入框,保存时合成一个圆写进 areas,所以现有交互不受影响。 - topics.areas JSONB + 存量回填:老的单圆搬进去,之后 areas 是唯一权威。 废弃的 center_lat/center_lon/radius_km 保留(写入时镜像第一个圆), 一是回滚旧二进制时还能退化显示成第一个圆,二是回填的输入。 - Topic.AreaList() 归一:Areas 为空时退回老的三列,未回填的老行与直接构造 Topic 的调用方都继续工作。 - topicAreaCond 改成 N 个圆的 OR;每个圆占 3 个占位符,编号纪律照旧 (写错就是「专题内容全变 0」那种难查的事故)。 - fitParams 改用所有圆的并集外接矩形:多圆之后「圆心+半径」表达不了, 统一走 fitBBox;单圆时外接矩形就是这个圆的外接正方形,与原路径等价。 - 坐标系:编辑器提交的圆心若是 WGS-84 底图上点的,存前换算成 GCJ-02 (地点过滤要拿它跟 places 的 GCJ-02 坐标比距离)。 测试: - 表单解析(两个入口同一套校验)、wgs84 换算、半径为 0/空 = 不限区域、 坏 JSON/越界必须报错、上限 20 个。 - 占位符纪律:本段引用的 $n 必须恰好是 [prefix+1, len(args)] 连续区间, 最大值必须等于参数个数。 - 多圆并集语义(DB 门控):两圆专题的地点集合必须恰好等于两个单圆专题的 并集 —— 定义性不变量,变异验证过(只认第一个圆 ⇒ 两个用例同时红)。
This commit is contained in:
+177
-28
@@ -9,10 +9,10 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rond-with-you/internal/geo"
|
||||
"rond-with-you/internal/stats"
|
||||
)
|
||||
|
||||
@@ -41,6 +41,131 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// ---------- 显示区(一组圆) ----------
|
||||
|
||||
const (
|
||||
// maxTopicAreas 限制显示区个数:每个圆在 SQL 里占 3 个占位符、在 clip-path 里是一段子路径,
|
||||
// 都不是能无限堆的东西;后台地图编辑器用同一个上限。
|
||||
maxTopicAreas = 20
|
||||
// 半径 50 米 ~ 2000 公里:再小在地图上拖不出来,再大没有意义。
|
||||
topicAreaMinRadiusKm = 0.05
|
||||
topicAreaMaxRadiusKm = 2000.0
|
||||
)
|
||||
|
||||
// parseTopicAreas 解析 areas 列。坏数据要上抛而不是吞掉——
|
||||
// 显示区决定了专题「有哪些地点」,静默退化成「全都要」比报错危险得多。
|
||||
func parseTopicAreas(raw []byte) ([]TopicArea, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var areas []TopicArea
|
||||
if err := json.Unmarshal(raw, &areas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return areas, nil
|
||||
}
|
||||
|
||||
// normalizeTopicAreas 校验并收敛显示区。crs 是**点选时所处底图**的坐标系:
|
||||
// 天地图那类 WGS-84 底图上点的圆心必须先换算成 GCJ-02 再存 ——
|
||||
// 地点过滤是拿它跟 places 的 p.lat/p.lon(GCJ-02)直接比球面距离的,不换算会整体偏几百米。
|
||||
func normalizeTopicAreas(areas []TopicArea, crs string) ([]TopicArea, error) {
|
||||
if len(areas) > maxTopicAreas {
|
||||
return nil, fmt.Errorf("显示区最多 %d 个", maxTopicAreas)
|
||||
}
|
||||
out := make([]TopicArea, 0, len(areas))
|
||||
for _, a := range areas {
|
||||
if math.IsNaN(a.Lat) || math.IsNaN(a.Lon) || math.IsNaN(a.RadiusKm) ||
|
||||
math.IsInf(a.Lat, 0) || math.IsInf(a.Lon, 0) || math.IsInf(a.RadiusKm, 0) {
|
||||
return nil, fmt.Errorf("显示区里有非法数值")
|
||||
}
|
||||
if math.Abs(a.Lat) > 90 || math.Abs(a.Lon) > 180 {
|
||||
return nil, fmt.Errorf("显示区的圆心超出经纬度范围")
|
||||
}
|
||||
if a.RadiusKm < topicAreaMinRadiusKm || a.RadiusKm > topicAreaMaxRadiusKm {
|
||||
return nil, fmt.Errorf("显示区的半径要在 %g ~ %g 公里之间", topicAreaMinRadiusKm, topicAreaMaxRadiusKm)
|
||||
}
|
||||
if crs == "wgs84" {
|
||||
a.Lat, a.Lon = geo.WGS84ToGCJ02(a.Lat, a.Lon)
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// topicAreasColumn 是写进 areas 列的值。**空必须是 `[]` 而不是 `null`**:
|
||||
// 列上有 NOT NULL,而 json.Marshal(nil slice) 给出来的正好是 "null"。
|
||||
func topicAreasColumn(areas []TopicArea) []byte {
|
||||
if len(areas) == 0 {
|
||||
return []byte("[]")
|
||||
}
|
||||
b, err := json.Marshal(areas)
|
||||
if err != nil {
|
||||
return []byte("[]")
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// topicAreasFromForm 从表单取显示区:优先读地图编辑器提交的 areas(JSON);
|
||||
// 没给才退回老的 center_lat / center_lon / radius_km 三字段(合成一个圆)。
|
||||
// 两条路走同一套校验与换算,免得「新入口有校验、老入口没有」。
|
||||
func topicAreasFromForm(r *http.Request) ([]TopicArea, error) {
|
||||
crs := strings.TrimSpace(r.PostFormValue("crs"))
|
||||
raw := strings.TrimSpace(r.PostFormValue("areas"))
|
||||
if raw == "" {
|
||||
lat, ok1 := parseFloat(r.PostFormValue("center_lat"))
|
||||
lon, ok2 := parseFloat(r.PostFormValue("center_lon"))
|
||||
rkm, ok3 := parseFloat(r.PostFormValue("radius_km"))
|
||||
// 老表单里「不限区域」就是留空或 0,不是错误
|
||||
if !ok1 || !ok2 || !ok3 || rkm <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if lat == 0 && lon == 0 {
|
||||
return nil, fmt.Errorf("限定区域时要在图上点选圆心")
|
||||
}
|
||||
return normalizeTopicAreas([]TopicArea{{Lat: lat, Lon: lon, RadiusKm: rkm}}, crs)
|
||||
}
|
||||
var areas []TopicArea
|
||||
if err := json.Unmarshal([]byte(raw), &areas); err != nil {
|
||||
return nil, fmt.Errorf("显示区数据无法解析")
|
||||
}
|
||||
return normalizeTopicAreas(areas, crs)
|
||||
}
|
||||
|
||||
// topicAreasBounds 求所有圆的并集外接矩形(GCJ-02 度)。
|
||||
// 只有一个圆时它就是这个圆的外接正方形,与原来 fitCircle 那条路(toBounds(2r km))等价,
|
||||
// 所以单圆专题的视野不变;多圆只有外接矩形才有意义。
|
||||
func topicAreasBounds(areas []TopicArea) (minLat, maxLat, minLon, maxLon float64, ok bool) {
|
||||
if len(areas) == 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
minLat, maxLat = math.MaxFloat64, -math.MaxFloat64
|
||||
minLon, maxLon = math.MaxFloat64, -math.MaxFloat64
|
||||
for _, a := range areas {
|
||||
dLat := a.RadiusKm / 111.32
|
||||
// 经度方向 1 度比纬度短,按纬度余弦放大;高纬处夹一下,避免除到极大
|
||||
cos := math.Cos(a.Lat * math.Pi / 180)
|
||||
if cos < 0.01 {
|
||||
cos = 0.01
|
||||
}
|
||||
dLon := a.RadiusKm / (111.32 * cos)
|
||||
minLat, maxLat = math.Min(minLat, a.Lat-dLat), math.Max(maxLat, a.Lat+dLat)
|
||||
minLon, maxLon = math.Min(minLon, a.Lon-dLon), math.Max(maxLon, a.Lon+dLon)
|
||||
}
|
||||
if minLat < -90 {
|
||||
minLat = -90
|
||||
}
|
||||
if maxLat > 90 {
|
||||
maxLat = 90
|
||||
}
|
||||
if minLon < -180 {
|
||||
minLon = -180
|
||||
}
|
||||
if maxLon > 180 {
|
||||
maxLon = 180
|
||||
}
|
||||
return minLat, maxLat, minLon, maxLon, true
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
// loadTopics 读专题列表。onlyEnabled 时只取对外展示的(前台用)。
|
||||
@@ -50,7 +175,7 @@ func parseTopicTime(raw string) (time.Time, bool) {
|
||||
// 专题的**内容**仍然照当前生效数据集算(见 topicContent),变的是「这份定义归谁」。
|
||||
func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool) ([]Topic, error) {
|
||||
q := `SELECT id, title, subtitle, description, start_at, end_at, center_lat, center_lon,
|
||||
radius_km, show_fog, enabled, sort_order
|
||||
radius_km, show_fog, enabled, sort_order, areas
|
||||
FROM topics WHERE user_id=$1`
|
||||
if onlyEnabled {
|
||||
q += ` AND enabled`
|
||||
@@ -64,10 +189,15 @@ func (s *Server) loadTopics(ctx context.Context, ownerID int64, onlyEnabled bool
|
||||
var out []Topic
|
||||
for rows.Next() {
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
if err := rows.Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder); err != nil {
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder,
|
||||
&rawAreas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||
return nil, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err)
|
||||
}
|
||||
t.StartDate = t.StartAt.Local().Format(topicDateTimeLayout)
|
||||
t.EndDate = t.EndAt.Local().Format(topicDateTimeLayout)
|
||||
t.RangeLabel = topicRangeLabel(t.StartAt, t.EndAt)
|
||||
@@ -100,16 +230,23 @@ const topicHaversineSQL = `6371 * 2 * asin(sqrt(
|
||||
power(sin(radians((%s - p.lat) / 2)), 2) +
|
||||
cos(radians(p.lat)) * cos(radians(%s)) * power(sin(radians((%s - p.lon) / 2)), 2)))`
|
||||
|
||||
// topicAreaCond 追加圆心/半径参数并返回区域条件。批量统计要重复拼这段,抽出来
|
||||
// 保证两条路径的占位符编号逐字一致 —— 这里少算一个 $n,表现就是「专题内容全变 0」。
|
||||
// topicAreaCond 追加显示区的参数并返回区域条件:落在**任一**圆内就算在区域内(并集)。
|
||||
// 批量统计要重复拼这段,抽出来保证两条路径的占位符编号逐字一致 ——
|
||||
// 这里少算一个 $n,表现就是「专题内容全变 0」。每个圆占 3 个参数(纬度、经度、半径)。
|
||||
func topicAreaCond(t *Topic, args *[]any) string {
|
||||
if !t.HasArea() {
|
||||
areas := t.AreaList()
|
||||
if len(areas) == 0 {
|
||||
return ""
|
||||
}
|
||||
*args = append(*args, t.CenterLat, t.CenterLon, t.RadiusKm)
|
||||
n := len(*args)
|
||||
return fmt.Sprintf(" AND %s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n)
|
||||
conds := make([]string, 0, len(areas))
|
||||
for _, a := range areas {
|
||||
*args = append(*args, a.Lat, a.Lon, a.RadiusKm)
|
||||
n := len(*args)
|
||||
// 球面距离表达式的三个 %s 依次是 中心纬度 / 中心纬度 / 中心经度,最后再比半径
|
||||
conds = append(conds, fmt.Sprintf("%s <= $%d",
|
||||
fmt.Sprintf(topicHaversineSQL, fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-2), fmt.Sprintf("$%d", n-1)), n))
|
||||
}
|
||||
return " AND (" + strings.Join(conds, " OR ") + ")"
|
||||
}
|
||||
|
||||
// topicContent 取专题里的地点(含到访次数与停留),并汇总到访数 / 天数 / 城市数。
|
||||
@@ -258,14 +395,15 @@ func parseTopicForm(r *http.Request) (Topic, error) {
|
||||
return t, fmt.Errorf("结束时间不能早于开始时间")
|
||||
}
|
||||
t.StartAt, t.EndAt = start, end
|
||||
t.CenterLat, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lat")), 64)
|
||||
t.CenterLon, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("center_lon")), 64)
|
||||
t.RadiusKm, _ = strconv.ParseFloat(strings.TrimSpace(r.PostFormValue("radius_km")), 64)
|
||||
if t.RadiusKm < 0 {
|
||||
t.RadiusKm = 0
|
||||
areas, err := topicAreasFromForm(r)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if t.RadiusKm > 0 && t.CenterLat == 0 && t.CenterLon == 0 {
|
||||
return t, fmt.Errorf("限定区域时要填中心点坐标")
|
||||
t.Areas = areas
|
||||
// 镜像第一个圆进那三列废弃列:万一回滚旧二进制,退化成「第一个圆」总比「没有区域」强。
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm = 0, 0, 0
|
||||
if len(areas) > 0 {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm = areas[0].Lat, areas[0].Lon, areas[0].RadiusKm
|
||||
}
|
||||
t.ShowFog = r.PostFormValue("show_fog") == "on"
|
||||
t.Enabled = r.PostFormValue("enabled") == "on"
|
||||
@@ -345,10 +483,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
if id > 0 {
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE topics SET title=$3, subtitle=$4, description=$5,
|
||||
start_at=$6, end_at=$7, center_lat=$8, center_lon=$9, radius_km=$10,
|
||||
show_fog=$11, enabled=$12, sort_order=$13
|
||||
show_fog=$11, enabled=$12, sort_order=$13, areas=$14::jsonb
|
||||
WHERE id=$1 AND user_id=$2`,
|
||||
id, u.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder); err != nil {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||
topicAreasColumn(t.Areas)); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -360,10 +499,11 @@ func (s *Server) adminTopicSave(w http.ResponseWriter, r *http.Request) {
|
||||
// 认它归谁、对外怎么展示都与它无关(外键已摘,见 schema.sql)。
|
||||
if err := s.db.QueryRowContext(ctx, `INSERT INTO topics
|
||||
(user_id, dataset_id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb) RETURNING id`,
|
||||
u.ID, ds.ID, t.Title, t.Subtitle, t.Description, t.StartAt, t.EndAt,
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder).Scan(&newID); err != nil {
|
||||
t.CenterLat, t.CenterLon, t.RadiusKm, t.ShowFog, t.Enabled, t.SortOrder,
|
||||
topicAreasColumn(t.Areas)).Scan(&newID); err != nil {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -443,11 +583,13 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var t Topic
|
||||
var rawAreas []byte
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id, title, subtitle, description, start_at, end_at,
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order
|
||||
center_lat, center_lon, radius_km, show_fog, enabled, sort_order, areas
|
||||
FROM topics WHERE id=$1 AND user_id=$2`, id, p.Dataset.UserID).
|
||||
Scan(&t.ID, &t.Title, &t.Subtitle, &t.Description, &t.StartAt, &t.EndAt,
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder)
|
||||
&t.CenterLat, &t.CenterLon, &t.RadiusKm, &t.ShowFog, &t.Enabled, &t.SortOrder,
|
||||
&rawAreas)
|
||||
if err == sql.ErrNoRows {
|
||||
s.notFoundPage(w, "这个专题不存在", "地址可能写错了,或者这个专题已经被删掉。")
|
||||
return
|
||||
@@ -456,6 +598,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
if t.Areas, err = parseTopicAreas(rawAreas); err != nil {
|
||||
s.serverError(w, r, fmt.Errorf("专题 #%d 的显示区解析失败: %w", t.ID, err))
|
||||
return
|
||||
}
|
||||
// 未对外展示的专题只有站长能看。给访客的话术与「不存在」保持一致——
|
||||
// 这里刻意不区分,否则等于告诉访客「这个 ID 存在但你没资格看」。
|
||||
if !t.Enabled && !p.IsAdmin() {
|
||||
@@ -512,14 +658,17 @@ func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points
|
||||
}
|
||||
}
|
||||
|
||||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定区域时用圆心 + 半径,
|
||||
// fitParams 生成把地图视野框到专题范围的脚本参数:限定显示区时用**所有圆的并集外接矩形**,
|
||||
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
|
||||
//
|
||||
// 多圆之后不再有「圆心 + 半径」可言(fitCircle 只表达得了一个圆),统一走 fitBBox:
|
||||
// 单圆时外接矩形就是这个圆的外接正方形,与原来 fitCircle 那条路等价。
|
||||
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
|
||||
if t.HasArea() {
|
||||
if minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(t.AreaList()); ok {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"lat": t.CenterLat, "lon": t.CenterLon, "radiusKm": t.RadiusKm,
|
||||
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
|
||||
})
|
||||
return template.JS(b), template.JS("null")
|
||||
return template.JS("null"), template.JS(b)
|
||||
}
|
||||
if len(points) == 0 {
|
||||
return template.JS("null"), template.JS("null")
|
||||
|
||||
Reference in New Issue
Block a user