Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/engine/bake/bake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
18 changes: 18 additions & 0 deletions internal/engine/portrayal/anchor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
192 changes: 134 additions & 58 deletions internal/engine/portrayal/build.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package portrayal

import (
"container/heap"
"fmt"
"math"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -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).
Expand Down
14 changes: 11 additions & 3 deletions internal/engine/portrayal/primitive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions internal/engine/portrayal/s101build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 8 additions & 1 deletion web/src/chart-canvas/s52-style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion web/src/chart-canvas/sprite-builder.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:<unit>:<palette>:<deci-metres>` (see soundingsIconImage). Converts the
// baked metres depth, formats it as S-52 SNDFRM04 column glyphs, and reuses
Expand Down