leaflet-india-boundary 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +25 -0
  2. package/LICENSE-DATA +101 -0
  3. package/README.md +391 -0
  4. package/data/india-boundary.geojson +1 -0
  5. package/dist/cjs/attribution.js +24 -0
  6. package/dist/cjs/attribution.js.map +1 -0
  7. package/dist/cjs/data.js +78 -0
  8. package/dist/cjs/data.js.map +1 -0
  9. package/dist/cjs/index.js +39 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/leaflet.js +152 -0
  12. package/dist/cjs/leaflet.js.map +1 -0
  13. package/dist/cjs/package.json +3 -0
  14. package/dist/cjs/react.js +77 -0
  15. package/dist/cjs/react.js.map +1 -0
  16. package/dist/cjs/style.js +129 -0
  17. package/dist/cjs/style.js.map +1 -0
  18. package/dist/cjs/suppress.js +462 -0
  19. package/dist/cjs/suppress.js.map +1 -0
  20. package/dist/cjs/suppressionData.js +205 -0
  21. package/dist/cjs/suppressionData.js.map +1 -0
  22. package/dist/esm/attribution.d.ts +21 -0
  23. package/dist/esm/attribution.d.ts.map +1 -0
  24. package/dist/esm/attribution.js +21 -0
  25. package/dist/esm/attribution.js.map +1 -0
  26. package/dist/esm/data.d.ts +78 -0
  27. package/dist/esm/data.d.ts.map +1 -0
  28. package/dist/esm/data.js +75 -0
  29. package/dist/esm/data.js.map +1 -0
  30. package/dist/esm/index.d.ts +19 -0
  31. package/dist/esm/index.d.ts.map +1 -0
  32. package/dist/esm/index.js +19 -0
  33. package/dist/esm/index.js.map +1 -0
  34. package/dist/esm/leaflet.d.ts +103 -0
  35. package/dist/esm/leaflet.d.ts.map +1 -0
  36. package/dist/esm/leaflet.js +147 -0
  37. package/dist/esm/leaflet.js.map +1 -0
  38. package/dist/esm/package.json +3 -0
  39. package/dist/esm/react.d.ts +31 -0
  40. package/dist/esm/react.d.ts.map +1 -0
  41. package/dist/esm/react.js +69 -0
  42. package/dist/esm/react.js.map +1 -0
  43. package/dist/esm/style.d.ts +106 -0
  44. package/dist/esm/style.d.ts.map +1 -0
  45. package/dist/esm/style.js +121 -0
  46. package/dist/esm/style.js.map +1 -0
  47. package/dist/esm/suppress.d.ts +64 -0
  48. package/dist/esm/suppress.d.ts.map +1 -0
  49. package/dist/esm/suppress.js +456 -0
  50. package/dist/esm/suppress.js.map +1 -0
  51. package/dist/esm/suppressionData.d.ts +34 -0
  52. package/dist/esm/suppressionData.d.ts.map +1 -0
  53. package/dist/esm/suppressionData.js +202 -0
  54. package/dist/esm/suppressionData.js.map +1 -0
  55. package/dist/style.css +66 -0
  56. package/package.json +113 -0
  57. package/src/attribution.ts +23 -0
  58. package/src/data.ts +153 -0
  59. package/src/index.ts +45 -0
  60. package/src/leaflet.ts +252 -0
  61. package/src/react.ts +92 -0
  62. package/src/style.css +66 -0
  63. package/src/style.ts +168 -0
  64. package/src/suppress.ts +599 -0
  65. package/src/suppressionData.ts +238 -0
  66. package/tools/build-india-boundary.mjs +836 -0
  67. package/tools/postbuild.mjs +61 -0
