Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions docs/design/visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ Each decision lives at the cheapest layer that can express it correctly:

The tile-build numbers are set generously (the cap well above any budget, floors below any style threshold that might move) precisely so the style side can be retuned without touching them.

## Standards mode is the off-switch for every deviation on this page

The `standards` style option renders strict S-52 portrayal instead of the chart's own: every mechanism above — budgets, `topOfCell`, decoration legibility floors, size tokens and ramps — is a deliberate departure from the standards, and standards mode neutralizes them all in one place ([`visibility.ts`](../../style/layers/visibility.ts), `visibility(standards)`). Symbols draw at their fixed S-52 size, decorations draw whenever their symbol does, visibility comes from the SCAMIN-derived `std_minzoom` the tiles carry per feature ([zoom.md](zoom.md)), and sectors switch to display-fixed 20 mm arcs drawn as rotated sprites ([`sectors.ts`](../../style/layers/sectors.ts)) — the portrayal this page argues against, kept available because seeing the standard is how the departures stay honest. Two ceilings are accepted rather than papered over: the destructive tile cap still bounds what dense cells carry below z13, and an all-scales class exists only from its tile floor.

## Safety guarantees

- A hazard inside any supported safety depth is always in the tiles, and exempt from its budget when it breaches the mariner's setting.
Expand Down
2 changes: 2 additions & 0 deletions docs/design/zoom.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ The tiles carry this decision's inputs in S-52's own vocabulary, both DEM-derive

“Derived” combines the home-band floor with the class's SCAMIN steps: the first zoom at which the standards would show the feature, assuming coverage in every band. Each departure includes its rationale so a later audit does not have to reconstruct or relitigate the decision.

The derived floor is not documentation-only: `SeamarkZoomRules.getStandardMinZoom` bakes it into every `seamark` and `light` feature as `std_minzoom` (0 = SCAMIN NOT SET), and the style's standards mode thresholds on it at runtime ([`visibility.ts`](../../style/layers/visibility.ts)). The tile floor stays the Minzoom column — everywhere at or below the derived value, so the strict filter always has the feature to work with. The one exception runs the other way: an all-scales class (`std_minzoom` 0) still only exists from its tile floor, so standards mode shows TSS linework from z2 and conspicuous landmarks from z6, not from z0.
Comment thread
bkeepers marked this conversation as resolved.
Outdated

| Type(s) | Minzoom | Derived | Note |
| --------------------------------------------- | -------------------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TSS lanes / lines / boundaries | 2 | any (NOT SET) | ✓ the linework is the passage-planning signal and NOT SET licenses any floor; z2 matches OpenSeaMap-vector |
Expand Down
51 changes: 43 additions & 8 deletions src/main/java/Lights.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@
import org.locationtech.jts.geom.*;

