From de834bf3ecf5671b19c907acc8b81a20122cc187 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 17:15:26 -0400 Subject: [PATCH] =?UTF-8?q?fix(portrayal):=20place=20centred=20area=20symb?= =?UTF-8?q?ols=20per=20S-52=20=C2=A78.5.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centred area symbols (anchorages, restricted/cable/pipeline areas) were placed by putting the S-101 catalogue pivot on the area's point. For the "…RES" fan-out symbols that pivot is authored at the SVG corner, so the glyph hung ~100px off its area: misplaced (sometimes over a building), the tap at the area missed it, and it read as the symbol "moving around". - Anchor at the pole of inaccessibility (Mapbox polylabel), hole-aware, so the representative point stays inside concave areas and off holes (S-52 §8.5.3 representative point of an area). - Centre the PRIMARY centred symbol's glyph on that point "so it is evident which area the symbol applies to"; additional symbols keep the catalogue offset and fan out "to prevent overwriting" (S-52 §8.5.1 — a centred symbol plus offset extras). Baked as a pivot_center tag; the client selects a glyph-centred "ctr:" sprite variant for it. Requires a re-bake. Not yet implemented: the §8.5.1 rule "if the centre of the symbol bounding box falls outside the area it must not be drawn" (zoom-dependent in a tiled renderer; needs a client render-time test). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/bake/bake.go | 5 + internal/engine/portrayal/anchor_test.go | 18 +++ internal/engine/portrayal/build.go | 192 ++++++++++++++++------- internal/engine/portrayal/primitive.go | 14 +- internal/engine/portrayal/s101build.go | 16 ++ web/src/chart-canvas/s52-style.mjs | 9 +- web/src/chart-canvas/sprite-builder.mjs | 14 +- 7 files changed, 205 insertions(+), 63 deletions(-) diff --git a/internal/engine/bake/bake.go b/internal/engine/bake/bake.go index 50fdc37..1d18881 100644 --- a/internal/engine/bake/bake.go +++ b/internal/engine/bake/bake.go @@ -1055,6 +1055,11 @@ func (b *Baker) routeSymbol(v portrayal.SymbolCall, common func(...mvt.KeyValue) if v.RotationTrueNorth { attrs = append(attrs, mvt.KeyValue{Key: "rot_north", Value: mvt.IntVal(1)}) } + // pivot_center tags the primary centred-area symbol: the client centres the glyph + // on the point instead of the catalogue's fan-out pivot (S-52 §8.5.1; CentreOnArea). + if v.CentreOnArea { + attrs = append(attrs, mvt.KeyValue{Key: "pivot_center", Value: mvt.IntVal(1)}) + } if !isNaN32(v.DangerDepthM) { attrs = append(attrs, mvt.KeyValue{Key: "danger_depth", Value: mvt.FloatVal(v.DangerDepthM)}, diff --git a/internal/engine/portrayal/anchor_test.go b/internal/engine/portrayal/anchor_test.go index 432799f..ec7d766 100644 --- a/internal/engine/portrayal/anchor_test.go +++ b/internal/engine/portrayal/anchor_test.go @@ -47,3 +47,21 @@ func TestAreaSurfacePointConvex(t *testing.T) { t.Fatalf("convex anchor %+v ok=%v not inside", p, ok) } } + +// A square area with a large centred square hole: the area-weighted centroid lands +// dead-centre — inside the hole (i.e. on the excluded structure). areaLabelPoint +// must place the symbol in the surrounding ring, off the hole. +func TestAreaLabelPointHole(t *testing.T) { + exterior := []geo.LatLon{{Lat: 0, Lon: 0}, {Lat: 0, Lon: 10}, {Lat: 10, Lon: 10}, {Lat: 10, Lon: 0}} + hole := []geo.LatLon{{Lat: 3, Lon: 3}, {Lat: 3, Lon: 7}, {Lat: 7, Lon: 7}, {Lat: 7, Lon: 3}} + p, ok := areaLabelPoint([][]geo.LatLon{exterior, hole}) + if !ok { + t.Fatal("areaLabelPoint returned ok=false") + } + if !pointInRing(p, exterior) { + t.Fatalf("anchor %+v is outside the exterior", p) + } + if pointInRing(p, hole) { + t.Fatalf("anchor %+v landed inside the hole (on the excluded structure)", p) + } +} diff --git a/internal/engine/portrayal/build.go b/internal/engine/portrayal/build.go index f47472b..715f91b 100644 --- a/internal/engine/portrayal/build.go +++ b/internal/engine/portrayal/build.go @@ -1,9 +1,9 @@ package portrayal import ( + "container/heap" "fmt" "math" - "sort" "strconv" "strings" @@ -192,72 +192,148 @@ func textAnchor(g geom) (geo.LatLon, bool) { if len(g.area) == 0 || len(g.area[0]) == 0 { return geo.LatLon{}, false } - return areaSurfacePoint(g.area[0]) + return areaLabelPoint(g.area) default: return geo.LatLon{}, false } } -// areaSurfacePoint returns a point guaranteed to lie inside the polygon ring (a -// "point on surface"): the area-weighted centroid when that falls inside, else the -// midpoint of the widest interior span of a horizontal scan line through the -// centroid's latitude. This keeps an area's symbol/label off land for concave -// shapes — e.g. an anchorage anchor symbol that the plain vertex average would -// push outside the water. Falls back to the vertex average for degenerate rings. +// areaSurfacePoint returns a representative interior point for a single ring (no +// holes). It is a thin wrapper over areaLabelPoint, kept for callers that have +// only an exterior ring (e.g. the unknown-object question mark). func areaSurfacePoint(ring []geo.LatLon) (geo.LatLon, bool) { - if len(ring) == 0 { + return areaLabelPoint([][]geo.LatLon{ring}) +} + +// areaLabelPoint returns the polygon's "pole of inaccessibility" (the Mapbox +// polylabel algorithm): the interior point farthest from any edge. rings[0] is +// the exterior boundary; rings[1:] are holes. Containment is the even-odd rule +// over ALL rings, so the chosen point lies inside the exterior AND outside every +// hole — keeping a centred symbol (e.g. an anchorage anchor, a restricted-area +// mark) off an excluded structure, and off the missing limb of a concave (L- or +// U-shaped) area. This is the S-52 PresLib §8.5.3 "representative point of an +// area": robust for concave shapes and shapes whose centroid falls outside the +// area. Falls back to the vertex average for a degenerate (zero-area) ring. +// +// Distances are measured with longitude scaled by cos(lat) so "farthest from any +// edge" is in roughly equal ground units rather than skewed degrees. +func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) { + if len(rings) == 0 || len(rings[0]) == 0 { return geo.LatLon{}, false } + ext := rings[0] + minLat, minLon := math.Inf(1), math.Inf(1) + maxLat, maxLon := math.Inf(-1), math.Inf(-1) var sumLat, sumLon float64 - for _, p := range ring { - sumLat += p.Lat - sumLon += p.Lon - } - mean := geo.LatLon{Lat: sumLat / float64(len(ring)), Lon: sumLon / float64(len(ring))} - - // Area-weighted centroid (shoelace), x=lon, y=lat. - var a2, cx, cy float64 - for i := range ring { - j := (i + 1) % len(ring) - x0, y0 := ring[i].Lon, ring[i].Lat - x1, y1 := ring[j].Lon, ring[j].Lat - cross := x0*y1 - x1*y0 - a2 += cross - cx += (x0 + x1) * cross - cy += (y0 + y1) * cross - } - centroid := mean - if a2 != 0 { - centroid = geo.LatLon{Lat: cy / (3 * a2), Lon: cx / (3 * a2)} - } - if pointInRing(centroid, ring) { - return centroid, true - } - - // Concave shape: the centroid is outside. Scan horizontally at the centroid's - // latitude, collect where the boundary crosses it, and return the midpoint of - // the widest interior interval (which is inside the polygon by construction). - y := centroid.Lat - var xs []float64 - for i := range ring { - j := (i + 1) % len(ring) - y0, y1 := ring[i].Lat, ring[j].Lat - if (y0 <= y) != (y1 <= y) { - t := (y - y0) / (y1 - y0) - xs = append(xs, ring[i].Lon+t*(ring[j].Lon-ring[i].Lon)) - } - } - sort.Float64s(xs) - bestMid, bestW, found := 0.0, -1.0, false - for i := 0; i+1 < len(xs); i += 2 { - if w := xs[i+1] - xs[i]; w > bestW { - bestW, bestMid, found = w, (xs[i]+xs[i+1])/2, true - } - } - if found { - return geo.LatLon{Lat: y, Lon: bestMid}, true - } - return mean, true + for _, p := range ext { + minLat, maxLat = math.Min(minLat, p.Lat), math.Max(maxLat, p.Lat) + minLon, maxLon = math.Min(minLon, p.Lon), math.Max(maxLon, p.Lon) + sumLat, sumLon = sumLat+p.Lat, sumLon+p.Lon + } + mean := geo.LatLon{Lat: sumLat / float64(len(ext)), Lon: sumLon / float64(len(ext))} + kx := math.Cos((minLat + maxLat) / 2 * math.Pi / 180) + if kx < 1e-9 { + kx = 1 // near-polar guard; charts don't reach here + } + // Work in scaled space: X = lon*kx, Y = lat. + xMin, xMax := minLon*kx, maxLon*kx + w, h := xMax-xMin, maxLat-minLat + cellSize := math.Min(w, h) + if cellSize <= 0 { + return mean, true // zero-area / degenerate ring + } + + // Signed distance (positive inside) from a scaled point to the polygon: the + // min distance to any edge of any ring, with the sign from even-odd inclusion. + dist := func(px, py float64) float64 { + inside := false + best := math.Inf(1) + for _, ring := range rings { + n := len(ring) + for i, j := 0, n-1; i < n; j, i = i, i+1 { + ax, ay := ring[i].Lon*kx, ring[i].Lat + bx, by := ring[j].Lon*kx, ring[j].Lat + if (ay > py) != (by > py) && px < (bx-ax)*(py-ay)/(by-ay)+ax { + inside = !inside + } + if d := segDist(px, py, ax, ay, bx, by); d < best { + best = d + } + } + } + if inside { + return best + } + return -best + } + mkCell := func(x, y, half float64) plCell { + d := dist(x, y) + return plCell{x: x, y: y, half: half, d: d, max: d + half*math.Sqrt2} + } + + precision := math.Max(w, h) / 200 // ~0.5% of the span: ample for a label point + half := cellSize / 2 + best := mkCell((xMin+xMax)/2, (minLat+maxLat)/2, 0) // bbox-centre seed + pq := &plCellHeap{} + for x := xMin; x < xMax; x += cellSize { + for y := minLat; y < maxLat; y += cellSize { + heap.Push(pq, mkCell(x+half, y+half, half)) + } + } + const maxCells = 20000 // safety cap for very large/high-vertex rings + for processed := 0; pq.Len() > 0; processed++ { + c := heap.Pop(pq).(plCell) + if c.d > best.d { + best = c + } + if c.max-best.d <= precision || processed >= maxCells { + continue + } + hh := c.half / 2 + heap.Push(pq, mkCell(c.x-hh, c.y-hh, hh)) + heap.Push(pq, mkCell(c.x+hh, c.y-hh, hh)) + heap.Push(pq, mkCell(c.x-hh, c.y+hh, hh)) + heap.Push(pq, mkCell(c.x+hh, c.y+hh, hh)) + } + return geo.LatLon{Lat: best.y, Lon: best.x / kx}, true +} + +// plCell is one square candidate region in the polylabel search (scaled space). +type plCell struct { + x, y, half float64 // centre and half-size + d float64 // signed distance from centre to the polygon (+ inside) + max float64 // upper bound on d anywhere in the cell (d + half*√2) +} + +// plCellHeap is a max-heap on plCell.max (most-promising cell first). +type plCellHeap []plCell + +func (h plCellHeap) Len() int { return len(h) } +func (h plCellHeap) Less(i, j int) bool { return h[i].max > h[j].max } +func (h plCellHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *plCellHeap) Push(x any) { *h = append(*h, x.(plCell)) } +func (h *plCellHeap) Pop() any { + old := *h + n := len(old) + c := old[n-1] + *h = old[:n-1] + return c +} + +// segDist returns the Euclidean distance from point (px,py) to segment a–b. +func segDist(px, py, ax, ay, bx, by float64) float64 { + dx, dy := bx-ax, by-ay + if dx != 0 || dy != 0 { + t := ((px-ax)*dx + (py-ay)*dy) / (dx*dx + dy*dy) + switch { + case t > 1: + ax, ay = bx, by + case t > 0: + ax, ay = ax+dx*t, ay+dy*t + } + } + dx, dy = px-ax, py-ay + return math.Sqrt(dx*dx + dy*dy) } // pointInRing reports whether p is inside the polygon ring (ray casting). diff --git a/internal/engine/portrayal/primitive.go b/internal/engine/portrayal/primitive.go index b59a6bd..f66d184 100644 --- a/internal/engine/portrayal/primitive.go +++ b/internal/engine/portrayal/primitive.go @@ -78,9 +78,17 @@ type SymbolHalo struct { // split and the OBSTRN/WRECKS shallow/deep swap against the live safety contour // without a re-bake. SoundingDepthM / DangerDepthM are NaN for ordinary symbols. type SymbolCall struct { - Anchor geo.LatLon - SymbolName string - RotationDeg float32 + Anchor geo.LatLon + // CentreOnArea marks the PRIMARY centred-area symbol of an area feature, so the + // client centres the glyph on the representative point (S-52 PresLib §8.5.1: the + // primary centred symbol sits at the pivot "so it is evident which area the symbol + // applies to"). The "…RES" symbols carry a built-in corner-pivot offset meant to + // FAN OUT additional symbols ("an offset entry-restricted symbol with a subscript + // !"); applying it to the primary throws its glyph ~100px off its area. Set on the + // first centred symbol only — additional symbols keep the offset and fan out. + CentreOnArea bool + SymbolName string + RotationDeg float32 // RotationTrueNorth marks RotationDeg as referenced to TRUE NORTH (S-52 PresLib // Part I §9.2 ROT case 3 — rotation taken from an S-57 attribute like ORIENT), so // the mark must rotate WITH the chart as it turns. False means screen-referenced diff --git a/internal/engine/portrayal/s101build.go b/internal/engine/portrayal/s101build.go index 094ac71..6bb1bb9 100644 --- a/internal/engine/portrayal/s101build.go +++ b/internal/engine/portrayal/s101build.go @@ -234,6 +234,22 @@ func (b *S101Builder) buildFeature(f *s57.Feature, stream string) FeatureBuild { } } } + // Centred-area symbol placement (S-52 PresLib §8.5.1): the pivot point is the + // area's representative point (sg.Anchor), where the FIRST/primary centred symbol + // sits "so it is evident which area the symbol applies to"; ADDITIONAL symbols + // keep their catalogue pivot offset to fan out and "prevent overwriting" (the + // spec's "a centred traffic arrow and an offset entry-restricted symbol"). The + // "…RES" symbols are authored with a corner pivot for that fan-out, which throws + // the PRIMARY ~100px off its area; centring the first one restores §8.5.1. + if g.kind == geomArea { + for i, p := range prims { + if sc, ok := p.(SymbolCall); ok && sc.Anchor == sg.Anchor { + sc.CentreOnArea = true + prims[i] = sc + break // only the primary sits on the point; the rest fan out + } + } + } if cat == 0 { cat = displayStandard // no display-category band emitted (e.g. text-only) } diff --git a/web/src/chart-canvas/s52-style.mjs b/web/src/chart-canvas/s52-style.mjs index d93d100..54161a0 100644 --- a/web/src/chart-canvas/s52-style.mjs +++ b/web/src/chart-canvas/s52-style.mjs @@ -171,10 +171,17 @@ export function soundingsIconImage(mariner) { // changing the safety contour needs no re-bake. Non-danger symbols use `symbol_name`. export function pointSymbolImage(mariner) { const sfc = mariner.safetyContour ?? 10; - return ["case", + const name = ["case", ["all", ["has", "sym_deep"], [">", ["coalesce", ["get", "danger_depth"], 0], sfc]], ["get", "sym_deep"], ["get", "symbol_name"]]; + // pivot_center: the lone centred-area restriction symbol is drawn from the "ctr:" + // image variant (glyph centred on the point) instead of the catalogue pivot, + // which is at a corner to fan multiple restrictions out (see bake CentreOnArea). + return ["case", + ["==", ["coalesce", ["get", "pivot_center"], 0], 1], + ["concat", "ctr:", name], + name]; } // The dotted CHBLK foul boundary (OBSTRN/WRECKS) is shown only where the diff --git a/web/src/chart-canvas/sprite-builder.mjs b/web/src/chart-canvas/sprite-builder.mjs index 9e2db19..79dfa20 100644 --- a/web/src/chart-canvas/sprite-builder.mjs +++ b/web/src/chart-canvas/sprite-builder.mjs @@ -20,11 +20,23 @@ export class SpriteBuilder { // a synthesized sounding (`snd:…`), a composited glyph list (comma-joined), or // a single centred point symbol. Returns ImageData or null. imageFor(id) { - return id.startsWith("snd:") ? this.synthSounding(id) + return id.startsWith("ctr:") ? this.centredGlyph(id.slice(4)) + : id.startsWith("snd:") ? this.synthSounding(id) : id.indexOf(",") >= 0 ? this.compositeSounding(id) : this.centredSymbol(id); } + // centredGlyph centres the GLYPH's bounding box on the point, ignoring the + // catalogue pivot — used for a lone centred-area symbol (pivot_center) whose + // corner pivot would otherwise throw the glyph far off its area. The rendered + // cell is the glyph cropped to its content, so drawing it into a w×h canvas and + // letting MapLibre centre that canvas puts the glyph centre on the point. + centredGlyph(name) { + const c = this.sprite[name]; + if (!c) return null; + return this.rawCell(this.spriteImg, c); + } + // Build a sounding number in non-metric units from a synthesized name // `snd:::` (see soundingsIconImage). Converts the // baked metres depth, formats it as S-52 SNDFRM04 column glyphs, and reuses