package/src/leaflet.ts ADDED
@@ -0,0 +1,252 @@
1
+ import {
2
+ geoJSON,
3
+ type GeoJSON as LeafletGeoJSON,
4
+ type Map as LeafletMap,
5
+ type Path,
6
+ type PathOptions,
7
+ } from "leaflet";
8
+ import { INDIA_BOUNDARY_ATTRIBUTION } from "./attribution.js";
9
+ import { INDIA_BOUNDARY, type IndiaBoundaryProperties } from "./data.js";
10
+ import {
11
+ isOsmAdminBoundaryVisible,
12
+ OSM_ADMIN_BOUNDARY_MIN_ZOOM,
13
+ OSM_ADMIN_BOUNDARY_OPACITY,
14
+ osmAdminBoundaryColor,
15
+ osmAdminBoundaryWidth,
16
+ OSM_ADMIN_BOUNDARY_WIDE_ZOOM,
17
+ type OsmAdminBoundaryStyleOptions,
18
+ } from "./style.js";
19
+
20
+ /** Base class Leaflet writes onto the SVG paths, for CSS overrides. */
21
+ export const INDIA_BOUNDARY_CLASS = "leaflet-india-boundary";
22
+
23
+ /** Added alongside the base class from `wideZoom` up, mirroring carto's colour swap. */
24
+ export const INDIA_BOUNDARY_WIDE_CLASS = "leaflet-india-boundary--wide";
25
+
26
+ /** Pane the layer creates and filters when `tileFilter` is set. */
27
+ export const INDIA_BOUNDARY_PANE = "indiaBoundary";
28
+
29
+ export interface IndiaBoundaryLayerOptions extends OsmAdminBoundaryStyleOptions {
30
+ /**
31
+ * A CSS filter to put the boundary stroke through — pass exactly the same value
32
+ * your app applies to `.leaflet-tile-pane`.
33
+ *
34
+ * **This is the option most likely to be needed and least likely to be
35
+ * guessed, so it is worth the paragraph.** If your basemap is stock OSM raster
36
+ * tiles, leave this unset and everything below is irrelevant.
37
+ *
38
+ * If instead you filter your tile pane — inverting OSM's light tiles to build a
39
+ * dark basemap is the common case — then the tiles on screen are no longer the
40
+ * colours OSM rendered. carto's border colours are still correct *inside the
41
+ * tile*, but your overlay is drawn into a pane that is not filtered, so a
42
+ * colour lifted from carto's stylesheet lands nowhere near the purple actually
43
+ * visible. Passing the same filter here puts the overlay through the same
44
+ * transform, and it comes out matching.
45
+ *
46
+ * Setting this also raises the default `opacity` from carto's 0.5 to 1 — see
47
+ * {@link IndiaBoundaryLayerOptions.opacity}.
48
+ *
49
+ * Implemented by creating a dedicated map pane and filtering that, so it works
50
+ * under both the SVG and Canvas renderers. If you also pass `pane`, the filter
51
+ * goes on *your* pane and therefore on everything else in it, so pass a pane you
52
+ * own.
53
+ */
54
+ tileFilter?: string;
55
+
56
+ /**
57
+ * Stroke opacity. Defaults to carto's own 0.5, or to 1 when `tileFilter` is set.
58
+ *
59
+ * The default is conditional because carto's 0.5 is not a free-standing value —
60
+ * it is composited against carto's light land fill (`#f2efe9`) *inside the tile*,
61
+ * before any filter of yours runs. Honouring 0.5 on the unfiltered side of an
62
+ * inverting filter, over a near-black map, lands about three times too dark. The
63
+ * exact match was tried in the app this came from and resolves to `#5b5157`,
64
+ * which is genuinely indistinguishable from OSM's own borders and therefore
65
+ * genuinely invisible: a dark basemap crushes every border, OSM's included.
66
+ *
67
+ * So: unfiltered basemap, 0.5 is right. Filtered basemap, 1 keeps carto's hue
68
+ * while staying legible, which is the entire point of drawing the line.
69
+ */
70
+ opacity?: number;
71
+
72
+ /** Extra class(es) on each path, alongside {@link INDIA_BOUNDARY_CLASS}. */
73
+ className?: string;
74
+
75
+ /**
76
+ * Map pane to draw into. Defaults to Leaflet's `overlayPane`, or to a pane this
77
+ * layer creates ({@link INDIA_BOUNDARY_PANE}) when `tileFilter` is set.
78
+ */
79
+ pane?: string;
80
+
81
+ /**
82
+ * Attribution shown in Leaflet's attribution control. Defaults to
83
+ * {@link INDIA_BOUNDARY_ATTRIBUTION}, because the OSM-derived portion of this
84
+ * data is ODbL and asks for it. Pass `false` only if you credit it elsewhere.
85
+ */
86
+ attribution?: string | false;
87
+
88
+ /**
89
+ * Whether the boundary responds to the pointer. Off by default: it is a border,
90
+ * not a feature, and a national boundary swallowing clicks meant for the markers
91
+ * underneath it is a bug in every app I can imagine.
92
+ */
93
+ interactive?: boolean;
94
+ }
95
+
96
+ interface Resolved {
97
+ style: OsmAdminBoundaryStyleOptions;
98
+ opacity: number;
99
+ wideZoom: number;
100
+ className?: string;
101
+ tileFilter?: string;
102
+ pane?: string;
103
+ interactive: boolean;
104
+ }
105
+
106
+ function resolve(options: IndiaBoundaryLayerOptions): Resolved {
107
+ return {
108
+ style: options,
109
+ // The one line in this package that encodes the filter interaction.
110
+ opacity: options.opacity ?? (options.tileFilter ? 1 : OSM_ADMIN_BOUNDARY_OPACITY),
111
+ wideZoom: options.wideZoom ?? OSM_ADMIN_BOUNDARY_WIDE_ZOOM,
112
+ className: options.className,
113
+ tileFilter: options.tileFilter,
114
+ pane: options.pane ?? (options.tileFilter ? INDIA_BOUNDARY_PANE : undefined),
115
+ interactive: options.interactive ?? false,
116
+ };
117
+ }
118
+
119
+ function pathStyle(zoom: number, r: Resolved): PathOptions {
120
+ const wide = zoom >= r.wideZoom;
121
+ const classes = [INDIA_BOUNDARY_CLASS];
122
+ if (wide) classes.push(INDIA_BOUNDARY_WIDE_CLASS);
123
+ if (r.className) classes.push(r.className);
124
+
125
+ return {
126
+ className: classes.join(" "),
127
+ color: osmAdminBoundaryColor(zoom, r.style),
128
+ weight: osmAdminBoundaryWidth(zoom),
129
+ opacity: r.opacity,
130
+ // Below carto's z4 the basemap draws no national borders, so this draws
131
+ // nothing either. `stroke: false` rather than removing the layer from the
132
+ // map: same result on screen, but `map.hasLayer()` keeps telling the truth
133
+ // and layer-control checkboxes do not flicker.
134
+ stroke: isOsmAdminBoundaryVisible(zoom, r.style),
135
+ fill: false,
136
+ interactive: r.interactive,
137
+ // carto joins its border segments with a bevel. Note that nothing in CSS may
138
+ // set `stroke-linejoin` — a CSS property would beat the SVG attribute
139
+ // Leaflet writes here.
140
+ lineJoin: "bevel",
141
+ };
142
+ }
143
+
144
+ /**
145
+ * India's boundary through Jammu & Kashmir, Ladakh and Aksai Chin — as depicted
146
+ * by the Survey of India — drawn over your basemap in the basemap's own border
147
+ * style.
148
+ *
149
+ * ```js
150
+ * import { indiaBoundaryLayer } from "leaflet-india-boundary/leaflet";
151
+ *
152
+ * indiaBoundaryLayer().addTo(map);
153
+ * ```
154
+ *
155
+ * The returned layer restyles itself on zoom, so carto's per-zoom widths and its
156
+ * colour swap at z10 keep matching the tiles underneath, and it draws nothing
157
+ * below z4 where the basemap has no national borders to match.
158
+ *
159
+ * ## The ceiling, stated plainly
160
+ *
161
+ * This *adds* India's claimed border. It cannot *remove* the Line of Control /
162
+ * Line of Actual Control that raster tiles have already drawn, because by the time
163
+ * a tile reaches the browser it is a picture. Where the two run apart, both are
164
+ * visible. Suppressing the basemap's own line needs either vector tiles, where the
165
+ * boundary layer can be filtered out, or a provider political view — HERE's
166
+ * `pview=IN`, MapTiler's worldview setting.
167
+ */
168
+ export function indiaBoundaryLayer(
169
+ options: IndiaBoundaryLayerOptions = {},
170
+ ): LeafletGeoJSON<IndiaBoundaryProperties> {
171
+ const r = resolve(options);
172
+ const attribution = options.attribution ?? INDIA_BOUNDARY_ATTRIBUTION;
173
+
174
+ // Declared before `geoJSON()` on purpose. Leaflet builds the child paths
175
+ // synchronously inside the constructor, which calls `style` right there — so a
176
+ // `style` closure reading a `let` declared afterwards would hit it in its
177
+ // temporal dead zone and throw on construction.
178
+ let map: LeafletMap | null = null;
179
+ const zoomNow = () => map?.getZoom() ?? r.style.minZoom ?? OSM_ADMIN_BOUNDARY_MIN_ZOOM;
180
+
181
+ const layer = geoJSON<IndiaBoundaryProperties>(INDIA_BOUNDARY, {
182
+ style: () => pathStyle(zoomNow(), r),
183
+ pane: r.pane,
184
+ interactive: r.interactive,
185
+ ...(attribution === false ? {} : { attribution }),
186
+ });
187
+
188
+ const restyle = () => {
189
+ const zoom = zoomNow();
190
+ layer.setStyle(() => pathStyle(zoom, r));
191
+
192
+ // `setStyle` does not reapply `className`. Leaflet writes the class once, when
193
+ // it creates the path element, and its `_updateStyle` only touches the paint
194
+ // attributes — so the wide-zoom modifier has to be toggled by hand or it stays
195
+ // frozen at whatever it was when the layer was built. Harmless for the default
196
+ // rendering, where the colour arrives as the `stroke` attribute; not harmless
197
+ // for anyone driving the colours from style.css, where the class *is* the
198
+ // mechanism.
199
+ const wide = zoom >= r.wideZoom;
200
+ for (const el of indiaBoundaryElements(layer)) {
201
+ el.classList.toggle(INDIA_BOUNDARY_WIDE_CLASS, wide);
202
+ }
203
+ };
204
+
205
+ // Instance-level overrides of two documented `Layer` methods. Leaflet's own
206
+ // class system (`L.GeoJSON.extend`) would work too, but it types poorly and
207
+ // subclassing it from an ES class fights its `initialize` convention for no
208
+ // gain — there is no second implementation to share.
209
+ const baseOnAdd = layer.onAdd.bind(layer);
210
+ const baseOnRemove = layer.onRemove.bind(layer);
211
+
212
+ layer.onAdd = (m: LeafletMap) => {
213
+ map = m;
214
+ // Must happen before the base `onAdd`, which is when each path resolves the
215
+ // renderer that will live in this pane.
216
+ if (r.tileFilter && r.pane) {
217
+ const pane = m.getPane(r.pane) ?? m.createPane(r.pane);
218
+ if (!pane.style.zIndex) pane.style.zIndex = "400"; // beside overlayPane, under markers
219
+ pane.style.filter = r.tileFilter;
220
+ if (!r.interactive) pane.style.pointerEvents = "none";
221
+ }
222
+ const result = baseOnAdd(m);
223
+ m.on("zoomend", restyle);
224
+ restyle();
225
+ return result;
226
+ };
227
+
228
+ layer.onRemove = (m: LeafletMap) => {
229
+ m.off("zoomend", restyle);
230
+ map = null;
231
+ return baseOnRemove(m);
232
+ };
233
+
234
+ return layer;
235
+ }
236
+
237
+ /**
238
+ * Escape hatch for anyone doing something this factory does not cover — reading
239
+ * the live SVG elements, say, to hand them to an animation library.
240
+ *
241
+ * Returns undefined for canvas-rendered paths, which have no element of their own.
242
+ */
243
+ export function indiaBoundaryElements(
244
+ layer: LeafletGeoJSON<IndiaBoundaryProperties>,
245
+ ): Element[] {
246
+ const out: Element[] = [];
247
+ layer.eachLayer((child) => {
248
+ const el = (child as Path).getElement?.();
249
+ if (el) out.push(el);
250
+ });
251
+ return out;
252
+ }
package/src/react.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { useEffect } from "react";
2
+ import { useMap } from "react-leaflet";
3
+ import { indiaBoundaryLayer, type IndiaBoundaryLayerOptions } from "./leaflet.js";
4
+
5
+ export type IndiaBoundaryProps = IndiaBoundaryLayerOptions;
6
+
7
+ /**
8
+ * react-leaflet wrapper. Drop it inside a `<MapContainer>`, anywhere below the
9
+ * `<TileLayer>`:
10
+ *
11
+ * ```tsx
12
+ * import { IndiaBoundary } from "leaflet-india-boundary/react";
13
+ *
14
+ * <MapContainer center={[22, 79]} zoom={5}>
15
+ * <TileLayer url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" />
16
+ * <IndiaBoundary />
17
+ * </MapContainer>
18
+ * ```
19
+ *
20
+ * If your app filters its tile pane to build a dark basemap, pass the same filter
21
+ * so the overlay is transformed with it — see `tileFilter` on
22
+ * {@link IndiaBoundaryLayerOptions}, which is the one option worth reading about
23
+ * before you need it:
24
+ *
25
+ * ```tsx
26
+ * <IndiaBoundary tileFilter="invert(1) hue-rotate(192deg) saturate(0.45)" />
27
+ * ```
28
+ *
29
+ * Renders no DOM of its own — the layer is Leaflet's, so React only owns its
30
+ * lifetime. Note this file is deliberately `.ts`, not `.tsx`: the component
31
+ * returns `null`, so the package needs no JSX transform to build.
32
+ */
33
+ export function IndiaBoundary(props: IndiaBoundaryProps = {}): null {
34
+ const map = useMap();
35
+
36
+ // Every option is a primitive, so they can be listed individually instead of
37
+ // reaching for a deep-equality hook or re-creating the layer whenever the
38
+ // caller passes a fresh object literal (which, in JSX, is every render).
39
+ const {
40
+ attribution,
41
+ className,
42
+ color,
43
+ colorWide,
44
+ interactive,
45
+ minZoom,
46
+ opacity,
47
+ pane,
48
+ tileFilter,
49
+ wideZoom,
50
+ } = props;
51
+
52
+ useEffect(() => {
53
+ const layer = indiaBoundaryLayer({
54
+ attribution,
55
+ className,
56
+ color,
57
+ colorWide,
58
+ interactive,
59
+ minZoom,
60
+ opacity,
61
+ pane,
62
+ tileFilter,
63
+ wideZoom,
64
+ });
65
+ layer.addTo(map);
66
+ return () => {
67
+ layer.remove();
68
+ };
69
+ }, [
70
+ map,
71
+ attribution,
72
+ className,
73
+ color,
74
+ colorWide,
75
+ interactive,
76
+ minZoom,
77
+ opacity,
78
+ pane,
79
+ tileFilter,
80
+ wideZoom,
81
+ ]);
82
+
83
+ return null;
84
+ }
85
+
86
+ export {
87
+ INDIA_BOUNDARY_CLASS,
88
+ INDIA_BOUNDARY_PANE,
89
+ INDIA_BOUNDARY_WIDE_CLASS,
90
+ indiaBoundaryLayer,
91
+ type IndiaBoundaryLayerOptions,
92
+ } from "./leaflet.js";
package/src/style.css ADDED
@@ -0,0 +1,66 @@
1
+ /*
2
+ * leaflet-india-boundary — OPTIONAL stylesheet.
3
+ *
4
+ * You do not need this file. The layer sets colour, width, opacity and join as
5
+ * SVG attributes, so `indiaBoundaryLayer().addTo(map)` already draws a border
6
+ * that matches the basemap. Import this only if you would rather drive the
7
+ * styling from CSS than from JavaScript options.
8
+ *
9
+ * import "leaflet-india-boundary/style.css";
10
+ *
11
+ * Be aware of the precedence if you do: a CSS `stroke` beats the SVG `stroke`
12
+ * attribute Leaflet writes, so any colour rule here silently wins over the
13
+ * `color` / `colorWide` options. That is the point of the file, but it is worth
14
+ * knowing before you spend an afternoon wondering why `color` does nothing.
15
+ *
16
+ * One rule you must not add: `stroke-linejoin`. The layer sets `lineJoin: bevel`
17
+ * as an SVG attribute to match carto, and a CSS property would override it.
18
+ */
19
+
20
+ .leaflet-india-boundary {
21
+ /* Belt and braces. The layer also passes `interactive: false`, which keeps
22
+ Leaflet from marking the paths as pointer targets, so clicks reach the
23
+ markers underneath. */
24
+ pointer-events: none;
25
+
26
+ /* CSS route to the tile-filter fix, for apps that would rather set a variable
27
+ than pass `tileFilter` in JS. Set it on your map container:
28
+
29
+ .leaflet-container {
30
+ --india-boundary-filter: invert(1) hue-rotate(192deg) saturate(0.45);
31
+ }
32
+
33
+ Use one route or the other, not both — `tileFilter` filters a dedicated pane,
34
+ and combining the two filters the stroke twice.
35
+
36
+ Why this exists at all: if you filter `.leaflet-tile-pane` to turn OSM's light
37
+ tiles into a dark basemap, the tiles on screen are no longer the colours OSM
38
+ rendered, while this overlay sits in an unfiltered pane. carto's purple applied
39
+ raw here lands nowhere near the purple actually visible. Putting the overlay
40
+ through the same filter is what makes them match. See the README. */
41
+ filter: var(--india-boundary-filter, none);
42
+ }
43
+
44
+ /*
45
+ * carto's own values, for reference and for overriding. Uncomment to take control
46
+ * of the colours from CSS.
47
+ *
48
+ * These are openstreetmap-carto's `@admin-boundaries` and `@admin-boundaries-wide`
49
+ * verbatim from `style/admin.mss`, which is the whole trick: the border reads as
50
+ * part of the map because it is the exact colour the map draws every other national
51
+ * border in.
52
+ *
53
+ * Do not add a white line underneath. carto has one, but it is a `comp-op: darken`
54
+ * device for hiding the seam where two countries' borders overlap — not a visible
55
+ * casing. On a single overlay there is no seam to hide and it just looks wrong.
56
+ */
57
+
58
+ /*
59
+ .leaflet-india-boundary {
60
+ stroke: #8d618b;
61
+ }
62
+
63
+ .leaflet-india-boundary--wide {
64
+ stroke: #a37da1;
65
+ }
66
+ */
package/src/style.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * openstreetmap-carto's own `admin_level=2` border style, transcribed from its
3
+ * `style/admin.mss` (the `::wideline` attachment) rather than picked by eye.
4
+ *
5
+ * Matching these exactly is most of what stops the overlay reading as something
6
+ * drawn *on top of* the basemap instead of part of it. A border that is a
7
+ * slightly different purple, or a pixel too heavy, looks like an annotation. The
8
+ * same border in the basemap's own colour and weight looks like the map.
9
+ *
10
+ * Nothing in this module imports Leaflet, or anything else. It is here so that a
11
+ * MapLibre, OpenLayers or deck.gl user gets the same values without taking a
12
+ * mapping dependency they do not use.
13
+ */
14
+
15
+ /** carto's `@admin-boundaries`. Applies below {@link OSM_ADMIN_BOUNDARY_WIDE_ZOOM}. */
16
+ export const OSM_ADMIN_BOUNDARY_COLOR = "#8d618b";
17
+
18
+ /** carto's `@admin-boundaries-wide`, which it swaps in from zoom 10. */
19
+ export const OSM_ADMIN_BOUNDARY_COLOR_WIDE = "#a37da1";
20
+
21
+ /** The zoom at which carto swaps `@admin-boundaries` for the lighter wide colour. */
22
+ export const OSM_ADMIN_BOUNDARY_WIDE_ZOOM = 10;
23
+
24
+ /**
25
+ * carto's country-border rule starts at `[zoom >= 4]`, so below that the basemap
26
+ * draws no national boundaries at all — and neither should this overlay. On a
27
+ * borderless world map it is a lone squiggle floating over central Asia, attached
28
+ * to nothing. See `isOsmAdminBoundaryVisible`.
29
+ */
30
+ export const OSM_ADMIN_BOUNDARY_MIN_ZOOM = 4;
31
+
32
+ /**
33
+ * carto's width table ends here. Above it the z14 width carries on, which is what
34
+ * carto does too.
35
+ */
36
+ export const OSM_ADMIN_BOUNDARY_MAX_TABLE_ZOOM = 14;
37
+
38
+ /**
39
+ * carto's own stroke opacity for national borders.
40
+ *
41
+ * Read the note on `tileFilter` in the README before overriding this. Short
42
+ * version: carto composites this 0.5 against its light land fill (`#f2efe9`)
43
+ * *inside the tile*. If your app puts a CSS filter over its tile pane, an overlay
44
+ * drawn at 0.5 on the unfiltered side of that filter lands roughly three times
45
+ * too dark and disappears.
46
+ */
47
+ export const OSM_ADMIN_BOUNDARY_OPACITY = 0.5;
48
+
49
+ /**
50
+ * Stroke width by zoom, from carto's `::wideline` attachment.
51
+ *
52
+ * carto pairs this with a white "background" line, which is *not* a visible
53
+ * casing — it is a `comp-op: darken` trick for hiding the seams where two
54
+ * countries' borders overlap. Do not draw one. On a single overlay there is
55
+ * nothing to hide, and a white line under a purple one just looks like a mistake.
56
+ */
57
+ export const OSM_ADMIN_BOUNDARY_WIDTH: Readonly<Record<number, number>> =
58
+ Object.freeze({
59
+ 4: 1.2,
60
+ 5: 1.5,
61
+ 6: 1.8,
62
+ 7: 2.2,
63
+ 8: 3,
64
+ 9: 3.5,
65
+ 10: 4.5,
66
+ 11: 5,
67
+ 12: 6,
68
+ 13: 7,
69
+ 14: 8,
70
+ });
71
+
72
+ /** Every carto-derived value in one object, for destructuring or spreading. */
73
+ export const OSM_ADMIN_STYLE = Object.freeze({
74
+ color: OSM_ADMIN_BOUNDARY_COLOR,
75
+ colorWide: OSM_ADMIN_BOUNDARY_COLOR_WIDE,
76
+ wideZoom: OSM_ADMIN_BOUNDARY_WIDE_ZOOM,
77
+ minZoom: OSM_ADMIN_BOUNDARY_MIN_ZOOM,
78
+ maxTableZoom: OSM_ADMIN_BOUNDARY_MAX_TABLE_ZOOM,
79
+ opacity: OSM_ADMIN_BOUNDARY_OPACITY,
80
+ width: OSM_ADMIN_BOUNDARY_WIDTH,
81
+ });
82
+
83
+ /** Overrides accepted by the resolvers below and by every layer factory. */
84
+ export interface OsmAdminBoundaryStyleOptions {
85
+ /** Stroke colour below `wideZoom`. Defaults to carto's `@admin-boundaries`. */
86
+ color?: string;
87
+ /** Stroke colour from `wideZoom` up. Defaults to carto's `@admin-boundaries-wide`. */
88
+ colorWide?: string;
89
+ /** Zoom at which `colorWide` takes over. Defaults to 10, as carto does. */
90
+ wideZoom?: number;
91
+ /** Below this zoom the boundary is not drawn. Defaults to 4, as carto does. */
92
+ minZoom?: number;
93
+ /** Stroke opacity. Defaults to carto's 0.5 — but see `tileFilter`. */
94
+ opacity?: number;
95
+ }
96
+
97
+ /** carto's width for a zoom, clamped to the ends of its table. */
98
+ export function osmAdminBoundaryWidth(zoom: number): number {
99
+ const z = Math.min(
100
+ OSM_ADMIN_BOUNDARY_MAX_TABLE_ZOOM,
101
+ Math.max(OSM_ADMIN_BOUNDARY_MIN_ZOOM, Math.round(zoom)),
102
+ );
103
+ return OSM_ADMIN_BOUNDARY_WIDTH[z] ?? OSM_ADMIN_BOUNDARY_WIDTH[OSM_ADMIN_BOUNDARY_MIN_ZOOM];
104
+ }
105
+
106
+ /** carto's border colour for a zoom. */
107
+ export function osmAdminBoundaryColor(
108
+ zoom: number,
109
+ options: OsmAdminBoundaryStyleOptions = {},
110
+ ): string {
111
+ const wideZoom = options.wideZoom ?? OSM_ADMIN_BOUNDARY_WIDE_ZOOM;
112
+ return zoom >= wideZoom
113
+ ? (options.colorWide ?? OSM_ADMIN_BOUNDARY_COLOR_WIDE)
114
+ : (options.color ?? OSM_ADMIN_BOUNDARY_COLOR);
115
+ }
116
+
117
+ /** Whether the basemap draws national borders at this zoom, and so whether we should. */
118
+ export function isOsmAdminBoundaryVisible(
119
+ zoom: number,
120
+ options: OsmAdminBoundaryStyleOptions = {},
121
+ ): boolean {
122
+ return zoom >= (options.minZoom ?? OSM_ADMIN_BOUNDARY_MIN_ZOOM);
123
+ }
124
+
125
+ /** A resolved stroke, ready to hand to whatever renderer you are using. */
126
+ export interface ResolvedBoundaryStyle {
127
+ color: string;
128
+ width: number;
129
+ opacity: number;
130
+ visible: boolean;
131
+ }
132
+
133
+ /**
134
+ * The whole style for one zoom level, in renderer-neutral terms.
135
+ *
136
+ * ```js
137
+ * const { color, width, visible } = osmAdminBoundaryStyle(map.getZoom());
138
+ * ```
139
+ */
140
+ export function osmAdminBoundaryStyle(
141
+ zoom: number,
142
+ options: OsmAdminBoundaryStyleOptions = {},
143
+ ): ResolvedBoundaryStyle {
144
+ return {
145
+ color: osmAdminBoundaryColor(zoom, options),
146
+ width: osmAdminBoundaryWidth(zoom),
147
+ opacity: options.opacity ?? OSM_ADMIN_BOUNDARY_OPACITY,
148
+ visible: isOsmAdminBoundaryVisible(zoom, options),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * carto's width table as a MapLibre / Mapbox style expression, for
154
+ * `paint: { "line-width": … }`.
155
+ *
156
+ * `interpolate` rather than `step` on purpose: carto's table is per-integer-zoom
157
+ * because raster tiles only exist at integer zooms, but a vector map renders the
158
+ * fractional zooms in between, and stepping through them makes the line visibly
159
+ * jump. Interpolating hits carto's exact widths at every integer zoom and behaves
160
+ * sanely between them.
161
+ */
162
+ export function osmAdminBoundaryWidthExpression(): unknown[] {
163
+ const stops = Object.keys(OSM_ADMIN_BOUNDARY_WIDTH)
164
+ .map(Number)
165
+ .sort((a, b) => a - b)
166
+ .flatMap((z) => [z, OSM_ADMIN_BOUNDARY_WIDTH[z]]);
167
+ return ["interpolate", ["linear"], ["zoom"], ...stops];
168
+ }