/**
* Turns a light's OSM sector tags into arc and leg lines on the ground.
* Turns a light's OSM sector tags into arc and leg lines on the ground, and into a point per sector
* and per sector limit carrying the angles those figures span.
*
* <p>S-52 fixes the arc to the display — 20 mm radius whatever the scale (PresLib 4.0.4,
* `LIGHTS06`) — but on a chart the reader zooms, a display-fixed figure is the one thing moving
* against everything else. So the arc is ordinary ground geometry at a nominal radius, scaling with
* the chart like its neighbours, and the style stops drawing it at the zoom where the radius
* outgrows the screen. The radius is a drawing size, not the light's range: nobody navigates by
* being inside the arc, and the range is stated in the characteristic.
*
* <p>A line cannot hold a constant screen size, so the points are what a display-fixed portrayal
* draws from: the light's position plus the bearings to rotate a sprite to.
*/
public class Lights {

Expand Down Expand Up @@ -41,13 +45,34 @@ private LightGeometry(Geometry geometry, Map<String, Object> attrs) {
/** A coloured sector, drawn as an arc at the light's nominal radius. */
static LightGeometry sector(
Geometry arc, String color, String visibility, String range, boolean extended) {
return new LightGeometry(arc, sectorAttrs("sector", color, visibility, range, extended));
}

/** The same sector as a point at the light, for a portrayal fixed to the display. */
static LightGeometry sectorPoint(
Point at,
String color,
String visibility,
String range,
double start,
double end,
boolean extended) {
Map<String, Object> attrs = sectorAttrs("sector_point", color, visibility, range, extended);
attrs.put("sector_start", start);
attrs.put("sector_end", end);
attrs.put("sector_width", width(start, end));
return new LightGeometry(at, attrs);
}

private static Map<String, Object> sectorAttrs(
String subtype, String color, String visibility, String range, boolean extended) {
Map<String, Object> attrs = new HashMap<>();
attrs.put("subtype", "sector");
attrs.put("subtype", subtype);
if (extended) attrs.put("extended", true);
if (color != null) attrs.put("color", color);
if (visibility != null) attrs.put("visibility", visibility);
if (range != null) attrs.put("range", range);
return new LightGeometry(arc, attrs);
return attrs;
}

/** One radial leg, at a bearing where some sector begins or ends. */
Expand All @@ -57,6 +82,15 @@ static LightGeometry leg(Geometry line, String range) {
if (range != null) attrs.put("range", range);
return new LightGeometry(line, attrs);
}

/** The same limit as a point at the light, for a portrayal fixed to the display. */
static LightGeometry limit(Point at, double bearing, String range) {
Map<String, Object> attrs = new HashMap<>();
attrs.put("subtype", "limit");
attrs.put("bearing", bearing);
if (range != null) attrs.put("range", range);
return new LightGeometry(at, attrs);
}
}

/** Sector width in degrees, going clockwise from start to end. */
Expand Down Expand Up @@ -115,13 +149,13 @@ public static List<LightGeometry> extractLightGeometries(SourceFeature sf, Strin
if (from.equals(to) || (from == 0 && to == 360)) continue;
boolean extended = overlappedByWider(from, to, limits);
double radius = (extended ? EXTENDED_SCALE : 1) * arcRadius / metersPerWorldUnit;
String color = Seamark.resolveLightColor(segment.get("colour"));
String visibility = segment.get("visibility");
String range = segment.get("range");
results.add(
LightGeometry.sector(
createArc(center, from, to, radius),
Seamark.resolveLightColor(segment.get("colour")),
segment.get("visibility"),
segment.get("range"),
extended));
createArc(center, from, to, radius), color, visibility, range, extended));
results.add(LightGeometry.sectorPoint(center, color, visibility, range, from, to, extended));
}

