diff --git a/docs/design/visibility.md b/docs/design/visibility.md index 05f7e80..576d448 100644 --- a/docs/design/visibility.md +++ b/docs/design/visibility.md @@ -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. diff --git a/docs/design/zoom.md b/docs/design/zoom.md index 4575102..613aede 100644 --- a/docs/design/zoom.md +++ b/docs/design/zoom.md @@ -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 ([`index.ts`](../../style/layers/index.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. + | 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 | diff --git a/src/main/java/Lights.java b/src/main/java/Lights.java index 1715837..d1d3733 100644 --- a/src/main/java/Lights.java +++ b/src/main/java/Lights.java @@ -4,7 +4,8 @@ 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. * *

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 @@ -12,6 +13,9 @@ * 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. + * + *

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 { @@ -41,13 +45,34 @@ private LightGeometry(Geometry geometry, Map 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 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 sectorAttrs( + String subtype, String color, String visibility, String range, boolean extended) { Map 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. */ @@ -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 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. */ @@ -115,13 +149,13 @@ public static List 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 @@ -145,6 +179,7 @@ public static List extractLightGeometries(SourceFeature sf, Strin double legRadius = EXTENDED_SCALE * arcRadius / metersPerWorldUnit; for (Map.Entry 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; diff --git a/src/main/java/Seamap.java b/src/main/java/Seamap.java index 4cb7ef6..5733e32 100644 --- a/src/main/java/Seamap.java +++ b/src/main/java/Seamap.java @@ -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 = @@ -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)); } diff --git a/src/main/java/SeamarkZoomRules.java b/src/main/java/SeamarkZoomRules.java index 2b40384..50ae2bf 100644 --- a/src/main/java/SeamarkZoomRules.java +++ b/src/main/java/SeamarkZoomRules.java @@ -32,6 +32,66 @@ 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 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 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. * @@ -39,9 +99,12 @@ public class SeamarkZoomRules { * @return minimum zoom level (0-14) */ public static int getMinZoom(Map 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 attrs) { int base; if (type == null) { base = 8; // default @@ -64,29 +127,39 @@ public static int getMinZoom(Map 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 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 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 diff --git a/src/test/java/LightsTest.java b/src/test/java/LightsTest.java index ad87f13..48b6038 100644 --- a/src/test/java/LightsTest.java +++ b/src/test/java/LightsTest.java @@ -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; @@ -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() { diff --git a/src/test/java/SeamapTest.java b/src/test/java/SeamapTest.java index 0d32326..bf7ba51 100644 --- a/src/test/java/SeamapTest.java +++ b/src/test/java/SeamapTest.java @@ -22,17 +22,21 @@ class SeamapTest { private static final GeometryFactory GF = new GeometryFactory(); - private static List seamarkFeatures(SimpleFeature sf) { + private static List layerFeatures(SimpleFeature sf, String layer) { FeatureCollector collector = new FeatureCollector.Factory(PlanetilerConfig.defaults(), Stats.inMemory()).get(sf); new Seamap().processFeature(sf, collector); List out = new ArrayList<>(); for (FeatureCollector.Feature f : collector) { - if ("seamark".equals(f.getLayer())) out.add(f); + if (layer.equals(f.getLayer())) out.add(f); } return out; } + private static List seamarkFeatures(SimpleFeature sf) { + return layerFeatures(sf, "seamark"); + } + /** A point source is already its own label; a second centroid would draw the name twice. */ @Test void pointLandmarkEmitsOneFeature() { @@ -60,6 +64,32 @@ void polygonLandmarkEmitsLabelPoint() { assertEquals(2, seamarkFeatures(sf).size()); } + /** + * A sector light writes both portrayals — arcs and legs on the ground, points at the light — and + * all of them appear with the mark, on its standard floor. + */ + @Test + void sectorLightsEmitBothPortrayalsUnderTheHostsStandardFloor() { + Map tags = new HashMap<>(); + tags.put("seamark:type", "light_minor"); + tags.put("seamark:light:1:colour", "red"); + tags.put("seamark:light:1:sector_start", "10"); + tags.put("seamark:light:1:sector_end", "70"); + SimpleFeature sf = + SimpleFeature.create(GF.createPoint(new Coordinate(11.0, 55.0)), tags, "osm", null, 1); + + var lights = layerFeatures(sf, "light"); + var subtypes = lights.stream().map(f -> f.getAttrsAtZoom(14).get("subtype")).toList(); + assertTrue(subtypes.contains("sector"), subtypes.toString()); + assertTrue(subtypes.contains("leg"), subtypes.toString()); + assertTrue(subtypes.contains("sector_point"), subtypes.toString()); + assertTrue(subtypes.contains("limit"), subtypes.toString()); + for (FeatureCollector.Feature light : lights) { + assertEquals(11, light.getAttrsAtZoom(14).get("std_minzoom")); + } + assertEquals(11, seamarkFeatures(sf).get(0).getAttrsAtZoom(14).get("std_minzoom")); + } + /** A seamark point at tile-pixel x/y, classified the way processFeature classifies it. */ private static VectorTile.Feature seamark(String name, double x, double y, Object... keyValues) { Map attrs = new HashMap<>(); diff --git a/src/test/java/SeamarkZoomRulesTest.java b/src/test/java/SeamarkZoomRulesTest.java index 08a457c..a13e273 100644 --- a/src/test/java/SeamarkZoomRulesTest.java +++ b/src/test/java/SeamarkZoomRulesTest.java @@ -1,6 +1,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -85,6 +87,111 @@ void lightGeometryWaitsForItsHost() { assertEquals(13, SeamarkZoomRules.getLightMinZoom(attrs("type", "rock", "near_shore", true))); } + /** Band plus SCAMIN, with the classes the chart draws early held to their derived floor. */ + @Test + void standardFloorsFollowTheDerivation() { + assertEquals(11, SeamarkZoomRules.getStandardMinZoom(attrs("type", "light_minor"))); + assertEquals(12, SeamarkZoomRules.getStandardMinZoom(attrs("type", "buoy_lateral"))); + assertEquals(12, SeamarkZoomRules.getStandardMinZoom(attrs("type", "beacon_special_purpose"))); + assertEquals(12, SeamarkZoomRules.getStandardMinZoom(attrs("type", "fog_signal"))); + assertEquals(12, SeamarkZoomRules.getStandardMinZoom(attrs("type", "anchorage"))); + assertEquals(14, SeamarkZoomRules.getStandardMinZoom(attrs("type", "mooring"))); + assertEquals(15, SeamarkZoomRules.getStandardMinZoom(attrs("type", "small_craft_facility"))); + assertEquals(11, SeamarkZoomRules.getStandardMinZoom(attrs("type", "harbour"))); + assertEquals(9, SeamarkZoomRules.getStandardMinZoom(attrs("type", "buoy_cardinal"))); + assertEquals(9, SeamarkZoomRules.getStandardMinZoom(attrs("type", "beacon_isolated_danger"))); + assertEquals(5, SeamarkZoomRules.getStandardMinZoom(attrs("type", "platform"))); + assertEquals(6, SeamarkZoomRules.getStandardMinZoom(attrs("type", "restricted_area"))); + // the area classes the restricted-area list would otherwise sweep up carry two SCAMIN steps + assertEquals(10, SeamarkZoomRules.getStandardMinZoom(attrs("type", "cable_area"))); + assertEquals(10, SeamarkZoomRules.getStandardMinZoom(attrs("type", "marine_farm"))); + } + + /** SCAMIN NOT SET is every scale, linework and fills alike. */ + @Test + void tssCarriesNoScamin() { + assertEquals(0, SeamarkZoomRules.getStandardMinZoom(attrs("type", "separation_lane"))); + assertEquals(0, SeamarkZoomRules.getStandardMinZoom(attrs("type", "separation_boundary"))); + assertEquals(0, SeamarkZoomRules.getStandardMinZoom(attrs("type", "separation_zone"))); + assertEquals(0, SeamarkZoomRules.getStandardMinZoom(attrs("type", "separation_roundabout"))); + } + + /** A light's reach promotes it under the standards too, one band later than the chart's. */ + @Test + void standardFloorsPromoteByRange() { + assertEquals(5, SeamarkZoomRules.getStandardMinZoom(attrs("type", "light_major"))); + assertEquals( + 5, SeamarkZoomRules.getStandardMinZoom(attrs("type", "light_minor", "light_range", 15))); + assertEquals( + 6, SeamarkZoomRules.getStandardMinZoom(attrs("type", "light_minor", "light_range", 10))); + assertEquals( + 11, SeamarkZoomRules.getStandardMinZoom(attrs("type", "light_minor", "light_range", 5))); + } + + /** The contextual hazard derivation is the standard, so both floors agree. */ + @Test + void hazardFloorsAreAlreadyTheStandard() { + assertEquals(8, SeamarkZoomRules.getStandardMinZoom(attrs("type", "rock"))); + assertEquals( + 13, SeamarkZoomRules.getStandardMinZoom(attrs("type", "rock", "near_shore", true))); + assertEquals(11, SeamarkZoomRules.getStandardMinZoom(attrs("type", "wreck", "depth", 42.0))); + assertEquals( + 6, SeamarkZoomRules.getStandardMinZoom(attrs("type", "wreck", "category", "dangerous"))); + } + + /** Conspicuity or a light takes a landmark's SCAMIN away; being a turbine does not. */ + @Test + void standardLandmarksSplitByConspicuityAlone() { + assertEquals( + 11, SeamarkZoomRules.getStandardMinZoom(attrs("type", "landmark", "category", "tower"))); + assertEquals( + 0, + SeamarkZoomRules.getStandardMinZoom( + attrs("type", "landmark", "seamark:landmark:conspicuity", "conspicuous"))); + assertEquals( + 0, + SeamarkZoomRules.getStandardMinZoom( + attrs("type", "landmark", "category", "tower", "light", "Fl.5s"))); + assertEquals( + 11, + SeamarkZoomRules.getStandardMinZoom(attrs("type", "landmark", "category", "windmotor"))); + } + + /** + * Wherever SCAMIN sets a floor at all, the feature reaches the tile by then — a style filtering + * on the standard floor never asks for something the tile doesn't carry. The all-scales classes + * are the exception by construction: they arrive at their own floor, since no tile exists below + * z0. + */ + @Test + void theChartFloorNeverTrailsTheStandardFloor() { + for (String type : + List.of( + "light_major", + "light_minor", + "fog_signal", + "platform", + "anchorage", + "cable_area", + "restricted_area", + "buoy_lateral", + "buoy_cardinal", + "mooring", + "harbour", + "landmark", + "small_craft_facility", + "separation_lane", + "rock", + "wreck")) { + Map attrs = attrs("type", type); + int standard = SeamarkZoomRules.getStandardMinZoom(attrs); + if (standard == 0) continue; + assertTrue( + SeamarkZoomRules.getMinZoom(attrs) <= standard, + type + " arrives after the standards would draw it"); + } + } + /** A plain tower is Coastal detail; what a mariner steers by keeps the early floor. */ @Test void landmarksSplitByConspicuity() { diff --git a/style/README.md b/style/README.md index 9e62f13..94dd894 100644 --- a/style/README.md +++ b/style/README.md @@ -15,7 +15,7 @@ const map = new maplibregl.Map({ }); ``` -`style()` is async: land hillshading comes from the VersaTiles elevation tiles, and the builder fetches their TileJSON. Options: `tiles` (seamark TileJSON URL), `seascape`, `versatiles`, `language`, `spriteBase`, `hillshade` (on by default; `false` to skip, or an object to tune the shading), and the seascape passthroughs — `flavor` (overrides merged over its `day`), `unit`, `safety`, `shading`, and the `dem`/`vector`/`coverage` source id overrides. +`style()` is async: land hillshading comes from the VersaTiles elevation tiles, and the builder fetches their TileJSON. Options: `tiles` (seamark TileJSON URL), `seascape`, `versatiles`, `language`, `spriteBase`, `hillshade` (on by default; `false` to skip, or an object to tune the shading), `standards` (off by default; strict S-52 portrayal — fixed symbol sizes, SCAMIN-derived visibility, display-fixed sector arcs), and the seascape passthroughs — `flavor` (overrides merged over its `day`), `unit`, `safety`, `shading`, and the `dem`/`vector`/`coverage` source id overrides. ## Composed @@ -37,7 +37,7 @@ const map = new maplibregl.Map({ style /* ... */ }); ``` - `sources({ url? })` — the `seamap` vector source (defaults to `https://tiles.openwaters.io/seamap/tiles.json`). -- `layers({ font? })` — the chart layers, split into `areas` and `symbols` to preserve draw order around your land layers. `font` renames glyph fontstacks (`"Noto Sans Regular"`) to match your glyph server. +- `layers({ font?, safety?, unit?, standards? })` — the chart layers, split into `areas` and `symbols` to preserve draw order around your land layers. `font` renames glyph fontstacks (`"Noto Sans Regular"`) to match your glyph server; `safety`, `unit`, and `standards` mean the same as `style()`'s options of those names. - `sprite(base)` — the `style.sprite` entry pointing at wherever you serve the sheet. When tags compose a colour combination the sheet doesn't carry, the layers fall back to the shape's `generic` icon in the style itself (a `coalesce` of `image` expressions), so unusual marks never render as nothing. diff --git a/style/__snapshots__/index.test.ts.snap b/style/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000..1319cbc --- /dev/null +++ b/style/__snapshots__/index.test.ts.snap @@ -0,0 +1,8105 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`keeps the chart portrayal byte for byte 1`] = ` +{ + "areas": [ + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "anchorage", + "anchor_berth", + "mooring", + "dredged_area", + "marine_farm", + "harbour", + ], + ], + ], + "id": "allowed-areas", + "paint": { + "line-color": [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "anchorage", + "anchor_berth", + "mooring", + ], + ], + ], + "#ec008c", + "black", + ], + "line-dasharray": [ + 4, + 4, + ], + "line-opacity": 0.8, + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 6, + 0.5, + 10, + 1.5, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "anchorage", + "anchor_berth", + "mooring", + "marine_farm", + ], + ], + ], + "id": "allowed-areas-labels", + "layout": { + "icon-image": [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "anchorage", + "anchor_berth", + "mooring", + ], + ], + ], + "freenauticalchart:anchor", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "crustaceans", + "oysters_mussels", + "pearl_culture", + ], + ], + ], + "freenauticalchart:shellfish", + "freenauticalchart:marine-farm", + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 0.2, + 12, + 0.8, + ], + "symbol-placement": "line", + "symbol-spacing": 90, + }, + "paint": { + "icon-opacity": 0.8, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "restricted_area", + "military_area", + "production_area", + "cable_area", + "dumping_ground", + "pipeline_area", + ], + ], + ], + "id": "restricted-areas", + "paint": { + "line-color": [ + "case", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "nature_reserve", + "bird_sanctuary", + "game_reserve", + "seal_sanctuary", + "fish_sanctuary", + "ecological_reserve", + "essa", + "pssa", + ], + ], + ], + "green", + "#ec008c", + ], + "line-dasharray": [ + 4, + 2, + ], + "line-opacity": 0.8, + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 6, + 0.5, + 10, + 1.5, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "restricted_area", + "military_area", + "production_area", + ], + ], + ], + "id": "restricted-areas-label", + "layout": { + "symbol-placement": "line", + "text-field": [ + "get", + "name", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-size": 11, + }, + "paint": { + "text-color": [ + "case", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "nature_reserve", + "bird_sanctuary", + "game_reserve", + "seal_sanctuary", + "fish_sanctuary", + "ecological_reserve", + "essa", + "pssa", + ], + ], + ], + "green", + "#ec008c", + ], + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "restricted_area", + "military_area", + "production_area", + "cable_area", + "dumping_ground", + "pipeline_area", + ], + ], + ], + "id": "restricted-areas-fill", + "paint": { + "fill-color": [ + "case", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "nature_reserve", + "bird_sanctuary", + "game_reserve", + "seal_sanctuary", + "fish_sanctuary", + "ecological_reserve", + "essa", + "pssa", + ], + ], + ], + "green", + "#ec008c", + ], + "fill-opacity": 0.04, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "fill", + }, + { + "filter": [ + "let", + "restriction", + [ + "coalesce", + [ + "get", + [ + "concat", + "seamark:", + [ + "get", + "type", + ], + ":restriction", + ], + ], + [ + "get", + "seamark:restriction", + ], + [ + "get", + "restriction", + ], + "", + ], + [ + "any", + [ + "==", + [ + "get", + "type", + ], + "military_area", + ], + [ + "==", + [ + "get", + "category", + ], + "military", + ], + [ + "in", + "no_entry", + [ + "var", + "restriction", + ], + ], + [ + "in", + "restricted_entry", + [ + "var", + "restriction", + ], + ], + [ + "in", + "no_anchoring", + [ + "var", + "restriction", + ], + ], + ], + ], + "id": "restricted-areas-fill-pattern", + "paint": { + "fill-opacity": [ + "case", + [ + "==", + [ + "get", + "type", + ], + "military_area", + ], + 0.1, + [ + "==", + [ + "get", + "category", + ], + "military", + ], + 0.1, + 0.4, + ], + "fill-pattern": [ + "let", + "restriction", + [ + "coalesce", + [ + "get", + [ + "concat", + "seamark:", + [ + "get", + "type", + ], + ":restriction", + ], + ], + [ + "get", + "seamark:restriction", + ], + [ + "get", + "restriction", + ], + "", + ], + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "military_area", + ], + "freenauticalchart:military", + [ + "==", + [ + "get", + "category", + ], + "military", + ], + "freenauticalchart:military", + [ + "in", + "no_entry", + [ + "var", + "restriction", + ], + ], + "freenauticalchart:no-entry", + [ + "in", + "restricted_entry", + [ + "var", + "restriction", + ], + ], + "freenauticalchart:no-entry", + [ + "in", + "no_anchoring", + [ + "var", + "restriction", + ], + ], + "freenauticalchart:no-anchor", + "", + ], + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "fill", + }, + ], + "symbols": [ + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "Polygon", + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "!", + [ + "all", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "boom", + "shark_net", + ], + ], + ], + ], + ], + ], + "id": "hazard-areas-fill", + "minzoom": 8, + "paint": { + "fill-color": "#61b7ff", + "fill-opacity": 0.2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "fill", + }, + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "Polygon", + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "!", + [ + "all", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "boom", + "shark_net", + ], + ], + ], + ], + ], + ], + "id": "hazard-areas", + "minzoom": 8, + "paint": { + "line-color": [ + "case", + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + -9999, + ], + 2, + ], + "#333", + "#768c97", + ], + "line-dasharray": [ + "case", + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + -9999, + ], + 2, + ], + [ + "literal", + [ + 1, + 1.5, + ], + ], + [ + "literal", + [ + 4, + 2, + ], + ], + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 0.6, + 14, + 1.4, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "Point", + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + 9999, + ], + 2, + ], + [ + ">=", + [ + "coalesce", + [ + "get", + "surrounding_depth", + ], + -9999, + ], + 2, + ], + [ + "!", + [ + "==", + [ + "coalesce", + [ + "get", + "near_shore", + ], + false, + ], + true, + ], + ], + ], + "id": "isolated-dangers", + "paint": { + "circle-opacity": 0, + "circle-radius": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 6, + 12, + 10, + ], + "circle-stroke-color": "#ec008c", + "circle-stroke-opacity": 0.85, + "circle-stroke-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 1, + 12, + 1.4, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "circle", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "rock", + ], + [ + "any", + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + 9999, + ], + 2, + ], + ], + [ + "!", + [ + "in", + [ + "coalesce", + [ + "get", + "seamark:rock:water_level", + ], + [ + "get", + "seamark:water_level", + ], + [ + "get", + "water_level", + ], + "", + ], + [ + "literal", + [ + "dry", + "part-submerged", + "part_submerged", + ], + ], + ], + ], + ], + "id": "rocks_outline", + "layout": { + "icon-image": "freenauticalchart:obstruction", + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.33, + 11, + 0.8, + ], + }, + "minzoom": 8, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "rock", + ], + [ + "any", + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + 9999, + ], + 2, + ], + ], + ], + "id": "rocks", + "layout": { + "icon-image": [ + "match", + [ + "coalesce", + [ + "get", + "seamark:rock:water_level", + ], + [ + "get", + "seamark:water_level", + ], + [ + "get", + "water_level", + ], + "", + ], + "covers", + "freenauticalchart:rock-covers", + "awash", + "freenauticalchart:rock-awash", + [ + "dry", + "part-submerged", + "part_submerged", + ], + "freenauticalchart:rock-dry", + "freenauticalchart:rock-submerged", + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.33, + 11, + 1, + ], + }, + "minzoom": 8, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "boom", + "shark_net", + ], + ], + ], + ], + "id": "floating-barriers", + "paint": { + "line-color": "#3d3d3d", + "line-dasharray": [ + 4, + 2, + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 0.6, + 14, + 1.6, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "boom", + "shark_net", + ], + ], + ], + ], + "id": "floating-barriers-label", + "layout": { + "symbol-placement": "line", + "symbol-spacing": 250, + "text-field": [ + "case", + [ + "has", + "name", + ], + [ + "get", + "name", + ], + [ + "==", + [ + "get", + "category", + ], + "shark_net", + ], + "Shark Net", + "Floating Barrier", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-offset": [ + 0, + -0.8, + ], + "text-optional": true, + "text-size": 10, + }, + "minzoom": 12, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.5, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "wreck", + "obstruction", + ], + ], + ], + [ + "any", + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + [ + "<=", + [ + "coalesce", + [ + "get", + "depth", + ], + [ + "get", + "seabed_depth", + ], + 9999, + ], + 2, + ], + ], + [ + "any", + [ + "==", + [ + "geometry-type", + ], + "Point", + ], + [ + "!", + [ + "all", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "boom", + "shark_net", + ], + ], + ], + ], + ], + ], + ], + "id": "obstructions", + "layout": { + "icon-image": [ + "case", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "foul_ground", + "distributed_remains", + ], + ], + ], + "freenauticalchart:foul", + [ + "==", + [ + "get", + "type", + ], + "obstruction", + ], + "freenauticalchart:obstruction", + [ + "==", + [ + "get", + "category", + ], + "non-dangerous", + ], + "freenauticalchart:wreck-non-dangerous", + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "hull_showing", + "mast_showing", + ], + ], + ], + "freenauticalchart:wreck-hull_showing", + "freenauticalchart:wreck-dangerous", + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.33, + 11, + 1, + ], + }, + "minzoom": 6, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "has", + "depth", + ], + [ + ">=", + [ + "get", + "depth", + ], + 0, + ], + ], + "id": "hazard-depths", + "layout": { + "text-field": [ + "to-string", + [ + "get", + "depth", + ], + ], + "text-font": [ + "Noto Sans Italic", + ], + "text-justify": "auto", + "text-optional": true, + "text-radial-offset": 1.7, + "text-size": 10, + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top", + ], + }, + "minzoom": 12, + "paint": { + "text-color": [ + "case", + [ + "<=", + [ + "get", + "depth", + ], + 2, + ], + "#000", + "#768c97", + ], + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.5, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "has", + "seamark:seabed_area:surface", + ], + "id": "seabed", + "layout": { + "text-field": [ + "get", + "seamark:seabed_area:surface", + ], + "text-font": [ + "Noto Sans Italic", + ], + "text-letter-spacing": 0.1, + "text-max-width": 5, + "text-padding": 10, + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 9, + 13, + 11, + ], + }, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "cable_submarine", + "pipeline_submarine", + ], + ], + ], + "id": "cables-pipes", + "paint": { + "line-opacity": 0.5, + "line-pattern": [ + "case", + [ + "==", + [ + "get", + "type", + ], + "pipeline_submarine", + ], + "freenauticalchart:pipeline", + "freenauticalchart:cable", + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 4, + 12, + 8, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "separation_zone", + ], + "id": "TSS-separation-zone", + "paint": { + "fill-color": "#ec008c", + "fill-opacity": 0.15, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "fill", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "separation_crossing", + ], + "id": "TSS-crossing-zone", + "paint": { + "fill-color": "#ec008c", + "fill-opacity": 0.1, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "fill", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "separation_lane", + ], + "id": "TSS-separation-lane-arrows", + "layout": { + "icon-image": "freenauticalchart:TSS-arrow", + "icon-overlap": "always", + "icon-padding": 0, + "icon-rotate": 90, + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 2, + 0.15, + 6, + 0.15, + 14, + 0.6, + ], + "symbol-placement": "line", + "symbol-spacing": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 6, + 40, + 14, + 180, + ], + }, + "paint": { + "icon-opacity": 0.4, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "separation_boundary", + ], + "id": "TSS-separation-boundary", + "paint": { + "line-color": "#ec008c", + "line-dasharray": [ + 4, + 4, + ], + "line-opacity": 0.8, + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 2, + 1, + 6, + 1, + 10, + 2, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "separation_line", + ], + "id": "TSS-separation-line", + "paint": { + "line-color": "#ec008c", + "line-opacity": 0.4, + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 2, + 1, + 6, + 1, + 10, + 2, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "ferry_route", + ], + "id": "ferry", + "layout": { + "line-join": "round", + }, + "paint": { + "line-color": "#7c8af6", + "line-dasharray": [ + 2, + 2, + ], + "line-opacity": 0.6, + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 0.7, + 14, + 2, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "navigation_line", + ], + "id": "navigation-lines", + "paint": { + "line-color": "black", + "line-dasharray": [ + 4, + 2, + ], + "line-width": 0.8, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "recommended_track", + ], + "id": "navigation-tracks", + "paint": { + "line-color": "gray", + "line-dasharray": [ + 4, + 2, + ], + "line-width": 1, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "shoreline_construction", + ], + "id": "shoreline-constructions", + "minzoom": 12, + "paint": { + "line-color": "#3d3d3d", + "line-dasharray": [ + "match", + [ + "coalesce", + [ + "get", + "seamark:shoreline_construction:water_level", + ], + [ + "get", + "seamark:water_level", + ], + [ + "get", + "water_level", + ], + "", + ], + [ + "covers", + "flooding", + "awash", + ], + [ + "literal", + [ + 2, + 2, + ], + ], + [ + "literal", + [ + 1, + 0, + ], + ], + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 0.6, + 16, + 1.8, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "==", + [ + "get", + "type", + ], + "pontoon", + ], + "id": "pontoons", + "minzoom": 12, + "paint": { + "line-color": "#3d3d3d", + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 0.6, + 16, + 1.8, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "line", + }, + { + "filter": [ + "all", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "pile", + "mooring", + ], + ], + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "piles", + "minzoom": 11, + "paint": { + "circle-radius": 3, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "circle", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "platform", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "platforms", + "layout": { + "icon-image": "freenauticalchart:platform", + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.4, + 10, + 1, + ], + "text-field": [ + "step", + [ + "zoom", + ], + "", + 12, + [ + "get", + "name", + ], + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-optional": true, + "text-radial-offset": 1, + "text-size": 11, + "text-variable-anchor": [ + "left", + "right", + "bottom", + "top", + ], + }, + "minzoom": 8, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "crane", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "cranes", + "layout": { + "icon-image": "freenauticalchart:crane", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 14, + 0.6, + 16, + 1, + ], + "symbol-sort-key": [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + }, + "minzoom": 14, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "rescue_station", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "rescue-stations", + "layout": { + "icon-image": "freenauticalchart:rescue", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 0.5, + 13, + 1, + ], + "symbol-sort-key": [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + }, + "minzoom": 10, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "radar_station", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "radar-stations", + "layout": { + "icon-image": "freenauticalchart:radar_scanner", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 11, + 0.6, + 14, + 1, + ], + "symbol-sort-key": [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + }, + "minzoom": 11, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "has", + "radio_station", + ], + "id": "radio_station", + "minzoom": 8, + "paint": { + "circle-opacity": 0, + "circle-radius": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 5, + 12, + 40, + ], + "circle-stroke-color": "#ec008c", + "circle-stroke-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 0.5, + 12, + 1.5, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "circle", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "small_craft_facility", + ], + [ + "!", + [ + "in", + [ + "get", + "access", + ], + [ + "literal", + [ + "no", + "private", + "permit", + "customers", + ], + ], + ], + ], + ], + "id": "small-craft-facilities", + "layout": { + "icon-image": [ + "match", + [ + "get", + "category", + ], + "slipway", + "freenauticalchart:poi-slipway", + "fuel_station", + "freenauticalchart:poi-fuel", + [ + "water_tap", + "drinking_water", + ], + "freenauticalchart:poi-water", + "pump-out", + "freenauticalchart:poi-pumpout", + [ + "visitor_berth", + "visitors_mooring", + "berth", + ], + "freenauticalchart:poi-berth", + "nautical_club", + "freenauticalchart:poi-club", + "boat_hoist", + "freenauticalchart:poi-hoist", + "boatyard", + "freenauticalchart:poi-boatyard", + "boat_storage", + "freenauticalchart:poi-boat-storage", + "toilets", + "freenauticalchart:poi-toilets", + "showers", + "freenauticalchart:poi-showers", + "laundrette", + "freenauticalchart:poi-laundry", + "provisions", + "freenauticalchart:poi-provisions", + "restaurant", + "freenauticalchart:poi-restaurant", + "chandler", + "freenauticalchart:poi-chandler", + "electricity", + "freenauticalchart:poi-electricity", + "access_point", + "freenauticalchart:poi-kayak", + "fish_cleaning", + "freenauticalchart:poi-fish-cleaning", + "fishing", + "freenauticalchart:poi-fishing", + "beach", + "freenauticalchart:poi-beach", + "freenauticalchart:poi-generic", + ], + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 14, + 0.8, + 17, + 1, + ], + "text-field": [ + "case", + [ + "==", + [ + "get", + "category", + ], + "fuel_station", + ], + [ + "coalesce", + [ + "get", + "fuel", + ], + "", + ], + "", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-optional": true, + "text-size": 10, + "text-variable-anchor-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 14, + [ + "literal", + [ + "left", + [ + 2.03, + -0, + ], + "right", + [ + -2.03, + -0, + ], + "bottom", + [ + 0, + -2.03, + ], + "top", + [ + 0, + 2.03, + ], + ], + ], + 17, + [ + "literal", + [ + "left", + [ + 2.35, + -0, + ], + "right", + [ + -2.35, + -0, + ], + "bottom", + [ + 0, + -2.35, + ], + "top", + [ + 0, + 2.35, + ], + ], + ], + ], + }, + "minzoom": 14, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "Point", + ], + [ + "==", + [ + "get", + "type", + ], + "harbour", + ], + [ + "in", + [ + "get", + "category", + ], + [ + "literal", + [ + "marina", + "fishing", + ], + ], + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "harhours", + "layout": { + "icon-image": [ + "case", + [ + "==", + [ + "get", + "category", + ], + "marina", + ], + "freenauticalchart:poi-marina", + [ + "==", + [ + "get", + "category", + ], + "fishing", + ], + "freenauticalchart:poi-fishing-harbour", + "", + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.1, + 12, + 1, + ], + "text-field": [ + "get", + "name", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-optional": true, + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + 9, + 12, + 12, + ], + "text-variable-anchor-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 8, + [ + "literal", + [ + "left", + [ + 1.72, + -0, + ], + "right", + [ + -1.72, + -0, + ], + "bottom", + [ + 0, + -1.72, + ], + "top", + [ + 0, + 1.72, + ], + ], + ], + 12, + [ + "literal", + [ + "left", + [ + 1.96, + -0, + ], + "right", + [ + -1.96, + -0, + ], + "bottom", + [ + 0, + -1.96, + ], + "top", + [ + 0, + 1.96, + ], + ], + ], + ], + }, + "minzoom": 8, + "paint": { + "icon-opacity": [ + "case", + [ + "in", + [ + "get", + "access", + ], + [ + "literal", + [ + "no", + "private", + "permit", + "customers", + ], + ], + ], + 0.5, + 1, + ], + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + "text-opacity": [ + "case", + [ + "in", + [ + "get", + "access", + ], + [ + "literal", + [ + "no", + "private", + "permit", + "customers", + ], + ], + ], + 0.5, + 1, + ], + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "any", + [ + "has", + "seamark:light:colour", + ], + [ + "has", + "seamark:light:1:colour", + ], + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "lights", + "layout": { + "icon-anchor": "top", + "icon-image": [ + "coalesce", + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "==", + [ + "coalesce", + [ + "get", + "seamark:light:category", + ], + [ + "get", + "seamark:light:1:category", + ], + "", + ], + "floodlight", + ], + "floodlight/", + "light/", + ], + [ + "get", + "light_color", + ], + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "==", + [ + "coalesce", + [ + "get", + "seamark:light:category", + ], + [ + "get", + "seamark:light:1:category", + ], + "", + ], + "floodlight", + ], + "floodlight/", + "light/", + ], + [ + "coalesce", + [ + "get", + "seamark:light:colour", + ], + [ + "get", + "seamark:light:1:colour", + ], + ], + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "==", + [ + "coalesce", + [ + "get", + "seamark:light:category", + ], + [ + "get", + "seamark:light:1:category", + ], + "", + ], + "floodlight", + ], + "floodlight/", + "light/", + ], + "generic", + ], + ], + ], + "icon-offset": [ + 0, + 2, + ], + "icon-overlap": "always", + "icon-rotate": -45, + "icon-rotation-alignment": "viewport", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 0.7, + 14, + 1, + ], + }, + "minzoom": 10, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "light_minor", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "light-minor", + "layout": { + "icon-image": "freenauticalchart:light-minor", + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.275, + 12, + 1, + ], + }, + "minzoom": 8, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "light_major", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "light-major", + "layout": { + "icon-image": "freenauticalchart:light-major", + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.275, + 11, + 1, + ], + }, + "minzoom": 6, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "has", + "seamark:fog_signal:category", + ], + [ + ">=", + [ + "zoom", + ], + 11, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "fogsignals", + "layout": { + "icon-image": "freenauticalchart:fogsignal", + "icon-overlap": "always", + "icon-rotate": 90, + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 0.7, + 14, + 1, + ], + }, + "minzoom": 10, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "subtype", + ], + "leg", + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "sector-legs", + "layout": { + "line-cap": "butt", + "line-join": "round", + }, + "maxzoom": 17, + "minzoom": 10, + "paint": { + "line-color": "#666", + "line-dasharray": [ + 2, + 3, + ], + "line-opacity": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 15, + 0.8, + 17, + 0, + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 0.8, + 14, + 1.2, + ], + }, + "source": "seamap", + "source-layer": "light", + "type": "line", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "subtype", + ], + "sector", + ], + [ + "!", + [ + "in", + [ + "coalesce", + [ + "get", + "visibility", + ], + "", + ], + [ + "literal", + [ + "obscured", + "part_obscured", + "partially_obscured", + "faint", + ], + ], + ], + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "sector-arc-casing", + "layout": { + "line-cap": "butt", + "line-join": "round", + }, + "maxzoom": 17, + "minzoom": 10, + "paint": { + "line-color": "#333", + "line-opacity": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 15, + 1, + 17, + 0, + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 3, + 14, + 5, + ], + }, + "source": "seamap", + "source-layer": "light", + "type": "line", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "subtype", + ], + "sector", + ], + [ + "!", + [ + "in", + [ + "coalesce", + [ + "get", + "visibility", + ], + "", + ], + [ + "literal", + [ + "obscured", + "part_obscured", + "partially_obscured", + "faint", + ], + ], + ], + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "sector-arc", + "layout": { + "line-cap": "butt", + "line-join": "round", + }, + "maxzoom": 17, + "minzoom": 10, + "paint": { + "line-color": [ + "match", + [ + "get", + "color", + ], + "green", + "#00a650", + "red", + "#ed1c24", + [ + "white", + "yellow", + "orange", + "amber", + ], + "#fab20b", + "#ec008c", + ], + "line-opacity": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 15, + 1, + 17, + 0, + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 1.5, + 14, + 3, + ], + }, + "source": "seamap", + "source-layer": "light", + "type": "line", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "subtype", + ], + "sector", + ], + [ + "!", + [ + "!", + [ + "in", + [ + "coalesce", + [ + "get", + "visibility", + ], + "", + ], + [ + "literal", + [ + "obscured", + "part_obscured", + "partially_obscured", + "faint", + ], + ], + ], + ], + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "sector-arc-obscured", + "layout": { + "line-cap": "butt", + "line-join": "round", + }, + "maxzoom": 17, + "minzoom": 10, + "paint": { + "line-color": "#666", + "line-dasharray": [ + 2, + 2, + ], + "line-opacity": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 15, + 1, + 17, + 0, + ], + "line-width": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 1, + 14, + 2, + ], + }, + "source": "seamap", + "source-layer": "light", + "type": "line", + }, + { + "filter": [ + "all", + [ + "has", + "topmark_shape", + ], + [ + ">=", + [ + "zoom", + ], + 10, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "topmarks", + "layout": { + "icon-anchor": "bottom", + "icon-image": [ + "coalesce", + [ + "image", + [ + "case", + [ + "has", + "topmark_color_pattern", + ], + [ + "concat", + "freenauticalchart:", + [ + "get", + "topmark_shape", + ], + "/", + [ + "get", + "topmark_color_pattern", + ], + "/", + [ + "get", + "topmark_color", + ], + ], + [ + "concat", + "freenauticalchart:", + [ + "get", + "topmark_shape", + ], + "/", + [ + "get", + "topmark_color", + ], + ], + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "get", + "topmark_shape", + ], + "/generic", + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "get", + "topmark_shape", + ], + ], + ], + ], + "icon-offset": [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + [ + "literal", + [ + 0, + -19.2, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pillar", + "spar", + "stake", + "pole", + "perch", + "post", + ], + ], + ], + [ + "literal", + [ + 0, + -22, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + "lattice", + "pile", + "tower", + ], + ], + ], + [ + "literal", + [ + 0, + -20, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "cairn", + ], + ], + ], + [ + "literal", + [ + 0, + -22, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "conical", + ], + ], + ], + [ + "literal", + [ + 0, + -12, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + ], + ], + ], + [ + "literal", + [ + 0, + -8, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "spherical", + "barrel", + "super-buoy", + ], + ], + ], + [ + "literal", + [ + 0, + -9, + ], + ], + [ + "literal", + [ + 0, + -26, + ], + ], + ], + "icon-overlap": "always", + "icon-rotate": [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pillar", + "barrel", + "conical", + "spar", + "can", + ], + ], + ], + 15, + 0, + ], + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.25, + 12, + 1, + ], + }, + "minzoom": 6, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "buoys", + "layout": { + "icon-anchor": "bottom", + "icon-image": [ + "coalesce", + [ + "image", + [ + "case", + [ + "all", + [ + "==", + [ + "get", + "shape", + ], + "withy", + ], + [ + "==", + [ + "get", + "category", + ], + "port", + ], + ], + "freenauticalchart:withy-port", + [ + "==", + [ + "get", + "shape", + ], + "withy", + ], + "freenauticalchart:withy-starboard", + "", + ], + ], + [ + "image", + [ + "case", + [ + "all", + [ + "has", + "color_pattern", + ], + [ + "!", + [ + "==", + [ + "get", + "color_pattern", + ], + "no", + ], + ], + ], + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "==", + [ + "get", + "shape", + ], + "lattice", + ], + "pile", + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + ], + ], + ], + "pile", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pole", + "perch", + "post", + ], + ], + ], + "stake", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "ice-buoy", + "ice_buoy", + ], + ], + ], + "super-buoy", + [ + "all", + [ + "!", + [ + "has", + "shape", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "light_float", + ], + "light_float", + "super-buoy", + ], + [ + "get", + "shape", + ], + ], + ], + "/", + [ + "get", + "color_pattern", + ], + "/", + [ + "get", + "color", + ], + ], + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + ], + ], + ], + "pile", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pole", + "perch", + "post", + ], + ], + ], + "stake", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "ice-buoy", + "ice_buoy", + ], + ], + ], + "super-buoy", + [ + "all", + [ + "!", + [ + "has", + "shape", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "light_float", + ], + "light_float", + "super-buoy", + ], + [ + "get", + "shape", + ], + ], + "/", + [ + "coalesce", + [ + "get", + "color", + ], + "generic", + ], + ], + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "==", + [ + "get", + "shape", + ], + "lattice", + ], + "pile", + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + ], + ], + ], + "pile", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pole", + "perch", + "post", + ], + ], + ], + "stake", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "ice-buoy", + "ice_buoy", + ], + ], + ], + "super-buoy", + [ + "all", + [ + "!", + [ + "has", + "shape", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "light_float", + ], + "light_float", + "super-buoy", + ], + [ + "get", + "shape", + ], + ], + ], + "/", + [ + "get", + "color", + ], + ], + ], + [ + "image", + [ + "concat", + "freenauticalchart:", + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + ], + ], + ], + "pile", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pole", + "perch", + "post", + ], + ], + ], + "stake", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "ice-buoy", + "ice_buoy", + ], + ], + ], + "super-buoy", + [ + "all", + [ + "!", + [ + "has", + "shape", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "light_float", + ], + "light_float", + "super-buoy", + ], + [ + "get", + "shape", + ], + ], + "/generic", + ], + ], + [ + "image", + [ + "case", + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + "freenauticalchart:stake/generic", + "freenauticalchart:pillar/generic", + ], + ], + ], + "icon-offset": [ + 0, + 4, + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.25, + 12, + 1, + ], + }, + "minzoom": 6, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "has", + "radar_reflector", + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "!=", + [ + "get", + "shape", + ], + "buoyant", + ], + [ + ">=", + [ + "zoom", + ], + 11, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "radar-reflectors", + "layout": { + "icon-anchor": "bottom", + "icon-image": "freenauticalchart:radar-reflector", + "icon-offset": [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + [ + "literal", + [ + 0, + -16, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "pillar", + "spar", + "stake", + "pole", + "perch", + "post", + ], + ], + ], + [ + "literal", + [ + 0, + -10, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "buoyant", + "lattice", + "pile", + "tower", + ], + ], + ], + [ + "literal", + [ + 0, + -10, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "cairn", + ], + ], + ], + [ + "literal", + [ + 0, + -22, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "conical", + ], + ], + ], + [ + "literal", + [ + 0, + -12, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + ], + ], + ], + [ + "literal", + [ + 0, + -8, + ], + ], + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "spherical", + "barrel", + "super-buoy", + ], + ], + ], + [ + "literal", + [ + 0, + -9, + ], + ], + [ + "literal", + [ + 0, + -26, + ], + ], + ], + "icon-overlap": "always", + "icon-rotate": -60, + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + 0.25, + 12, + 1, + ], + }, + "minzoom": 10, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "get", + "type", + ], + "landmark", + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + [ + "any", + [ + "!=", + [ + "get", + "category", + ], + "tower", + ], + [ + "has", + "color", + ], + [ + "has", + "name", + ], + [ + "in", + [ + "get", + "function", + ], + [ + "literal", + [ + "radio", + "radar", + "television", + "light_support", + "leading", + ], + ], + ], + ], + ], + "id": "landmarks", + "layout": { + "icon-anchor": "bottom", + "icon-image": [ + "case", + [ + "==", + [ + "get", + "seamark:landmark:conspicuity", + ], + "conspicuous", + ], + [ + "case", + [ + "in", + [ + "get", + "function", + ], + [ + "literal", + [ + "church", + "chapel", + ], + ], + ], + "freenauticalchart:convis/church", + [ + "match", + [ + "get", + "category", + ], + "cairn", + "freenauticalchart:convis/cairn-lmk", + "chimney", + "freenauticalchart:convis/chimney", + "column", + "freenauticalchart:convis/column", + "cross", + "freenauticalchart:convis/cross-lmk", + "dish_aerial", + "freenauticalchart:convis/dish_aerial", + "dome", + "freenauticalchart:convis/dome", + "flagstaff", + "freenauticalchart:convis/flagstaff", + "flare_stack", + "freenauticalchart:convis/flare-stack", + "mast", + "freenauticalchart:convis/mast", + "monument", + "freenauticalchart:convis/monument", + "obelisk", + "freenauticalchart:convis/obelisk", + "radar_scanner", + "freenauticalchart:convis/radar_scanner", + "spire", + "freenauticalchart:convis/spire", + "statue", + "freenauticalchart:convis/statue", + "tower", + "freenauticalchart:convis/tower-lmk", + "windmill", + "freenauticalchart:convis/windmill", + "windmotor", + "freenauticalchart:convis/windmotor", + "windsock", + "freenauticalchart:convis/windsock", + "freenauticalchart:convis/monument", + ], + ], + [ + "case", + [ + "in", + [ + "get", + "function", + ], + [ + "literal", + [ + "church", + "chapel", + ], + ], + ], + "freenauticalchart:church", + [ + "match", + [ + "get", + "category", + ], + "cairn", + "freenauticalchart:cairn-lmk", + "chimney", + "freenauticalchart:chimney", + "column", + "freenauticalchart:column", + "cross", + "freenauticalchart:cross-lmk", + "dish_aerial", + "freenauticalchart:dish_aerial", + "dome", + "freenauticalchart:dome", + "flagstaff", + "freenauticalchart:flagstaff", + "flare_stack", + "freenauticalchart:flare-stack", + "mast", + "freenauticalchart:mast", + "monument", + "freenauticalchart:monument", + "obelisk", + "freenauticalchart:obelisk", + "radar_scanner", + "freenauticalchart:radar_scanner", + "spire", + "freenauticalchart:spire", + "statue", + "freenauticalchart:statue", + "tower", + "freenauticalchart:tower-lmk", + "windmill", + "freenauticalchart:windmill", + "windmotor", + "freenauticalchart:windmotor", + "windsock", + "freenauticalchart:windsock", + "freenauticalchart:monument", + ], + ], + ], + "icon-offset": [ + "case", + [ + "==", + [ + "get", + "seamark:landmark:conspicuity", + ], + "conspicuous", + ], + [ + "literal", + [ + 0, + 6.4, + ], + ], + [ + "literal", + [ + 0, + 5.2, + ], + ], + ], + "icon-overlap": "always", + "icon-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + [ + "case", + [ + "==", + [ + "get", + "seamark:landmark:conspicuity", + ], + "conspicuous", + ], + 0.25, + 0.3076923076923077, + ], + 12, + 1, + ], + "text-field": [ + "step", + [ + "zoom", + ], + "", + 12, + [ + "case", + [ + "any", + [ + "has", + "seamark:light:colour", + ], + [ + "has", + "seamark:light:1:colour", + ], + ], + "", + [ + "!", + [ + "==", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + 0, + ], + ], + "", + [ + "get", + "name", + ], + ], + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-optional": true, + "text-padding": 8, + "text-size": 12, + "text-variable-anchor-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + [ + "literal", + [ + "left", + [ + 0.85, + -0, + ], + "right", + [ + -0.85, + -0, + ], + "bottom", + [ + 0, + -1.3, + ], + "top", + [ + 0, + 0.6, + ], + ], + ], + 16, + [ + "literal", + [ + "left", + [ + 1.82, + -0, + ], + "right", + [ + -1.82, + -0, + ], + "bottom", + [ + 0, + -3.3, + ], + "top", + [ + 0, + 1.15, + ], + ], + ], + ], + }, + "minzoom": 6, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "has", + "name", + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "ferry_route", + ], + ], + ], + ], + "id": "line_symbols", + "layout": { + "symbol-placement": "line", + "text-field": [ + "get", + "name", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 10, + 10, + 16, + 12, + ], + }, + "minzoom": 10, + "paint": { + "text-color": [ + "case", + [ + "==", + [ + "get", + "type", + ], + "ferry_route", + ], + "#7c8af6", + "#333", + ], + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "LineString", + ], + [ + "has", + "name", + ], + [ + "!", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "landmark", + "harbour", + "ferry_route", + ], + ], + ], + ], + ], + "id": "seamark-line-label", + "layout": { + "symbol-placement": "line-center", + "text-field": [ + "get", + "name", + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-letter-spacing": 0.1, + "text-line-height": 1.6, + "text-max-width": 5, + "text-offset": [ + 0, + -0.65, + ], + "text-pitch-alignment": "viewport", + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 13, + 10, + 16, + 13, + ], + }, + "minzoom": 13, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "==", + [ + "geometry-type", + ], + "Point", + ], + [ + "has", + "name", + ], + [ + "!", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "landmark", + "harbour", + ], + ], + ], + ], + [ + "!", + [ + "any", + [ + "has", + "seamark:light:colour", + ], + [ + "has", + "seamark:light:1:colour", + ], + ], + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + [ + "==", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + 0, + ], + ], + "id": "seamark-label", + "layout": { + "text-field": [ + "case", + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "format", + [ + "get", + "name", + ], + { + "text-font": [ + "literal", + [ + "Noto Sans Italic", + ], + ], + }, + ], + [ + "get", + "name", + ], + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-padding": 8, + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 10, + 16, + 13, + ], + "text-variable-anchor-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.7, + -0, + ], + "right", + [ + -1.7, + -0, + ], + "bottom", + [ + 0, + -1.7, + ], + "top", + [ + 0, + 1.7, + ], + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + "conical", + "spherical", + "barrel", + "super-buoy", + "ice-buoy", + "ice_buoy", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -0.6, + ], + "right", + [ + -1.5, + -0.6, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -1.2, + ], + "right", + [ + -1.5, + -1.2, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -0, + ], + "right", + [ + -1.5, + -0, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + ], + 16, + [ + "case", + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "rock", + "wreck", + "obstruction", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.4, + -0, + ], + "right", + [ + -1.4, + -0, + ], + "bottom", + [ + 0, + -1.4, + ], + "top", + [ + 0, + 1.4, + ], + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + "conical", + "spherical", + "barrel", + "super-buoy", + "ice-buoy", + "ice_buoy", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.1538461538461537, + -0.46153846153846156, + ], + "right", + [ + -1.1538461538461537, + -0.46153846153846156, + ], + "bottom", + [ + 0, + -2.4615384615384617, + ], + "top", + [ + 0, + 0.9230769230769231, + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.1538461538461537, + -0.9230769230769231, + ], + "right", + [ + -1.1538461538461537, + -0.9230769230769231, + ], + "bottom", + [ + 0, + -2.4615384615384617, + ], + "top", + [ + 0, + 0.9230769230769231, + ], + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.1538461538461537, + -0, + ], + "right", + [ + -1.1538461538461537, + -0, + ], + "bottom", + [ + 0, + -2.4615384615384617, + ], + "top", + [ + 0, + 0.9230769230769231, + ], + ], + ], + ], + ], + }, + "minzoom": 12, + "paint": { + "text-color": "#333", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "has", + "radar_transponder", + ], + [ + ">=", + [ + "zoom", + ], + 11, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "racon-labels", + "layout": { + "text-anchor": "top", + "text-field": [ + "case", + [ + "has", + "radar_transponder_group", + ], + [ + "concat", + "Racon(", + [ + "get", + "radar_transponder_group", + ], + ")", + ], + "Racon", + ], + "text-font": [ + "Noto Sans Italic", + ], + "text-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + [ + "literal", + [ + 0, + 0.85, + ], + ], + 16, + [ + "literal", + [ + 0, + 1.4, + ], + ], + ], + "text-optional": true, + "text-size": 10, + }, + "minzoom": 11, + "paint": { + "text-color": "#ec008c", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + { + "filter": [ + "all", + [ + "any", + [ + "has", + "seamark:light:colour", + ], + [ + "has", + "seamark:light:1:colour", + ], + ], + [ + ">=", + [ + "zoom", + ], + 11, + ], + [ + "<", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + [ + "step", + [ + "zoom", + ], + [ + "match", + [ + "get", + "family", + ], + "hazard", + 2, + "major_aid", + 1, + "minor_aid", + 1, + "harbour", + 1, + "structure", + 1, + 999, + ], + 9, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 4, + "major_aid", + 3, + "minor_aid", + 2, + "harbour", + 1, + "structure", + 2, + 999, + ], + 11, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 8, + "major_aid", + 6, + "minor_aid", + 4, + "harbour", + 3, + "structure", + 4, + 999, + ], + 13, + [ + "match", + [ + "get", + "family", + ], + "hazard", + 999, + "major_aid", + 999, + "minor_aid", + 999, + "harbour", + 999, + "structure", + 999, + 999, + ], + ], + ], + ], + "id": "lights-label", + "layout": { + "text-field": [ + "step", + [ + "zoom", + ], + [ + "case", + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "format", + [ + "get", + "light", + ], + { + "text-font": [ + "literal", + [ + "Noto Sans Italic", + ], + ], + }, + ], + [ + "format", + [ + "get", + "light", + ], + {}, + ], + ], + 12, + [ + "case", + [ + "!", + [ + "all", + [ + "has", + "name", + ], + [ + "==", + [ + "coalesce", + [ + "get", + "cell_rank", + ], + 0, + ], + 0, + ], + ], + ], + [ + "case", + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "format", + [ + "get", + "light", + ], + { + "text-font": [ + "literal", + [ + "Noto Sans Italic", + ], + ], + }, + ], + [ + "format", + [ + "get", + "light", + ], + {}, + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "format", + [ + "get", + "name", + ], + { + "font-scale": 1.15, + "text-font": [ + "literal", + [ + "Noto Sans Italic", + ], + ], + }, + " +", + {}, + [ + "get", + "light", + ], + { + "text-font": [ + "literal", + [ + "Noto Sans Italic", + ], + ], + }, + ], + [ + "format", + [ + "get", + "name", + ], + { + "font-scale": 1.15, + }, + " +", + {}, + [ + "get", + "light", + ], + {}, + ], + ], + ], + "text-font": [ + "Noto Sans Regular", + ], + "text-justify": "auto", + "text-padding": 4, + "text-size": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 12, + 10, + 16, + 14, + ], + "text-variable-anchor-offset": [ + "interpolate", + [ + "linear", + ], + [ + "zoom", + ], + 9, + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "landmark", + ], + [ + "literal", + [ + "left", + [ + 1, + -0, + ], + "right", + [ + -1, + -0, + ], + "bottom", + [ + 0, + -1.55, + ], + "top", + [ + 0, + 0.7, + ], + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "literal", + [ + "left", + [ + 0.3, + -0.3, + ], + "right", + [ + -0.3, + -0.3, + ], + "bottom", + [ + 0, + -1.15, + ], + "top", + [ + 0, + 1.15, + ], + ], + ], + [ + "literal", + [ + "left", + [ + 0.3, + -0, + ], + "right", + [ + -0.3, + -0, + ], + "bottom", + [ + 0, + -1.15, + ], + "top", + [ + 0, + 1.15, + ], + ], + ], + ], + 12, + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "landmark", + ], + [ + "literal", + [ + "left", + [ + 1.24, + -0, + ], + "right", + [ + -1.24, + -0, + ], + "bottom", + [ + 0, + -2.1, + ], + "top", + [ + 0, + 0.83, + ], + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + "conical", + "spherical", + "barrel", + "super-buoy", + "ice-buoy", + "ice_buoy", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -0.6, + ], + "right", + [ + -1.5, + -0.6, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -1.2, + ], + "right", + [ + -1.5, + -1.2, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.5, + -0, + ], + "right", + [ + -1.5, + -0, + ], + "bottom", + [ + 0, + -3.2, + ], + "top", + [ + 0, + 1.2, + ], + ], + ], + ], + 16, + [ + "case", + [ + "==", + [ + "get", + "type", + ], + "landmark", + ], + [ + "literal", + [ + "left", + [ + 1.56, + -0, + ], + "right", + [ + -1.56, + -0, + ], + "bottom", + [ + 0, + -2.83, + ], + "top", + [ + 0, + 1, + ], + ], + ], + [ + "any", + [ + "in", + "buoy", + [ + "get", + "type", + ], + ], + [ + "in", + "beacon", + [ + "get", + "type", + ], + ], + [ + "in", + [ + "get", + "type", + ], + [ + "literal", + [ + "light_float", + "light_vessel", + ], + ], + ], + ], + [ + "case", + [ + "in", + [ + "get", + "shape", + ], + [ + "literal", + [ + "can", + "conical", + "spherical", + "barrel", + "super-buoy", + "ice-buoy", + "ice_buoy", + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.0714285714285714, + -0.42857142857142855, + ], + "right", + [ + -1.0714285714285714, + -0.42857142857142855, + ], + "bottom", + [ + 0, + -2.2857142857142856, + ], + "top", + [ + 0, + 0.8571428571428571, + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.0714285714285714, + -0.8571428571428571, + ], + "right", + [ + -1.0714285714285714, + -0.8571428571428571, + ], + "bottom", + [ + 0, + -2.2857142857142856, + ], + "top", + [ + 0, + 0.8571428571428571, + ], + ], + ], + ], + [ + "literal", + [ + "left", + [ + 1.0714285714285714, + -0, + ], + "right", + [ + -1.0714285714285714, + -0, + ], + "bottom", + [ + 0, + -2.2857142857142856, + ], + "top", + [ + 0, + 0.8571428571428571, + ], + ], + ], + ], + ], + }, + "minzoom": 11, + "paint": { + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2, + }, + "source": "seamap", + "source-layer": "seamark", + "type": "symbol", + }, + ], +} +`; diff --git a/style/index.test.ts b/style/index.test.ts index c3172e3..b67ac20 100644 --- a/style/index.test.ts +++ b/style/index.test.ts @@ -12,6 +12,9 @@ import { chartLayers } from "./layers/index.ts"; const { areas, symbols } = layers(); const all = [...areas, ...symbols]; +const strict = chartLayers({ standards: true }); +const strictLayers = [...strict.areas, ...strict.symbols]; + it("produces a valid style", () => { const style: StyleSpecification = { version: 8, @@ -140,6 +143,11 @@ it("keeps layer ids and order stable", () => { ]); }); +// The chart's own portrayal is what ships; a second mode alongside it must not move it at all. +it("keeps the chart portrayal byte for byte", () => { + expect(chartLayers()).toMatchSnapshot(); +}); + describe("isolated dangers", () => { const layer = chartLayers({ safety: 2 }).symbols.find((l) => l.id === "isolated-dangers") as { filter: never; @@ -290,6 +298,129 @@ describe("thinning and decoration", () => { }); }); +// The strict S-52 portrayal: the standard's own answers instead of the chart's, so every +// deviation the chart makes deliberately has to be absent here. +describe("standards mode", () => { + const point = (properties: Record) => ({ type: 1, properties }) as never; + const draws = (id: string, zoom: number, properties: Record) => { + const layer = strictLayers.find((l) => l.id === id) as { filter: never }; + return featureFilter(layer.filter).filter({ zoom }, point(properties), undefined as never); + }; + + it("produces a valid style", () => { + const style: StyleSpecification = { + version: 8, + glyphs: "https://example.com/glyphs/{fontstack}/{range}.pbf", + sprite: [sprite("https://example.com/sprites")], + sources: sources(), + layers: strictLayers, + }; + expect(validateStyleMin(style)).toEqual([]); + }); + + // SCAMIN is the whole of S-52's scale rule, and the tiles carry the derived floor per feature + it("gates every seamap layer on the tiles' strict scale floor", () => { + const clause = JSON.stringify([">=", ["zoom"], ["coalesce", ["get", "std_minzoom"], 0]]); + for (const layer of strictLayers) { + expect(JSON.stringify((layer as { filter?: unknown }).filter), layer.id).toContain(clause); + } + expect(JSON.stringify(chartLayers())).not.toContain("std_minzoom"); + }); + + it("holds a feature back until its own SCAMIN floor, where the chart never does", () => { + const buoy = { type: "buoy_lateral", family: "minor_aid", std_minzoom: 14 }; + expect(draws("buoys", 12, buoy)).toBe(false); + expect(draws("buoys", 14, buoy)).toBe(true); + const chart = chartLayers().symbols.find((l) => l.id === "buoys") as { filter: never }; + expect(featureFilter(chart.filter).filter({ zoom: 12 }, point(buoy), undefined as never)).toBe( + true, + ); + }); + + it("draws light sectors as display-fixed arcs rather than ground geometry", () => { + const ids = strict.symbols.map((l) => l.id); + for (const id of ["sector-arc-start", "sector-arc-middle-casing", "sector-arc-end-obscured"]) { + expect(ids).toContain(id); + } + for (const id of ["sector-arc", "sector-arc-casing", "sector-arc-obscured"]) { + expect(ids).not.toContain(id); + } + // the arcs ride the sector points and their limits, not the tiles' arc and leg lines + const sector = { subtype: "sector_point", sector_start: 10, sector_end: 40, sector_width: 30 }; + expect(draws("sector-arc-start", 12, sector)).toBe(true); + expect(draws("sector-arc-start", 12, { ...sector, subtype: "sector" })).toBe(false); + expect(draws("sector-legs", 12, { subtype: "limit", bearing: 40 })).toBe(true); + expect(draws("sector-legs", 12, { subtype: "leg" })).toBe(false); + }); + + /** + * One sprite covers its own span and no more, so a sector wider than that needs all three + * copies — obscured sectors included, or a 100° obscured sector shows only its middle 40°. + */ + it("covers an obscured sector with the same three copies as a coloured one", () => { + const obscured = { + subtype: "sector_point", + visibility: "obscured", + sector_start: 10, + sector_end: 110, + sector_width: 100, + }; + for (const part of ["start", "middle", "end"]) { + expect(draws(`sector-arc-${part}-obscured`, 12, obscured), part).toBe(true); + expect(draws(`sector-arc-${part}`, 12, obscured), part).toBe(false); + } + }); + + // the sprite index picks the largest arc whose three copies fit, so the two must agree + it("keeps the sprite spans in step with the artwork", () => { + // the end copy rotates back by the sprite's span: ["-", sector_end, ["step", width, span0, …]] + const endRotate = (strictLayers.find((l) => l.id === "sector-arc-end") as { layout: never }) + .layout["icon-rotate"] as [string, unknown, [string, unknown, ...number[]]]; + // in the step, every other entry from the first output is a span + const spans = endRotate[2].slice(2).filter((_, i) => i % 2 === 0) as number[]; + expect(spans).toHaveLength(5); + spans.forEach((span, i) => { + const svg = readFileSync(`sprites/icons/arc-${i}-G.svg`, "utf8"); + const radians = Number(/sodipodi:end="([\d.]+)"/.exec(svg)?.[1]); + expect(Number(((radians * 180) / Math.PI).toFixed(2)), `arc-${i}`).toBe(span); + }); + }); + + // S-52 sizes every symbol in millimetres on the display; a zoom ramp is the chart's own idea + it("holds every symbol at a fixed size", () => { + const variants = [{}, { "seamark:landmark:conspicuity": "conspicuous" }]; + let checked = 0; + for (const layer of strictLayers) { + const layout = (layer as { layout?: Record }).layout; + for (const key of ["icon-size", "text-size"] as const) { + const size = layout?.[key]; + if (size === undefined) continue; + const compiled = createExpression(size); + if (compiled.result !== "success") throw new Error(`${layer.id}: ${key} failed`); + for (const properties of variants) { + const at = (zoom: number) => + compiled.value.evaluate({ zoom }, { type: 1, properties } as never) as number; + for (let zoom = 0; zoom <= 22; zoom += 0.25) { + expect(at(zoom), `${layer.id} ${key} at z${zoom}`).toBe(at(0)); + } + // the sheet is rasterized at display resolution, so an icon above 1 upsamples and blurs + if (key === "icon-size") expect(at(0), `${layer.id} icon-size`).toBeLessThanOrEqual(1); + } + checked++; + } + } + expect(checked).toBeGreaterThan(0); + }); + + // no density budget, no legibility floors: a symbol that passes SCAMIN draws, dressed + it("draws every mark and its decorations whatever the crowd", () => { + const buoy = { type: "buoy_lateral", family: "minor_aid", topmark_shape: "cone" }; + expect(draws("buoys", 8, { ...buoy, cell_rank: 40 })).toBe(true); + expect(draws("topmarks", 8, { ...buoy, cell_rank: 40 })).toBe(true); + expect(draws("seamark-label", 14, { ...buoy, name: "Nyhavn", cell_rank: 3 })).toBe(true); + }); +}); + const PRIVATE = ["no", "private", "permit", "customers"]; describe("restricted access", () => { @@ -417,7 +548,8 @@ describe.skipIf(!existsSync(spriteIndex))("sprite sheet", () => { node.forEach(walk); } }; - for (const layer of all) { + // both portrayals: the sector arcs only draw in standards mode, and their sprites still ship + for (const layer of [...all, ...strictLayers]) { walk((layer as { layout?: { "icon-image"?: unknown } }).layout?.["icon-image"]); walk((layer as { paint?: { "fill-pattern"?: unknown } }).paint?.["fill-pattern"]); } @@ -427,6 +559,15 @@ describe.skipIf(!existsSync(spriteIndex))("sprite sheet", () => { expect([...literals].filter((name) => !icons.has(name))).toEqual([]); }); + // The sector layers compose an arc name from a sprite index and a colour token, which the walk + // above cannot see, and there is no /generic fallback behind them — so spell the grid out. + it("contains every sector arc the standards portrayal can compose", () => { + const arcs = [0, 1, 2, 3, 4].flatMap((index) => + ["G", "R", "Y", "M", "casing", "obscured"].map((suffix) => `arc-${index}-${suffix}`), + ); + expect([...arcs, "sector-leg"].filter((name) => !icons.has(name))).toEqual([]); + }); + // style() adds fill patterns the layers() walk above never sees (the unsurveyed // water stipple); check every chart-sheet pattern in the whole style resolves it("contains every fill pattern the whole style references", async () => { diff --git a/style/index.ts b/style/index.ts index 6dbf135..2ae6f3c 100644 --- a/style/index.ts +++ b/style/index.ts @@ -65,6 +65,12 @@ export interface LayersOptions { safety?: number; /** Depth unit for hazard depth numerals, matching seascape's soundings. */ unit?: "m" | "ft" | "fm"; + /** + * Strict S-52 portrayal, off by default: fixed symbol sizes, SCAMIN-derived visibility, and + * light sectors as display-fixed arcs. The chart's own answers — density budgets, decoration + * legibility floors, size ramps, ground-geometry sectors — are what it ships with. + */ + standards?: boolean; } /** @@ -73,11 +79,11 @@ export interface LayersOptions { * fills) belong below land fills, `symbols` (hazards, buoys, lights, topmarks, * landmarks, labels) on top of everything. */ -export function layers({ font, safety, unit }: LayersOptions = {}): { +export function layers({ font, safety, unit, standards }: LayersOptions = {}): { areas: LayerSpecification[]; symbols: LayerSpecification[]; } { - const { areas, symbols } = chartLayers({ safety, unit }); + const { areas, symbols } = chartLayers({ safety, unit, standards }); if (font) { for (const layer of [...areas, ...symbols]) { const layout = "layout" in layer ? (layer.layout as { "text-font"?: string[] }) : undefined; @@ -162,6 +168,11 @@ export interface StyleOptions { unit?: Unit; /** Safety contour depth in metres, 0 = off (seascape). */ safety?: number; + /** + * Strict S-52 portrayal of the chart symbology, off by default: fixed symbol sizes, + * SCAMIN-derived visibility, and display-fixed light sector arcs. + */ + standards?: boolean; /** * Water shading: ENC-style vector depth bands by default — their edges coincide with the * contour lines by construction, and they stay crisp under overzoom. "relief" selects the @@ -192,6 +203,7 @@ export async function style({ flavor, unit, safety, + standards, shading = "bands", dem, vector, @@ -261,7 +273,7 @@ export async function style({ paint: { "fill-pattern": "freenauticalchart:unsurveyed" }, }); - const { areas, symbols } = layers({ font: versatilesFont, safety, unit }); + const { areas, symbols } = layers({ font: versatilesFont, safety, unit, standards }); // draw sea: replace versatiles' first two layers (background, water) with the // chart's own background, bathymetry, sea areas, and seamap land diff --git a/style/layers/areas.ts b/style/layers/areas.ts index 9b4c7e1..a99a1f4 100644 --- a/style/layers/areas.ts +++ b/style/layers/areas.ts @@ -1,5 +1,6 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; +import type { Visibility } from "./visibility.js"; /** Restricted-area colouring: conservation green, everything else chart magenta (RESARE04). */ const restrictionColor: ExpressionSpecification = [ @@ -39,7 +40,7 @@ const restriction: ExpressionSpecification = [ * across a coastline stops at the shore. Allowed areas (anchorages, moorings) draw before * restricted areas — RESARE outranks ACHARE (S-52 priority 5 vs 3) where they overlap. */ -export function areas(): LayerSpecification[] { +export function areas({ symbolSize }: Visibility): LayerSpecification[] { return [ { id: "allowed-areas", @@ -92,7 +93,7 @@ export function areas(): LayerSpecification[] { "icon-overlap": "always", "symbol-placement": "line", "symbol-spacing": 90, - "icon-size": ["interpolate", ["linear"], ["zoom"], 8, 0.2, 12, 0.8], + "icon-size": symbolSize(8, 0.2, 12, 0.8), }, paint: { "icon-opacity": 0.8 }, }, diff --git a/style/layers/hazards.ts b/style/layers/hazards.ts index 4080cc5..c38ac25 100644 --- a/style/layers/hazards.ts +++ b/style/layers/hazards.ts @@ -1,6 +1,6 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; -import { TOKEN, sizeRamp, withinBudget } from "./visibility.js"; +import { TOKEN, type Visibility } from "./visibility.js"; const HAZARD_TYPES = ["rock", "wreck", "obstruction"]; @@ -56,7 +56,11 @@ const navigableSurround = (safety: number): ExpressionSpecification => [ * `safety` is the safety depth in metres, defaulting to seascape's 2 m small-craft default; it * drives the isolated-danger highlight and the hazard-area boundary style. */ -export function hazards(safety = 2, unit: "m" | "ft" | "fm" = "m"): LayerSpecification[] { +export function hazards( + { sizeRamp, symbolSize, withinBudget }: Visibility, + safety = 2, + unit: "m" | "ft" | "fm" = "m", +): LayerSpecification[] { // the surveyed depth in the mariner's unit, floored toward shallower — the safe direction // (S-4 B-412); metres print as tagged, often with a decimal const depthText: ExpressionSpecification = @@ -334,7 +338,7 @@ export function hazards(safety = 2, unit: "m" | "ft" | "fm" = "m"): LayerSpecifi "text-letter-spacing": 0.1, "text-max-width": 5, "text-padding": 10, - "text-size": ["interpolate", ["linear"], ["zoom"], 8, 9, 13, 11], + "text-size": symbolSize(8, 9, 13, 11), }, paint: { "text-color": colors.label, diff --git a/style/layers/index.ts b/style/layers/index.ts index 07ebc52..2671c7d 100644 --- a/style/layers/index.ts +++ b/style/layers/index.ts @@ -1,4 +1,8 @@ -import type { LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; +import type { + ExpressionSpecification, + FilterSpecification, + LayerSpecification, +} from "@maplibre/maplibre-gl-style-spec"; import { areas } from "./areas.js"; import { hazards } from "./hazards.js"; import { routes } from "./routes.js"; @@ -7,7 +11,24 @@ import { lights } from "./lights.js"; import { sectors } from "./sectors.js"; import { marks } from "./marks.js"; import { labels } from "./labels.js"; -import { MAX_SAFETY_DEPTH } from "./visibility.js"; +import { MAX_SAFETY_DEPTH, visibility } from "./visibility.js"; + +/** + * The strict scale floor S-52 reads off SCAMIN, carried per feature by the tiles + * (`SeamarkZoomRules.java`). Coalesced to 0 so a chart built against older tiles still draws. + */ +const withinScale: ExpressionSpecification = [ + ">=", + ["zoom"], + ["coalesce", ["get", "std_minzoom"], 0], +]; + +function atStandardScale(layer: LayerSpecification): LayerSpecification { + if (!("source" in layer) || layer.source !== "seamap") return layer; + const own = "filter" in layer ? layer.filter : undefined; + const filter = (own ? ["all", own, withinScale] : withinScale) as FilterSpecification; + return { ...layer, filter } as LayerSpecification; +} /** * The chart symbology, split around the consumer's land fills: `areas` are sea areas that a @@ -17,8 +38,15 @@ import { MAX_SAFETY_DEPTH } from "./visibility.js"; * half. The other is symbol collision: MapLibre places symbols in *reverse* draw order, so the * later a layer appears here the more likely its label survives a crowded harbour. Labels last is * deliberate. + * + * `standards` swaps the chart's own portrayal for a strict S-52 one: fixed symbol sizes, + * SCAMIN-derived visibility, and light sectors as display-fixed arcs. */ -export function chartLayers({ safety, unit }: { safety?: number; unit?: "m" | "ft" | "fm" } = {}): { +export function chartLayers({ + safety, + unit, + standards = false, +}: { safety?: number; unit?: "m" | "ft" | "fm"; standards?: boolean } = {}): { areas: LayerSpecification[]; symbols: LayerSpecification[]; } { @@ -26,16 +54,23 @@ export function chartLayers({ safety, unit }: { safety?: number; unit?: "m" | "f // have thinned or excluded the hazard entirely, so a deeper setting would silently miss // hazards it appears to highlight. Clamp rather than honour a promise the data cannot keep. const clamped = Math.min(safety ?? 2, MAX_SAFETY_DEPTH); - return { - areas: areas(), + const v = visibility(standards); + const built = { + areas: areas(v), symbols: [ - ...hazards(clamped, unit), - ...routes(), - ...structures(), - ...lights(), - ...sectors(), - ...marks(), - ...labels(), + ...hazards(v, clamped, unit), + ...routes(v), + ...structures(v), + ...lights(v), + ...sectors(v), + ...marks(v), + ...labels(v), ], }; + if (!standards) return built; + // one pass rather than a clause in every module: SCAMIN governs every seamark the same way + return { + areas: built.areas.map(atStandardScale), + symbols: built.symbols.map(atStandardScale), + }; } diff --git a/style/layers/labels.ts b/style/layers/labels.ts index 4b2d500..7214256 100644 --- a/style/layers/labels.ts +++ b/style/layers/labels.ts @@ -1,7 +1,7 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; import { MARK, anchorOffsets, markOffsets } from "./placement.js"; -import { RAMP_FROM, TOKEN, decoration, topOfCell, withinBudget } from "./visibility.js"; +import { RAMP_FROM, TOKEN, type Visibility } from "./visibility.js"; const halo = { "text-halo-color": colors.halo, @@ -137,7 +137,12 @@ function landmarkIcon(folder: "" | "convis/"): ExpressionSpecification { * name entirely. Instead `landmarks` blanks its own name for lit features once `lights-label` is * drawing, and `lights-label` stacks name over characteristic. Keep the two together. */ -export function labels(): LayerSpecification[] { +export function labels({ + decoration, + symbolSize, + topOfCell, + withinBudget, +}: Visibility): LayerSpecification[] { return [ { id: "landmarks", @@ -210,15 +215,12 @@ export function labels(): LayerSpecification[] { // bolder in the convis/ art rather than an upscale. The artwork is the same artwork // though, so what it can survive shrinking to is the same on-screen floor for both — // hence the larger variant's smaller floor fraction. - "icon-size": [ - "interpolate", - ["linear"], - ["zoom"], + "icon-size": symbolSize( RAMP_FROM, ["case", convis, TOKEN.detail / LMK_SCALE_CONVIS, TOKEN.detail / LMK_SCALE], 12, 1, - ], + ), }, paint: { "text-color": colors.label, ...halo }, }, @@ -232,7 +234,7 @@ export function labels(): LayerSpecification[] { layout: { "symbol-placement": "line", "text-field": ["get", "name"], - "text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 16, 12], + "text-size": symbolSize(10, 10, 16, 12), "text-font": ["Noto Sans Regular"], }, paint: { @@ -262,7 +264,7 @@ export function labels(): LayerSpecification[] { "text-max-width": 5, "text-offset": [0, -0.65], "text-pitch-alignment": "viewport", - "text-size": ["interpolate", ["linear"], ["zoom"], 13, 10, 16, 13], + "text-size": symbolSize(13, 10, 16, 13), }, paint: { "text-color": colors.label, ...halo }, }, @@ -292,7 +294,7 @@ export function labels(): LayerSpecification[] { ] as ExpressionSpecification, "text-font": ["Noto Sans Regular"], "text-justify": "auto", - "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 16, 13], + "text-size": symbolSize(12, 10, 16, 13), "text-padding": 8, // MARK's pixel clearances in this layer's ems per stop. Hazards are centred symbols // (and can wear the wide isolated-danger octagon), so they take even margins. @@ -380,7 +382,7 @@ export function labels(): LayerSpecification[] { ], "text-font": ["Noto Sans Regular"], "text-justify": "auto", - "text-size": ["interpolate", ["linear"], ["zoom"], 12, 10, 16, 14], + "text-size": symbolSize(12, 10, 16, 14), // tighter than the name layers: the block carries the characteristic, and padding that // makes it fail to place hides information a mariner steers by "text-padding": 4, diff --git a/style/layers/lights.ts b/style/layers/lights.ts index 8ca0232..d740978 100644 --- a/style/layers/lights.ts +++ b/style/layers/lights.ts @@ -1,5 +1,5 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; -import { TOKEN, decoration, sizeRamp, withinBudget } from "./visibility.js"; +import { TOKEN, type Visibility } from "./visibility.js"; /** Sprite folder for the flare icon: floodlights have their own artwork. */ const lightPrefix: ExpressionSpecification = [ @@ -18,7 +18,12 @@ const lightPrefix: ExpressionSpecification = [ * standalone light symbols, and fog signals. The light *characteristic* label lives in * labels.ts, where it shares an anchor with the name labels it has to cooperate with. */ -export function lights(): LayerSpecification[] { +export function lights({ + decoration, + sizeRamp, + symbolSize, + withinBudget, +}: Visibility): LayerSpecification[] { return [ { id: "lights", @@ -56,7 +61,7 @@ export function lights(): LayerSpecification[] { // guaranteed placement; the budget and legibility filters above do the thinning "icon-overlap": "always", "icon-rotation-alignment": "viewport", - "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.7, 14, 1], + "icon-size": symbolSize(12, 0.7, 14, 1), }, }, { @@ -103,7 +108,7 @@ export function lights(): LayerSpecification[] { "icon-image": "freenauticalchart:fogsignal", "icon-overlap": "always", "icon-rotate": 90, - "icon-size": ["interpolate", ["linear"], ["zoom"], 12, 0.7, 14, 1], + "icon-size": symbolSize(12, 0.7, 14, 1), }, }, ]; diff --git a/style/layers/marks.ts b/style/layers/marks.ts index 82e81d0..d7a2569 100644 --- a/style/layers/marks.ts +++ b/style/layers/marks.ts @@ -1,5 +1,5 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; -import { TOKEN, decoration, sizeRamp, withinBudget } from "./visibility.js"; +import { TOKEN, type Visibility } from "./visibility.js"; /** * Y-offsets (sprite px, scaled by icon-size) that seat a bottom-anchored topmark on each body @@ -88,7 +88,7 @@ const patternBodyShape: ExpressionSpecification = [ * mark; reflectors draw last, charted over the mark. minzoom 6 defers to the tile pipeline, * which only carries cardinal/isolated-danger/safe-water marks below zoom 8 (SeamarkZoomRules). */ -export function marks(): LayerSpecification[] { +export function marks({ decoration, sizeRamp, withinBudget }: Visibility): LayerSpecification[] { return [ { id: "topmarks", diff --git a/style/layers/palette.ts b/style/layers/palette.ts index c7f703e..8b23622 100644 --- a/style/layers/palette.ts +++ b/style/layers/palette.ts @@ -27,7 +27,11 @@ export const day = { lightGreen: "#00a650", lightRed: "#ed1c24", lightYellow: "#fab20b", - /** grey, not CHBLK black: a busy sector light should not read as a hazard boundary (S-4 B-475.1) */ + /** + * grey, not CHBLK black: a busy sector light should not read as a hazard boundary (S-4 B-475.1). + * Also baked into sprites/icons/sector-leg.svg, which the style cannot recolour — icon-color + * needs an SDF sheet and spreet builds SDF per sheet, not per icon. Change the two together. + */ sectorLeg: "#666", } as const; diff --git a/style/layers/routes.ts b/style/layers/routes.ts index 322a07e..4542892 100644 --- a/style/layers/routes.ts +++ b/style/layers/routes.ts @@ -1,11 +1,12 @@ import type { LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; +import type { Visibility } from "./visibility.js"; /** * Things vessels follow or must stay clear of, drawn as lines: traffic separation schemes, ferry * routes, navigation lines and tracks, and the submarine cables and pipelines below them. */ -export function routes(): LayerSpecification[] { +export function routes({ symbolSize }: Visibility): LayerSpecification[] { return [ { id: "cables-pipes", @@ -53,7 +54,7 @@ export function routes(): LayerSpecification[] { "icon-image": "freenauticalchart:TSS-arrow", // held at 0.15 (~8px on the 54px sprite) down to the z2 data floor: the arrow chain is // the lane's only portrayal, so it has to stay legible at passage-planning zooms - "icon-size": ["interpolate", ["linear"], ["zoom"], 2, 0.15, 6, 0.15, 14, 0.6], + "icon-size": symbolSize(2, 0.15, 6, 0.15, 14, 0.6), "symbol-placement": "line", "icon-padding": 0, // grows with icon-size so the gap between arrows stays open as they scale up diff --git a/style/layers/sectors.ts b/style/layers/sectors.ts index cc24372..53ed65f 100644 --- a/style/layers/sectors.ts +++ b/style/layers/sectors.ts @@ -1,19 +1,15 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; -import { decoration, withinBudget } from "./visibility.js"; +import type { Visibility } from "./visibility.js"; /** - * Light sectors: decoration on a mark, drawn as ground geometry. + * Light sectors: decoration on a mark, in either of the two portrayals the tiles carry. * * 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 tiles carry the arc as an ordinary line at a nominal radius + * everything else. So the chart draws the arc as an ordinary line at a nominal radius * (`Lights.java`), scaling with the chart like its neighbours; only the stroke widths live here. - * - * The zoom window is where that stops working. Below ~z10 the radius is a smudge; past ~z15 the - * arc starts outgrowing the screen and reads as an unexplained curve with its light nowhere in - * view — the arc never was a claim about reach, so it fades out over z15–17 rather than growing - * into one. + * Standards mode takes the millimetres literally and draws sprites at the light instead. */ /** A sector where the light shows; obscured/faint sectors draw uncoloured (S-52 LIGHTS06). */ @@ -26,6 +22,10 @@ const visible: ExpressionSpecification = [ ], ]; +export function sectors(v: Visibility): LayerSpecification[] { + return v.standards ? displaySectors(v) : groundSectors(v); +} + const isSector: ExpressionSpecification = ["==", ["get", "subtype"], "sector"]; const common = { @@ -49,7 +49,13 @@ const fade = (opacity: number): ExpressionSpecification => [ 0, ]; -export function sectors(): LayerSpecification[] { +/** + * Arcs and legs as ground geometry, inside a zoom window. Below ~z10 the radius is a smudge; past + * ~z15 the arc starts outgrowing the screen and reads as an unexplained curve with its light + * nowhere in view — the arc never was a claim about reach, so it fades out over z15–17 rather than + * growing into one. + */ +function groundSectors({ decoration, withinBudget }: Visibility): LayerSpecification[] { return [ { // radial legs at every limit, one per bearing (`Lights.java` dedupes shared ones) @@ -113,3 +119,181 @@ export function sectors(): LayerSpecification[] { }, ]; } + +/** Angular span of each `arc-N` sprite, stepping by a factor of three. */ +const SPRITE_SPAN = [1.48, 4.44, 13.33, 40, 120]; + +/** Sprite radius in the sheet, which the sheet rasterizes at twice the source size. */ +const SPRITE_RADIUS = 98; +/** Length of `sector-leg` in the sheet, which is drawn for exactly this use. */ +const LEG_LENGTH = 512; +/** CSS pixels per millimetre at 96 dpi, for turning the standard's millimetres into icon-size. */ +const PX_PER_MM = 1 / 0.2646; + +const mm = (target: number, spriteSize: number) => + Number(((target * PX_PER_MM) / spriteSize).toFixed(3)); + +/** Which sprite covers this sector: the largest whose three copies still fit inside it. */ +const spriteIndex: ExpressionSpecification = [ + "step", + ["get", "sector_width"], + 0, + SPRITE_SPAN[1], + 1, + SPRITE_SPAN[2], + 2, + SPRITE_SPAN[3], + 3, + SPRITE_SPAN[4], + 4, +]; + +/** How much of the sector that sprite covers, which the middle and end copies rotate back by. */ +const span: ExpressionSpecification = [ + "step", + ["get", "sector_width"], + SPRITE_SPAN[0], + SPRITE_SPAN[1], + SPRITE_SPAN[1], + SPRITE_SPAN[2], + SPRITE_SPAN[2], + SPRITE_SPAN[3], + SPRITE_SPAN[3], + SPRITE_SPAN[4], + SPRITE_SPAN[4], +]; + +/** + * Sector colour, on the same tokens the flare uses so a light and its arc agree. Orange and amber + * cover raw tag values; a combination the S-52 precedence can't name falls through to magenta, + * never a fake colour. + */ +const colourSuffix: ExpressionSpecification = [ + "match", + ["get", "color"], + "green", + "G", + "red", + "R", + ["white", "yellow", "orange", "amber"], + "Y", + "M", +]; + +const isSectorPoint: ExpressionSpecification = ["==", ["get", "subtype"], "sector_point"]; + +/** + * A sector figure draws wherever its light draws, and never reserves room: an arc quad is many + * times the size of the symbols around it, and MapLibre places in reverse layer order, so an arc + * in the collision index would suppress the marks and labels drawn after it. + */ +const guaranteed = { + "icon-overlap": "always", + "icon-ignore-placement": true, +} as const; + +/** Which sprite family a copy belongs to, and so its filter, suffix and opacity. */ +type Family = "casing" | "colour" | "obscured"; + +/** + * Arcs at the 20 mm S-52 fixes them to, drawn as sprites: a line layer cannot hold a constant + * screen size. Each `arc-N` spans a fixed angle, and three copies — rotated to the sector's start, + * its middle and its end — cover any width. A sector narrower than the smallest sprite overdraws + * past its limits by the difference, a fraction of a degree at the radius the arc is drawn at. The + * tiles carry one point per sector and one per limit with the angles (`Lights.java`); nothing here + * is geometry. + */ +function displaySectors({ decoration, withinBudget }: Visibility): LayerSpecification[] { + /** + * Two reversals that cancel, which is why the rotations below look like they are missing a + * conversion. Sector limits are bearings **from seaward** — the direction a vessel looks along + * to see the light — so drawing one outward from the light means adding 180°. S-52 warns about + * exactly this ("Do not forget to reverse the sector values (+/- 180 degrees) since the values + * are given from seaward"), and it is the classic sector-rendering bug. The arc sprite meanwhile + * begins at the bottom of its own image and sweeps clockwise, so its leading edge sits at 180° + * before any rotation. Reciprocal plus sprite origin is a full turn: rotate by the limit itself. + */ + const start: ExpressionSpecification = ["get", "sector_start"]; + const end: ExpressionSpecification = ["-", ["get", "sector_end"], span]; + const middle: ExpressionSpecification = [ + "-", + ["+", ["get", "sector_start"], ["/", ["get", "sector_width"], 2]], + ["/", span, 2], + ]; + + /** + * @param family `casing` is the wide neutral stroke S-52 puts under the colour ("First symbolize + * the Arc with a solid line, 4 units wide, COLOUR OUTLW; then symbolize the Arc with the + * COLOUR") — without it a yellow sector on pale water is barely there, so legibility rather + * than decoration. `obscured` is the uncoloured dashed style an obscured, part-obscured or + * faint sector takes instead (LIGHTS06 Continuation). + */ + const arcLayer = ( + id: string, + rotate: ExpressionSpecification, + family: Family, + ): LayerSpecification => ({ + id, + type: "symbol", + source: "seamap", + "source-layer": "light", + minzoom: 8, + filter: [ + "all", + isSectorPoint, + family === "obscured" ? ["!", visible] : visible, + decoration("sector"), + withinBudget, + ], + layout: { + "icon-image": [ + "concat", + "freenauticalchart:arc-", + ["to-string", spriteIndex], + "-", + family === "colour" ? colourSuffix : family, + ], + // the arc is a bearing, so it turns with the map and never with the screen + "icon-rotation-alignment": "map", + "icon-rotate": rotate, + ...guaranteed, + // the smaller of an overlapping pair reaches 25 mm so the larger cannot bury it + "icon-size": ["case", ["has", "extended"], mm(25, SPRITE_RADIUS), mm(20, SPRITE_RADIUS)], + }, + ...(family === "obscured" ? { paint: { "icon-opacity": 0.9 } } : {}), + }); + + return [ + { + // radial legs at every limit, one per bearing (`Lights.java` dedupes shared ones) + id: "sector-legs", + type: "symbol", + source: "seamap", + "source-layer": "light", + minzoom: 8, + filter: ["all", ["==", ["get", "subtype"], "limit"], decoration("sector"), withinBudget], + layout: { + "icon-image": "freenauticalchart:sector-leg", + // anchored at its foot, so the line runs outward from the light along the bearing + "icon-anchor": "bottom", + "icon-rotation-alignment": "map", + // anchored at its foot the leg runs to image north, so the seaward + // reciprocal is the whole correction here + "icon-rotate": ["+", ["get", "bearing"], 180], + ...guaranteed, + "icon-size": mm(25, LEG_LENGTH), + }, + }, + // casings first, so every colour stroke lands on its own neutral backing + arcLayer("sector-arc-start-casing", start, "casing"), + arcLayer("sector-arc-middle-casing", middle, "casing"), + arcLayer("sector-arc-end-casing", end, "casing"), + arcLayer("sector-arc-start", start, "colour"), + arcLayer("sector-arc-middle", middle, "colour"), + arcLayer("sector-arc-end", end, "colour"), + // an obscured sector takes the same three copies: one alone covers only the sprite's own span + arcLayer("sector-arc-start-obscured", start, "obscured"), + arcLayer("sector-arc-middle-obscured", middle, "obscured"), + arcLayer("sector-arc-end-obscured", end, "obscured"), + ]; +} diff --git a/style/layers/structures.ts b/style/layers/structures.ts index 01d8548..08c64dd 100644 --- a/style/layers/structures.ts +++ b/style/layers/structures.ts @@ -1,7 +1,7 @@ import type { ExpressionSpecification, LayerSpecification } from "@maplibre/maplibre-gl-style-spec"; import { colors } from "./palette.js"; import { anchorOffsets } from "./placement.js"; -import { TOKEN, sizeRamp, withinBudget } from "./visibility.js"; +import { TOKEN, type Visibility } from "./visibility.js"; /** * Fixed shore and harbour works: piers and breakwaters, piles and dolphins, platforms, cranes, @@ -15,7 +15,11 @@ const wallWaterLevel: ExpressionSpecification = [ "", ]; -export function structures(): LayerSpecification[] { +export function structures({ + sizeRamp, + symbolSize, + withinBudget, +}: Visibility): LayerSpecification[] { // access values that mean "not open to the public" (OpenSeaMap-vector's PRIVATE_TAGS). // An untagged feature gets null, and `in` against null is false, so it reads as public. const restricted: ExpressionSpecification = [ @@ -105,7 +109,7 @@ export function structures(): LayerSpecification[] { layout: { "icon-image": "freenauticalchart:crane", "symbol-sort-key": ["coalesce", ["get", "cell_rank"], 0], - "icon-size": ["interpolate", ["linear"], ["zoom"], 14, 0.6, 16, 1], + "icon-size": symbolSize(14, 0.6, 16, 1), }, }, { @@ -118,7 +122,7 @@ export function structures(): LayerSpecification[] { layout: { "icon-image": "freenauticalchart:rescue", "symbol-sort-key": ["coalesce", ["get", "cell_rank"], 0], - "icon-size": ["interpolate", ["linear"], ["zoom"], 10, 0.5, 13, 1], + "icon-size": symbolSize(10, 0.5, 13, 1), }, }, { @@ -131,7 +135,7 @@ export function structures(): LayerSpecification[] { layout: { "icon-image": "freenauticalchart:radar_scanner", "symbol-sort-key": ["coalesce", ["get", "cell_rank"], 0], - "icon-size": ["interpolate", ["linear"], ["zoom"], 11, 0.6, 14, 1], + "icon-size": symbolSize(11, 0.6, 14, 1), }, }, { @@ -203,7 +207,7 @@ export function structures(): LayerSpecification[] { "freenauticalchart:poi-beach", "freenauticalchart:poi-generic", ], - "icon-size": ["interpolate", ["linear"], ["zoom"], 14, 0.8, 17, 1], + "icon-size": symbolSize(14, 0.8, 17, 1), // which grades a dock sells decides whether it's worth the detour; the other badges say // all they need to with the icon "text-field": [ @@ -262,7 +266,7 @@ export function structures(): LayerSpecification[] { "icon-overlap": "always", "text-font": ["Noto Sans Regular"], "text-field": ["get", "name"], - "text-size": ["interpolate", ["linear"], ["zoom"], 8, 9, 12, 12], + "text-size": symbolSize(8, 9, 12, 12), // tracks icon-size to hold ~7.5px from icon edge to glyph, leaving ~5px clear of the halo. // Both sprites are square 32 display px at icon-size 1, so both axes match. "text-variable-anchor-offset": [ diff --git a/style/layers/visibility.ts b/style/layers/visibility.ts index 39ada67..9a57268 100644 --- a/style/layers/visibility.ts +++ b/style/layers/visibility.ts @@ -1,4 +1,8 @@ -import type { ExpressionSpecification } from "@maplibre/maplibre-gl-style-spec"; +import type { + DataDrivenPropertyValueSpecification, + ExpressionFilterSpecification, + ExpressionSpecification, +} from "@maplibre/maplibre-gl-style-spec"; /** * What survives when symbols overlap, and how much of each surviving symbol gets drawn. @@ -10,6 +14,9 @@ import type { ExpressionSpecification } from "@maplibre/maplibre-gl-style-spec"; * * Budgets are per family, which is what keeps a harbour from being displaced by the forty buoys * around it: they never compete for the same allowance. + * + * S-52 answers all of this from SCAMIN and a table of fixed symbol sizes, so standards mode makes + * every helper here a pass-through and the layers read the same either way. */ /** @@ -62,11 +69,6 @@ const LEGIBLE_FROM = { */ export const MAX_SAFETY_DEPTH = 30; -/** The zoom can resolve this kind of decoration. */ -export function decoration(kind: keyof typeof LEGIBLE_FROM): ExpressionSpecification { - return [">=", ["zoom"], LEGIBLE_FROM[kind]]; -} - /** * How small a symbol can be drawn and still mean something — a property of the artwork, not of the * feature's importance. Hue and gross silhouette survive scaling; internal detail does not, so what @@ -97,35 +99,68 @@ export const TOKEN = { */ export const RAMP_FROM = 9; -/** - * Floor from the token until {@link RAMP_FROM}, full size at the zoom the feature's prominence - * earns. A body never vanishes at the bottom of this ramp; only a budget removes one. - */ -export function sizeRamp(floor: number, fullAt: number, full = 1): ExpressionSpecification { - return ["interpolate", ["linear"], ["zoom"], RAMP_FROM, floor, fullAt, full]; -} - function budgetAt(step: 0 | 1 | 2 | 3): ExpressionSpecification { const arms = Object.entries(BUDGET).flatMap(([family, steps]) => [family, steps[step]]); return ["match", ["get", "family"], ...arms, 999] as unknown as ExpressionSpecification; } -/** This feature is inside its family's allowance for the cell it sits in. */ -export const withinBudget: ExpressionSpecification = [ - // `cell_rank` counts from zero, so the comparison is strict and BUDGET reads as symbol counts - "<", - // lines and areas hold no position in a cell, so they are never thinned - ["coalesce", ["get", "cell_rank"], 0], - ["step", ["zoom"], budgetAt(0), 9, budgetAt(1), 11, budgetAt(2), 13, budgetAt(3)], -]; +/** A zoom stop's size: a constant, or an expression over the feature. */ +type Size = number | ExpressionSpecification; + +/** The thinning, dressing and sizing rules the layer modules share. */ +export interface Visibility { + /** Strict S-52 portrayal rather than the chart's own. */ + standards: boolean; + /** This feature is inside its family's allowance for the cell it sits in. */ + withinBudget: ExpressionFilterSpecification; + /** + * A name is a luxury: it is affordable where the symbols around it already fit, and it must + * never take the room a light characteristic or a Racon group needs, because those change the + * decision and a name does not. + * + * Being first in your own cell is the proxy for "there is room here". Where the chart is sparse + * every mark is first and every mark is named; where it is crowded only the mark its neighbours + * are ranked against gets one. That needs no data the tiles do not already carry. + */ + topOfCell: ExpressionFilterSpecification; + /** The zoom can resolve this kind of decoration. */ + decoration(kind: keyof typeof LEGIBLE_FROM): ExpressionFilterSpecification; + /** + * Floor from the token until {@link RAMP_FROM}, full size at the zoom the feature's prominence + * earns. A body never vanishes at the bottom of this ramp; only a budget removes one. + */ + sizeRamp( + floor: number, + fullAt: number, + full?: number, + ): DataDrivenPropertyValueSpecification; + /** + * An icon or text size interpolated over `zoom, size` stops, for symbols whose growth follows + * the detail they carry rather than the feature's prominence. The last stop is full size. + */ + symbolSize(...stops: Size[]): DataDrivenPropertyValueSpecification; +} /** - * A name is a luxury: it is affordable where the symbols around it already fit, and it must never - * take the room a light characteristic or a Racon group needs, because those change the decision - * and a name does not. - * - * Being first in your own cell is the proxy for "there is room here". Where the chart is sparse - * every mark is first and every mark is named; where it is crowded only the mark its neighbours - * are ranked against gets one. That needs no data the tiles do not already carry. + * The helper set for one portrayal. Standards mode neutralizes each one: SCAMIN carries no notion + * of density, so nothing is thinned and nothing is undressed for want of room, and every symbol is + * drawn at the fixed size the standard gives it. */ -export const topOfCell: ExpressionSpecification = ["==", ["coalesce", ["get", "cell_rank"], 0], 0]; +export function visibility(standards = false): Visibility { + return { + standards, + withinBudget: standards || [ + // `cell_rank` counts from zero, so the comparison is strict and BUDGET reads as symbol counts + "<", + // lines and areas hold no position in a cell, so they are never thinned + ["coalesce", ["get", "cell_rank"], 0], + ["step", ["zoom"], budgetAt(0), 9, budgetAt(1), 11, budgetAt(2), 13, budgetAt(3)], + ], + topOfCell: standards || ["==", ["coalesce", ["get", "cell_rank"], 0], 0], + decoration: (kind) => standards || [">=", ["zoom"], LEGIBLE_FROM[kind]], + sizeRamp: (floor, fullAt, full = 1) => + standards ? full : ["interpolate", ["linear"], ["zoom"], RAMP_FROM, floor, fullAt, full], + symbolSize: (...stops) => + standards ? stops[stops.length - 1] : ["interpolate", ["linear"], ["zoom"], ...stops], + }; +} diff --git a/style/sprites/icons/arc-0-M.svg b/style/sprites/icons/arc-0-M.svg new file mode 100644 index 0000000..5cf2c4a --- /dev/null +++ b/style/sprites/icons/arc-0-M.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-0-casing.svg b/style/sprites/icons/arc-0-casing.svg new file mode 100644 index 0000000..136ff53 --- /dev/null +++ b/style/sprites/icons/arc-0-casing.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-0-obscured.svg b/style/sprites/icons/arc-0-obscured.svg new file mode 100644 index 0000000..fe4593e --- /dev/null +++ b/style/sprites/icons/arc-0-obscured.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-1-M.svg b/style/sprites/icons/arc-1-M.svg new file mode 100644 index 0000000..22f42f1 --- /dev/null +++ b/style/sprites/icons/arc-1-M.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-1-casing.svg b/style/sprites/icons/arc-1-casing.svg new file mode 100644 index 0000000..255a2e3 --- /dev/null +++ b/style/sprites/icons/arc-1-casing.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-1-obscured.svg b/style/sprites/icons/arc-1-obscured.svg new file mode 100644 index 0000000..c1d512a --- /dev/null +++ b/style/sprites/icons/arc-1-obscured.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-2-M.svg b/style/sprites/icons/arc-2-M.svg new file mode 100644 index 0000000..bd38109 --- /dev/null +++ b/style/sprites/icons/arc-2-M.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-2-casing.svg b/style/sprites/icons/arc-2-casing.svg new file mode 100644 index 0000000..bb06750 --- /dev/null +++ b/style/sprites/icons/arc-2-casing.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-2-obscured.svg b/style/sprites/icons/arc-2-obscured.svg new file mode 100644 index 0000000..b9c17fd --- /dev/null +++ b/style/sprites/icons/arc-2-obscured.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-3-M.svg b/style/sprites/icons/arc-3-M.svg new file mode 100644 index 0000000..7ca00fc --- /dev/null +++ b/style/sprites/icons/arc-3-M.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-3-casing.svg b/style/sprites/icons/arc-3-casing.svg new file mode 100644 index 0000000..53b8402 --- /dev/null +++ b/style/sprites/icons/arc-3-casing.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-3-obscured.svg b/style/sprites/icons/arc-3-obscured.svg new file mode 100644 index 0000000..a5947bc --- /dev/null +++ b/style/sprites/icons/arc-3-obscured.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-4-M.svg b/style/sprites/icons/arc-4-M.svg new file mode 100644 index 0000000..43db9e2 --- /dev/null +++ b/style/sprites/icons/arc-4-M.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-4-casing.svg b/style/sprites/icons/arc-4-casing.svg new file mode 100644 index 0000000..0a5c6a2 --- /dev/null +++ b/style/sprites/icons/arc-4-casing.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/arc-4-obscured.svg b/style/sprites/icons/arc-4-obscured.svg new file mode 100644 index 0000000..0662277 --- /dev/null +++ b/style/sprites/icons/arc-4-obscured.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/style/sprites/icons/sector-leg.svg b/style/sprites/icons/sector-leg.svg new file mode 100644 index 0000000..85282b6 --- /dev/null +++ b/style/sprites/icons/sector-leg.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/viewer/src/main.js b/viewer/src/main.js index f07cd44..238afc3 100644 --- a/viewer/src/main.js +++ b/viewer/src/main.js @@ -7,10 +7,12 @@ import { style } from "@openwaters/seamap"; // ?tiles= points the chart at another TileJSON — the dev server's local // worker, say — instead of the published tiles. ?hillshade turns on the // bathymetric hillshading the style ships off; ?shading=relief swaps the -// vector depth bands for the raster DEM color-relief. +// vector depth bands for the raster DEM color-relief; ?standards draws the +// symbology strictly per S-52 instead of the chart's own portrayal. const params = new URLSearchParams(location.search); const tiles = params.get("tiles") || undefined; const depthHillshade = params.has("hillshade"); +const standards = params.has("standards"); // anything but the known raster opt-in falls back to the style's default const shading = params.get("shading") === "relief" ? "relief" : undefined; @@ -25,7 +27,7 @@ const map = new maplibregl.Map({ center: [10.2351, 56.16858], zoom: 13.4, container: "map", - style: await style({ tiles, depthHillshade, shading }), + style: await style({ tiles, depthHillshade, shading, standards }), dragRotate: false, touchPitch: false, maxPitch: 0,