@metsa/isom-maplibre 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Malthe Poulsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # isom-maplibre
2
+
3
+ A [MapLibre GL](https://maplibre.org) style that renders orienteering maps per
4
+ [ISOM 2017-2](https://orienteering.sport/iof/mapping/), the IOF International
5
+ Specification for Orienteering Maps, at 1:10,000.
6
+
7
+ Every line width, dash, pattern and symbol is computed from the ISOM dimensions
8
+ (mm at 1:15,000, enlarged ×1.5 and converted at 96 dpi), uses the ISOM colours,
9
+ and is stacked in the ISOM colour order. The dimensions are cross-checked against
10
+ the [OpenOrienteering Mapper](https://www.openorienteering.org/apps/mapper/)
11
+ ISOM 2017-2 symbol set.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install @metsa/isom-maplibre maplibre-gl
17
+ ```
18
+
19
+ Releases are published to npm with provenance.
20
+
21
+ ## Usage
22
+
23
+ ### In-memory GeoJSON
24
+
25
+ `isomGeojsonStyle()` returns the GeoJSON style (one GeoJSON source per table,
26
+ no sprite) with your data attached, and `registerIsomIcons()` supplies the
27
+ pattern and symbol images at runtime:
28
+
29
+ ```ts
30
+ import maplibregl from "maplibre-gl";
31
+ import { isomGeojsonStyle, registerIsomIcons } from "@metsa/isom-maplibre";
32
+
33
+ const map = new maplibregl.Map({
34
+ container: "map",
35
+ style: isomGeojsonStyle({ contours, water, paths }), // FeatureCollections, lng/lat
36
+ });
37
+ registerIsomIcons(map);
38
+ ```
39
+
40
+ Tables you leave out start empty; fill them later with
41
+ `map.getSource(table).setData(featureCollection)`. `DETAIL_TABLES` lists them.
42
+ The style without data is also exported as
43
+ `@metsa/isom-maplibre/style.geojson.json`.
44
+
45
+ ### Vector tiles
46
+
47
+ ```ts
48
+ import style from "@metsa/isom-maplibre/style.json";
49
+ ```
50
+
51
+ The style reads one vector source per table from `/tiles/<table>` (TileJSON)
52
+ and its images from the sprite `/sprite/isom`. Point those URLs at your server
53
+ and build the sprite from `ICONS` (SVGs keyed by image id), or pass `-sprites`
54
+ to the generator (see [Development](#development)). Two optional sources serve
55
+ large tile sets:
56
+
57
+ - `overview`: one source whose layers carry `vegetation_areas`, `water`,
58
+ `paths` and `manmade`, drawn below zoom 13 in place of the per-table sources.
59
+ - `coverage`: polygons outlining where data exists. From zoom 10 they are white
60
+ paper under the map, so a basemap merged underneath never shows through; below
61
+ zoom 10 they are a translucent brown patch marking where maps are.
62
+
63
+ Every layer except the background carries `metadata` for selecting layers
64
+ without parsing ids: `isom:pass` (`detail`, `overview` or `coverage`), and on
65
+ symbol layers `isom:code` and `isom:group` (the ISOM colour group). The style's
66
+ own `metadata["isom:tables"]` lists the tables in definition order.
67
+
68
+ ## Data
69
+
70
+ Each feature carries a string `isom_code` property. Codes without a symbol stay
71
+ invisible.
72
+
73
+ | Table | Geometry | `isom_code` |
74
+ |--------------------|-----------------|----------------------------------------------------------------------|
75
+ | `contours` | lines | 101.000, 101.001 (slope line), 102.000, 103.000, 104.000, 105.000 |
76
+ | `cliffs` | lines, polygons | 201.000, 202.000, 206.000 |
77
+ | `knolls_points` | points | 109.000, 111.000 |
78
+ | `vegetation_areas` | polygons, lines | 401.000 to 410.000, 412.000, 413.000, 415.000 |
79
+ | `water` | lines, polygons | 301.000, 302.000, 304.000, 305.000, 306.000, 308.000 |
80
+ | `paths` | lines | 502.000 to 507.000 |
81
+ | `manmade` | lines, polygons | 501.000, 509.000, 510.000, 511.000, 515.000, 516.000, 520.000, 521.000, 521.001 (large building: outline and 65% infill), 529.000 |
82
+
83
+ A 101.001 slope line is a two-point line from the contour downhill; the symbol
84
+ takes its bearing from it. Coordinates are lng/lat, as for any MapLibre source.
85
+
86
+ ## Scale
87
+
88
+ Up to zoom 15 every dimension is constant; above it the whole map magnifies ×2
89
+ per zoom level, which keeps ISOM proportions intact. Zoom 15 is true 1:10,000
90
+ at about 56° latitude. At latitude φ the true-scale zoom is
91
+ `log2(156543.03 · cos φ / 2.6458)`.
92
+
93
+ ## Limitations
94
+
95
+ MapLibre cannot draw some ISOM ornaments, so these symbols are simplified:
96
+
97
+ - Plain line without its ornament: 104 tags, 105 dots, 201 tags, 510 pylon
98
+ bars, 515 dots, 516 tags, 529 tick pairs.
99
+ - Flat fill without its pattern: 402 and 404 holes, 412 dots, 413 dot rows.
100
+ - Dashes are not balanced per feature as ISOM asks.
101
+ - The white core of 509 and 511 masks what lies beneath it.
102
+ - Fill patterns (308, 407, 409) keep their density above zoom 15.
103
+ - The map must be displayed north-up for the north-oriented patterns to be
104
+ correct.
105
+
106
+ ## Development
107
+
108
+ [`isom.yaml`](isom.yaml) defines the whole style: scale, palette (referenced
109
+ through YAML anchors), images, and the symbol stack. It is validated by
110
+ [`isom.schema.json`](isom.schema.json), which editors with the YAML language
111
+ server pick up automatically; the generator enforces the schema too, plus the
112
+ cross-references it cannot express (palette colours, table and image names).
113
+ `src/style.json`, `src/style.geojson.json` and `src/icons.json` are generated
114
+ from it:
115
+
116
+ ```sh
117
+ go generate ./... # regenerate src/
118
+ go test ./... # schema, ISOM dimension and colour-order checks
119
+ npm run build && npm test
120
+ ```
121
+
122
+ The generator is also usable directly, for example to write a sprite directory
123
+ for a tile server. It reads the definition embedded in the module unless
124
+ `-spec` names another file:
125
+
126
+ ```sh
127
+ go run github.com/MetsaApp/isom-maplibre/cmd/genstyle@latest \
128
+ -style style.json -geojson-style style.geojson.json -icons icons.json -sprites sprites/
129
+ ```
130
+
131
+ Go programs get the parsed definition from `isomstyle.Default()` in
132
+ `github.com/MetsaApp/isom-maplibre/pkg/isomstyle` (`Load` and `Parse` take
133
+ another one), and render it with `Style`, `GeojsonStyle` and `Icons`.
134
+
135
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org);
136
+ release-please opens the release PR, and merging it tags the release and
137
+ publishes the package.
138
+
139
+ ## License
140
+
141
+ MIT. ISOM is a specification of the International Orienteering Federation;
142
+ this project is not affiliated with the IOF.
@@ -0,0 +1,7 @@
1
+ {
2
+ "isom:111": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"5\" height=\"3\" viewBox=\"0 0 5 3\"><path d=\"M0.742 0.621A1.758 1.758 0 0 0 4.258 0.621\" fill=\"none\" stroke=\"#D15C00\" stroke-width=\"1.02\"/></svg>",
3
+ "isom:308": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"4\" height=\"17\" viewBox=\"0 0 4 17\"><path d=\"M0 0.85H4M0 2.55H4M0 4.25H4M0 5.95H4M0 7.65H4M0 9.35H4M0 11.05H4M0 12.75H4M0 14.45H4M0 16.15H4\" fill=\"none\" stroke=\"#00FFFF\" stroke-width=\"0.567\"/></svg>",
4
+ "isom:407": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"62\" height=\"4\" viewBox=\"0 0 62 4\"><path d=\"M2.385 0V4M7.154 0V4M11.923 0V4M16.692 0V4M21.462 0V4M26.231 0V4M31 0V4M35.769 0V4M40.538 0V4M45.308 0V4M50.077 0V4M54.846 0V4M59.615 0V4\" fill=\"none\" stroke=\"#3DFF17\" stroke-width=\"0.68\"/></svg>",
5
+ "isom:409": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"31\" height=\"4\" viewBox=\"0 0 31 4\"><path d=\"M1.192 0V4M3.577 0V4M5.962 0V4M8.346 0V4M10.731 0V4M13.115 0V4M15.5 0V4M17.885 0V4M20.269 0V4M22.654 0V4M25.038 0V4M27.423 0V4M29.808 0V4\" fill=\"none\" stroke=\"#3DFF17\" stroke-width=\"0.794\"/></svg>",
6
+ "isom:slope": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"3\" height=\"1\" viewBox=\"0 0 3 1\"><path d=\"M0.167 0.5H2.833\" stroke=\"#D15C00\" stroke-width=\"0.794\"/></svg>"
7
+ }
@@ -0,0 +1,19 @@
1
+ import type { FeatureCollection } from "geojson";
2
+ import type { Map, StyleSpecification } from "maplibre-gl";
3
+ import geojsonStyle from "./style.geojson.json";
4
+ /** Pattern and point-symbol SVGs keyed by style image id ("isom:111"), sized
5
+ * in CSS px at 1:10,000. registerIsomIcons() rasterizes them for you. */
6
+ export declare const ICONS: Record<string, string>;
7
+ export type IsomTable = keyof typeof geojsonStyle.sources;
8
+ /** Source layers the style expects, in definition order; each feature carries
9
+ * a string `isom_code` property ("401.000", "509.000", ...) selecting the ISOM
10
+ * symbol. */
11
+ export declare const DETAIL_TABLES: readonly IsomTable[];
12
+ /** The generated GeoJSON style (one source per table, the full-detail symbol
13
+ * pass at every zoom, no sprite: registerIsomIcons supplies the images) with
14
+ * the given FeatureCollections attached. Tables left out start empty. */
15
+ export declare function isomGeojsonStyle(data?: Partial<Record<IsomTable, FeatureCollection>>): StyleSpecification;
16
+ /** Rasterizes the ISOM pattern/symbol images on demand at 2x (fill patterns
17
+ * and point symbols reference them as "isom:<id>"). Call once right after
18
+ * constructing the Map. */
19
+ export declare function registerIsomIcons(map: Map): void;
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ import geojsonStyle from "./style.geojson.json" with { type: "json" };
2
+ import icons from "./icons.json" with { type: "json" };
3
+ /** Pattern and point-symbol SVGs keyed by style image id ("isom:111"), sized
4
+ * in CSS px at 1:10,000. registerIsomIcons() rasterizes them for you. */
5
+ export const ICONS = icons;
6
+ /** Source layers the style expects, in definition order; each feature carries
7
+ * a string `isom_code` property ("401.000", "509.000", ...) selecting the ISOM
8
+ * symbol. */
9
+ export const DETAIL_TABLES = geojsonStyle.metadata["isom:tables"];
10
+ /** The generated GeoJSON style (one source per table, the full-detail symbol
11
+ * pass at every zoom, no sprite: registerIsomIcons supplies the images) with
12
+ * the given FeatureCollections attached. Tables left out start empty. */
13
+ export function isomGeojsonStyle(data) {
14
+ const style = structuredClone(geojsonStyle);
15
+ for (const t of DETAIL_TABLES) {
16
+ const fc = data?.[t];
17
+ if (fc)
18
+ style.sources[t].data = fc;
19
+ }
20
+ return style;
21
+ }
22
+ /** Rasterizes the ISOM pattern/symbol images on demand at 2x (fill patterns
23
+ * and point symbols reference them as "isom:<id>"). Call once right after
24
+ * constructing the Map. */
25
+ export function registerIsomIcons(map) {
26
+ const pending = new Set();
27
+ map.on("styleimagemissing", async ({ id }) => {
28
+ const svg = ICONS[id];
29
+ // styleimagemissing refires every frame until the image exists.
30
+ if (!svg || pending.has(id) || map.hasImage(id))
31
+ return;
32
+ pending.add(id);
33
+ const img = new Image();
34
+ img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
35
+ await img.decode();
36
+ const c = document.createElement("canvas");
37
+ c.width = img.width * 2;
38
+ c.height = img.height * 2;
39
+ const ctx = c.getContext("2d");
40
+ ctx.drawImage(img, 0, 0, c.width, c.height);
41
+ if (!map.hasImage(id)) {
42
+ map.addImage(id, ctx.getImageData(0, 0, c.width, c.height), { pixelRatio: 2 });
43
+ }
44
+ });
45
+ }