Skip to content

Commit 30db686

Browse files
beetlebugorgclaude
andcommitted
fix(portrayal): centre area symbols on the centroid, not the pole of inaccessibility
S-52 PresLib §8.5.3 specifies the area representative point is the CENTRE OF GRAVITY (centroid) by default, using another point only when the centroid falls outside the area. We always used the pole of inaccessibility (polylabel). For a wide rectangle the pole is not unique — it slides along the horizontal mid-line — so a centred symbol (e.g. the area TS_PAD tidal-stream diamond) landed visibly off-centre even though its box looked symmetric. areaLabelPoint now returns the ring centroid when it lies inside the even-odd area, falling back to the polylabel search for concave/holed shapes where the centroid is outside. Rectangular and convex areas now centre exactly (the area TS_PAD diamond, and the quality-of-data boxes, sit dead-centre); concave shapes keep the robust pole point. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 92cf22b commit 30db686

1 file changed

Lines changed: 49 additions & 0 deletions

File tree

internal/engine/portrayal/build.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,15 @@ func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) {
222222
return geo.LatLon{}, false
223223
}
224224
ext := rings[0]
225+
// S-52 §8.5.3: the representative point is the area's CENTRE OF GRAVITY by
226+
// default; only when the centroid falls outside the area (concave / holed
227+
// shapes) is another point used. Prefer the centroid when it's inside — it
228+
// sits at the true centre, whereas the pole of inaccessibility below drifts
229+
// off-centre along a wide shape's mid-line (a wide rectangle's pole is not
230+
// unique), which left centred symbols visibly off-centre.
231+
if c, ok := ringCentroid(ext); ok && pointInRingsEvenOdd(c, rings) {
232+
return c, true
233+
}
225234
minLat, minLon := math.Inf(1), math.Inf(1)
226235
maxLat, maxLon := math.Inf(-1), math.Inf(-1)
227236
var sumLat, sumLon float64
@@ -298,6 +307,46 @@ func areaLabelPoint(rings [][]geo.LatLon) (geo.LatLon, bool) {
298307
return geo.LatLon{Lat: best.y, Lon: best.x / kx}, true
299308
}
300309

310+
// ringCentroid returns the area centroid (centre of gravity) of a polygon ring
311+
// via the shoelace formula. ok is false for a degenerate (zero-area) ring. The
312+
// ring may be open or closed; edges wrap. Computed in raw lon/lat — the slight
313+
// cos(lat) skew is immaterial for a centring point over a chart-sized area.
314+
func ringCentroid(ring []geo.LatLon) (geo.LatLon, bool) {
315+
n := len(ring)
316+
if n < 3 {
317+
return geo.LatLon{}, false
318+
}
319+
var a, cx, cy float64
320+
for i := 0; i < n; i++ {
321+
j := (i + 1) % n
322+
cross := ring[i].Lon*ring[j].Lat - ring[j].Lon*ring[i].Lat
323+
a += cross
324+
cx += (ring[i].Lon + ring[j].Lon) * cross
325+
cy += (ring[i].Lat + ring[j].Lat) * cross
326+
}
327+
if math.Abs(a) < 1e-12 {
328+
return geo.LatLon{}, false
329+
}
330+
a *= 0.5
331+
return geo.LatLon{Lat: cy / (6 * a), Lon: cx / (6 * a)}, true
332+
}
333+
334+
// pointInRingsEvenOdd reports whether p is inside the even-odd union of rings
335+
// (exterior boundary + holes): inside the exterior AND outside every hole.
336+
func pointInRingsEvenOdd(p geo.LatLon, rings [][]geo.LatLon) bool {
337+
inside := false
338+
for _, ring := range rings {
339+
n := len(ring)
340+
for i, j := 0, n-1; i < n; j, i = i, i+1 {
341+
if (ring[i].Lat > p.Lat) != (ring[j].Lat > p.Lat) &&
342+
p.Lon < (ring[j].Lon-ring[i].Lon)*(p.Lat-ring[i].Lat)/(ring[j].Lat-ring[i].Lat)+ring[i].Lon {
343+
inside = !inside
344+
}
345+
}
346+
}
347+
return inside
348+
}
349+
301350
// plCell is one square candidate region in the polylabel search (scaled space).
302351
type plCell struct {
303352
x, y, half float64 // centre and half-size

0 commit comments

Comments
 (0)