// Adjacent sectors share a limit, and one leg per bearing is enough. Bearings normalize
Expand All @@ -145,6 +179,7 @@ public static List<LightGeometry> extractLightGeometries(SourceFeature sf, Strin
double legRadius = EXTENDED_SCALE * arcRadius / metersPerWorldUnit;
for (Map.Entry<Double, String> leg : legs.entrySet()) {
results.add(LightGeometry.leg(createLeg(center, leg.getKey(), legRadius), leg.getValue()));
results.add(LightGeometry.limit(center, leg.getKey(), leg.getValue()));
}

return results;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/Seamap.java
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ private void doProcessFeature(SourceFeature sf, FeatureCollector features) {
if (type != null) {
// add seamark to vector tile
attrs.put("osm_id", sf.id());
// the floor a strict S-52 portrayal would use, for a style that offers one
attrs.put("std_minzoom", SeamarkZoomRules.getStandardMinZoom(attrs));
// anyGeometry() makes a polygon of any closed way, which is wrong for the types that are
// linear however they're drawn — a TSS lane or a cable loop is never an area.
FeatureCollector.Feature feature =
Expand Down Expand Up @@ -258,6 +260,8 @@ private void doProcessFeature(SourceFeature sf, FeatureCollector features) {
lightFeature.setId(featureId(sf));
lightFeature.setAttr("osm_id", sf.id());
lightFeature.setAttr("type", type);
// sector geometry appears with the light it belongs to, never on a floor of its own
lightFeature.setAttr("std_minzoom", attrs.get("std_minzoom"));
lightGeom.attrs.forEach((k, v) -> lightFeature.setAttr(k, v));
lightFeature.setMinZoom(SeamarkZoomRules.getLightMinZoom(attrs));
}
Expand Down
107 changes: 90 additions & 17 deletions src/main/java/SeamarkZoomRules.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,79 @@ public class SeamarkZoomRules {
Map.entry("fog_signal", 10),
Map.entry("anchorage", 9));

/**
* The same floors as the standards put them: the home band's scale plus the class's SCAMIN steps
* (S-57 UOC Table 2.5), which is where the chart's own floors deliberately depart. The derivation
* and every departure are tabulated in docs/design/zoom.md.
*/
private static final Map<String, Integer> STANDARD_FLOORS =
Map.ofEntries(
Map.entry("light_major", 5),
Map.entry("light_minor", 11),
Map.entry("fog_signal", 12),
Map.entry("platform", 5),
Map.entry("anchorage", 12),
Map.entry("cable_area", 10),
Map.entry("pipeline_area", 10),
Map.entry("marine_farm", 10),
Map.entry("buoy_lateral", 12),
Map.entry("beacon_lateral", 12),
Map.entry("buoy_special_purpose", 12),
Map.entry("beacon_special_purpose", 12),
Map.entry("mooring", 14),
Map.entry("harbour", 11),
Map.entry("small_craft_facility", 15));

/**
* The strict floor: the zoom at which the S-57/S-52 derivation in docs/design/zoom.md would first
* show the feature, band compilation and SCAMIN together. 0 is SCAMIN NOT SET — all scales, which
* in practice means the feature's own {@link #getMinZoom}, since nothing exists below it.
* Wherever SCAMIN does set a floor this is the later of the two, so a style thresholding on it is
* always asking for a feature the tile carries.
*
* @param attrs Map containing seamark attributes (type, category, etc.)
* @return the standards-derived minimum zoom
*/
public static int getStandardMinZoom(Map<String, Object> attrs) {
String type = (String) attrs.get("type");
String category = (String) attrs.get("category");

int base;
if (type == null) {
base = chartFloor(type, category, attrs);
} else if (isHazard(type)) {
// the contextual derivation is itself the standard — UDWHAZ and S-4 B-404, not a departure
base = hazardMinZoom(type, category, attrs);
} else if (type.startsWith("separation_")) {
base = 0; // the whole TSS carries SCAMIN NOT SET
} else if ("landmark".equals(type)) {
// CONVIS promotes to STANDARD and a lit LNDMRK takes no SCAMIN; a plain one is Coastal detail
base = isSteeredBy(attrs) ? 0 : 11;
} else if (STANDARD_FLOORS.containsKey(type)) {
base = STANDARD_FLOORS.get(type);
} else if (isRestrictedArea(type)) {
base = 6;
} else if (isMediumHighPriorityType(type)) {
base = 9;
} else {
base = chartFloor(type, category, attrs);
}
return promoteByRange(base, attrs, 5, 6);
}

/**
* Get the minimum zoom level for a given seamark based on its type and attributes.
*
* @param attrs Map containing seamark attributes (type, category, etc.)
* @return minimum zoom level (0-14)
*/
public static int getMinZoom(Map<String, Object> attrs) {
String type = (String) attrs.get("type");
String category = (String) attrs.get("category");
int base = chartFloor((String) attrs.get("type"), (String) attrs.get("category"), attrs);
return promoteByRange(base, attrs, 4, 6);
}

/** The chart's own floor for a type, before a light's reach promotes it. */
private static int chartFloor(String type, String category, Map<String, Object> attrs) {
int base;
if (type == null) {
base = 8; // default
Expand All @@ -64,29 +127,39 @@ public static int getMinZoom(Map<String, Object> attrs) {
base = 8;
}

// A conspicuous landmark is part of what a mariner steers by (CONVIS promotes to the
// STANDARD display category in S-52), a lit one is an aid in its own right (LNDMRK carrying
// a light takes no SCAMIN), and wind turbines are conspicuous by what they are — the tag is
// rarely present, and without this a wind farm is empty sea below z10.
// Wind turbines are conspicuous by what they are — the conspicuity tag is rarely present, and
// without this a wind farm is empty sea below z10.
if ("landmark".equals(type)
&& ("conspicuous".equals(attrs.get("seamark:landmark:conspicuity"))
|| attrs.get("light") != null
|| "windmotor".equals(category)
|| "windmill".equals(category))) {
&& (isSteeredBy(attrs) || "windmotor".equals(category) || "windmill".equals(category))) {
base = Math.min(base, 6);
}

// A light's reach outranks how its host happens to be typed: plenty of real lighthouses are
// tagged light_minor or sit on plain beacons. The S-52 major-light test is 10 M nominal
// range (LIGHTS06); 15 M is landfall class. Range only ever promotes, never demotes.
Object range = attrs.get("light_range");
if (range instanceof Number n) {
if (n.doubleValue() >= 15) return Math.min(base, 4);
if (n.doubleValue() >= 10) return Math.min(base, 6);
return base;
}

/**
* A light's reach outranks how its host happens to be typed: plenty of real lighthouses are
* tagged light_minor or sit on plain beacons. The S-52 major-light test is 10 M nominal range
* (LIGHTS06); 15 M is landfall class. Range only ever promotes, never demotes.
*/
private static int promoteByRange(
int base, Map<String, Object> attrs, int landfallZoom, int majorZoom) {
if (attrs.get("light_range") instanceof Number n) {
if (n.doubleValue() >= 15) return Math.min(base, landfallZoom);
if (n.doubleValue() >= 10) return Math.min(base, majorZoom);
}
return base;
}

/**
* A landmark a mariner steers by: conspicuous (CONVIS promotes to the STANDARD display category
* in S-52) or carrying a light, which makes it an aid in its own right and takes its SCAMIN away.
*/
private static boolean isSteeredBy(Map<String, Object> attrs) {
return "conspicuous".equals(attrs.get("seamark:landmark:conspicuity"))
|| attrs.get("light") != null;
}

/**
* Floor for a light's sector arcs and legs: z8 for style headroom (the zoom a sector actually
* draws at is a style threshold, kept above this so it can move without a planet build), but
Expand Down
70 changes: 70 additions & 0 deletions src/test/java/LightsTest.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.onthegomap.planetiler.reader.SimpleFeature;
Expand Down Expand Up @@ -145,6 +146,75 @@ void northLegIsNotPaintedTwice() {
assertEquals(3, of(all, "leg").size(), "270, north and 90 — never north twice");
}

/** Every sector is also a point at the light, carrying the angles it spans. */
@Test
void emitsOnePointPerSector() {
var all =
sectorsOf(
light(
"seamark:light:1:colour", "red",
"seamark:light:1:sector_start", "10",
"seamark:light:1:sector_end", "70",
"seamark:light:2:colour", "green",
"seamark:light:2:sector_start", "70",
"seamark:light:2:sector_end", "180"));
var points = of(all, "sector_point");
assertEquals(2, points.size());
for (var point : points) {
assertEquals("Point", point.geometry.getGeometryType());
}
var red = points.stream().filter(g -> "red".equals(g.attrs.get("color"))).findFirst().get();
assertEquals(10.0, red.attrs.get("sector_start"));
assertEquals(70.0, red.attrs.get("sector_end"));
assertEquals(60.0, red.attrs.get("sector_width"));
}

/** A point's width wraps through north rather than going negative. */
@Test
void pointWidthWrapsThroughNorth() {
var all =
sectorsOf(light("seamark:light:1:sector_start", "350", "seamark:light:1:sector_end", "20"));
assertEquals(30.0, of(all, "sector_point").get(0).attrs.get("sector_width"));
}

/** The smaller of an overlapping pair is marked on the point too. */
@Test
void marksTheSmallerOfTwoOverlappingSectorPoints() {
var points =
of(
sectorsOf(
light(
"seamark:light:1:sector_start", "0",
"seamark:light:1:sector_end", "180",
"seamark:light:2:sector_start", "80",
"seamark:light:2:sector_end", "100")),
"sector_point");
var wide = points.stream().filter(g -> g.attrs.get("sector_width").equals(180.0)).findFirst();
var narrow = points.stream().filter(g -> g.attrs.get("sector_width").equals(20.0)).findFirst();
assertFalse(wide.get().attrs.containsKey("extended"));
assertTrue((Boolean) narrow.get().attrs.get("extended"));
}

/** One point per deduped limit, at a bearing normalized into [0, 360). */
@Test
void emitsOnePointPerLimit() {
var all =
sectorsOf(
light(
"seamark:light:1:sector_start", "270",
"seamark:light:1:sector_end", "360",
"seamark:light:2:sector_start", "0",
"seamark:light:2:sector_end", "90"));
var limits = of(all, "limit");
assertEquals(3, limits.size(), "270, north and 90 — never north twice");
assertEquals(
List.of(0.0, 90.0, 270.0),
limits.stream().map(g -> (Double) g.attrs.get("bearing")).sorted().toList());
for (var limit : limits) {
assertEquals("Point", limit.geometry.getGeometryType());
}
}

/** An all-round light gets a flare, never an arc. */
@Test
void skipsLightsWithNoRealSector() {
Expand Down
Loading