diff --git a/README.md b/README.md index 3ff3648..55939e9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ A file named `guide.md` produces `guide.html`. A folder named `docs` produces `d Relative links and images remain unchanged, so they resolve from the generated HTML file. The site needs internet access to load its pinned Markdown-it dependency. +`geomap` code fences render GeoJSON layers as interactive maps with searchable feature tables and Google Maps links for points. Maps load Leaflet and OpenStreetMap tiles online; the table remains usable if the map fails to load. See the [GeoMap guide](https://github.com/asafmor/pageleaf/blob/main/docs/geomap.md) and [example](https://github.com/asafmor/pageleaf/blob/main/examples/geomap.md). + For uploads, install [Git](https://git-scm.com/downloads) and [GitHub CLI](https://cli.github.com/), then run `gh auth login --hostname github.com`. Use `--upload my-docs` for the signed-in user's personal account, or `--upload my-org/my-docs` for an explicit owner. The repository must already exist and permit direct pushes; an empty repository is supported. Uploads keep the generated filename (`docs.html` in the example), replace that remote file automatically, and leave other files unchanged. Identical content creates no commit. Commits use the signed-in account's GitHub noreply identity. `--force` controls local output replacement only. If an upload fails, the local HTML remains available; fix the reported problem and rerun with `--force`. Uploading stores the file in GitHub; it does not configure GitHub Pages. diff --git a/docs/geomap.md b/docs/geomap.md new file mode 100644 index 0000000..2d0f9a0 --- /dev/null +++ b/docs/geomap.md @@ -0,0 +1,33 @@ +# GeoMap fences + +Put a JSON object with a `layers` array inside a `geomap` Markdown code fence. Generate the page as usual: + +```bash +npx pageleaf examples/geomap.md --open +``` + +See the [complete example](../examples/geomap.md) for points, routes, a boundary, and multiple maps. + +Each layer requires nonempty `id`, `name`, and `role` strings and a `geojson` object. IDs must be unique within a map. GeoJSON may be a FeatureCollection, Feature, or raw geometry; all standard geometry types are supported. Optional layer `source` metadata accepts string fields `url`, `publisher`, `retrievedAt`, and `originalFormat`. GPX conversion must happen before rendering; source files are never downloaded or converted by Pageleaf. + +The map uses [Leaflet 1.9.4](https://leafletjs.com/download.html) and OpenStreetMap tiles. It needs internet access for those assets. Pageleaf embeds its own map component in the HTML, so the generated file needs no adjacent scripts or backend. If Leaflet cannot load, the feature table and its links remain available. The document itself still needs the existing Markdown-it CDN dependency. + +All layers start visible and can be toggled under **Layers**. Routes use different colors and line patterns. Point categories select a marker symbol where recognized; unknown categories use a generic marker. The initial view includes every visible geometry, with a maximum initial zoom of 15 for isolated points. + +The table contains one row per Feature, including a named fallback for raw geometries. It opens by default for up to eight rows; larger tables start collapsed and offer a text filter. Optional columns appear when the data supplies them: + +| Column | Property | +| --- | --- | +| Name | `name`, or layer name and feature number | +| Category / Type | `category`, then `type`, then layer role | +| Layer | Layer name; hidden layers retain their rows with a visible state label | +| Route section | `routeSection` or `route_section` | +| Details | `note`, `description`, or `desc` | +| πŸ“ | Google Maps link for Point coordinates only; other geometry cells stay empty | +| Source | Feature `source.url`, `source` URL string, `sourceUrl`, or `source_url`; falls back to layer `source.url` | + +Select a name with the keyboard, or click a row, to reveal its layer, scroll to the map, focus the geometry, and open its popup. Selecting a map feature highlights it and its row without scrolling the page, expanding the table, or changing its filter. Hover highlights do not move the map. Google Maps and source links open a separate tab without selecting the row. Printed pages include expanded feature tables. + +Invalid JSON displays a local error. Invalid layers are omitted while valid siblings remain visible. Empty collections and unlocated features are accepted. Coordinates must be finite longitude/latitude values; polygon rings must be closed. Technical details go to the browser console. Feature text is rendered as text, and source URLs accept only HTTP or HTTPS. + +For development, `npm test` builds the template and tests normalization, the table, actual Leaflet interactions, failure handling, and the generated HTML. GeoMap modules live in `src/geomap/`; the build inlines their bundle and CSS into `dist/template.html`. Run `npm run build:template` after editing them to refresh the editable root `index.html` page's bundle. diff --git a/examples/geomap.md b/examples/geomap.md new file mode 100644 index 0000000..d1c5398 --- /dev/null +++ b/examples/geomap.md @@ -0,0 +1,78 @@ +# GeoMap example + +This illustrative dataset demonstrates places, routes, and a boundary. The route lines are examples, not navigation tracks. + +Select a name in the table to focus the map. Map clicks stay on the map. Point rows also have a small Google Maps link. + +```geomap +{ + "layers": [ + { + "id": "places", + "name": "Points of interest", + "role": "poi", + "source": { "url": "https://www.openstreetmap.org/" }, + "geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "name": "Valldemossa", "category": "settlement", "routeSection": "Core", "note": "Village reference point." }, + "geometry": { "type": "Point", "coordinates": [2.62300, 39.71080] } + }, + { + "type": "Feature", + "properties": { "name": "Illustrative viewpoint", "category": "viewpoint", "routeSection": "Detour" }, + "geometry": { "type": "Point", "coordinates": [2.641, 39.721] } + } + ] + } + }, + { + "id": "route", + "name": "Example route", + "role": "route", + "geojson": { + "type": "Feature", + "properties": { "name": "Illustrative route", "description": "One table entry for the entire line." }, + "geometry": { "type": "LineString", "coordinates": [[2.623, 39.7108], [2.632, 39.716], [2.647, 39.719]] } + } + }, + { + "id": "detour", + "name": "Example detour", + "role": "route", + "geojson": { + "type": "Feature", + "properties": { "name": "Illustrative detour", "route_section": "Detour" }, + "geometry": { "type": "MultiLineString", "coordinates": [[[2.632, 39.716], [2.641, 39.721]], [[2.641, 39.721], [2.647, 39.719]]] } + } + }, + { + "id": "boundary", + "name": "Example boundary", + "role": "boundary", + "geojson": { + "type": "Feature", + "properties": { "name": "Illustrative area" }, + "geometry": { "type": "Polygon", "coordinates": [[[2.629, 39.71], [2.648, 39.71], [2.648, 39.725], [2.629, 39.71]]] } + } + } + ] +} +``` + +Multiple maps have independent layers and selection: + +```geomap +{ + "layers": [ + { + "id": "single-point", + "name": "Single point", + "role": "poi", + "geojson": { "type": "Point", "coordinates": [2.62300, 39.71080] } + } + ] +} +``` diff --git a/index.html b/index.html index 904f85d..217ad4e 100644 --- a/index.html +++ b/index.html @@ -36,6 +36,7 @@ /* Standard scrollbar properties override the custom parts unless both are auto. */ @supports selector(::-webkit-scrollbar){.primary{scrollbar-width:auto;scrollbar-color:auto}.primary::-webkit-scrollbar-button{display:none;width:0;height:0}} + @@ -805,6 +806,7 @@ + diff --git a/package-lock.json b/package-lock.json index 8a0e268..9b65b9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,1207 @@ "bin": { "pageleaf": "bin/pageleaf.js" }, + "devDependencies": { + "esbuild": "0.28.2", + "jsdom": "27.4.0", + "leaflet": "1.9.4", + "markdown-it": "14.1.0" + }, "engines": { "node": ">=20" } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index ce7f051..7216e51 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "files": [ "bin/", - "dist/", + "dist/template.html", "lib/", "LICENSE", "README.md" @@ -38,5 +38,11 @@ "static-site", "cli" ], - "license": "MIT" + "license": "MIT", + "devDependencies": { + "esbuild": "0.28.2", + "jsdom": "27.4.0", + "leaflet": "1.9.4", + "markdown-it": "14.1.0" + } } diff --git a/scripts/build-template.mjs b/scripts/build-template.mjs index 5914430..23bc337 100644 --- a/scripts/build-template.mjs +++ b/scripts/build-template.mjs @@ -1,18 +1,33 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const sourcePath = path.join(root, 'index.html'); const outputPath = path.join(root, 'dist', 'template.html'); const opening = '', start + opening.length); if (start < 0 || end < 0) throw new Error('index.html has no valid markdown-source block'); const placeholder = '# Pageleaf\n\nYour document is ready.'; -const template = `${source.slice(0, start + opening.length)}${placeholder}${source.slice(end)}`; +const template = `${source.slice(0, start + opening.length)}${placeholder}${source.slice(end)}` + .replace('', () => ``) + .replace('', () => ``); await mkdir(path.dirname(outputPath), { recursive: true }); +// The editable source page uses a classic bundle so it also opens over file://. +await writeFile(path.join(root, 'dist', 'geomap.js'), geomapScript, 'utf8'); await writeFile(outputPath, template, 'utf8'); diff --git a/src/geomap/data.js b/src/geomap/data.js new file mode 100644 index 0000000..9386fc3 --- /dev/null +++ b/src/geomap/data.js @@ -0,0 +1,141 @@ +const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +function text(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + } + return ''; +} + +export function safeUrl(value) { + if (typeof value !== 'string') return ''; + try { + const url = new URL(value); + return ['https:', 'http:'].includes(url.protocol) ? url.href : ''; + } catch { + return ''; + } +} + +function sourceUrl(source) { + return safeUrl(isObject(source) ? source.url : source); +} + +function position(value) { + if (!Array.isArray(value) || value.length < 2 || !value.every(Number.isFinite) + || Math.abs(value[0]) > 180 || Math.abs(value[1]) > 90) { + throw new Error('Invalid longitude/latitude position'); + } +} + +function coordinates(value, child, minimum = 0) { + if (!Array.isArray(value) || value.length < minimum) throw new Error('Invalid coordinate array'); + value.forEach(child); +} + +function line(value) { + coordinates(value, position, 2); +} + +function ring(value) { + coordinates(value, position, 4); + const first = value[0], last = value.at(-1); + if (first.length !== last.length || first.some((n, i) => n !== last[i])) { + throw new Error('Polygon rings must be closed'); + } +} + +function polygon(value) { + coordinates(value, ring, 1); +} + +function validateGeometry(geometry, depth = 0) { + if (geometry === null) return; + if (!isObject(geometry) || depth > 64) throw new Error('Invalid or excessively nested geometry'); + if (geometry.type === 'GeometryCollection') { + if (!Array.isArray(geometry.geometries)) throw new Error('Missing geometries array'); + geometry.geometries.forEach(child => { + if (child === null) throw new Error('Null GeometryCollection member'); + validateGeometry(child, depth + 1); + }); + return; + } + const validators = { + Point: position, + MultiPoint: value => coordinates(value, position), + LineString: line, + MultiLineString: value => coordinates(value, line), + Polygon: polygon, + MultiPolygon: value => coordinates(value, polygon) + }; + if (!Object.hasOwn(validators, geometry.type)) throw new Error(`Unsupported geometry: ${geometry.type}`); + // GeoJSON permits empty coordinates as an unlocated geometry. Keep its table entry. + if (Array.isArray(geometry.coordinates) && geometry.coordinates.length === 0) return; + validators[geometry.type](geometry.coordinates); +} + +function asFeatures(geojson) { + if (!isObject(geojson)) throw new Error('Missing GeoJSON object'); + const features = geojson.type === 'FeatureCollection' ? geojson.features + : [geojson.type === 'Feature' ? geojson : { type: 'Feature', properties: {}, geometry: geojson }]; + if (!Array.isArray(features)) throw new Error('Missing features array'); + for (const feature of features) { + if (!isObject(feature) || feature.type !== 'Feature' + || !(feature.properties === null || isObject(feature.properties))) { + throw new Error('Invalid GeoJSON Feature'); + } + validateGeometry(feature.geometry); + } + return features; +} + +export function hasGeometry(geometry) { + if (!geometry) return false; + if (geometry.type === 'GeometryCollection') return geometry.geometries.some(hasGeometry); + return geometry.coordinates.length > 0; +} + +export function drawableGeometry(geometry) { + if (!hasGeometry(geometry)) return null; + if (geometry.type !== 'GeometryCollection') return geometry; + // Leaflet cannot draw empty collection members, although GeoJSON permits them. + return { ...geometry, geometries: geometry.geometries.map(drawableGeometry).filter(Boolean) }; +} + +export function normalizeGeoMap(input) { + if (!isObject(input) || !Array.isArray(input.layers)) throw new Error('GeoMap requires a layers array'); + const layers = [], features = [], errors = [], ids = new Set(); + input.layers.forEach((layer, layerIndex) => { + try { + if (!isObject(layer) || !['id', 'name', 'role'].every(key => typeof layer[key] === 'string' && layer[key].trim())) { + throw new Error('Layer requires a nonempty id, name and role'); + } + if (ids.has(layer.id)) throw new Error('Duplicate layer id'); + if (layer.source !== undefined && (!isObject(layer.source) + || ['url', 'publisher', 'retrievedAt', 'originalFormat'].some(key => layer.source[key] !== undefined && typeof layer.source[key] !== 'string'))) { + throw new Error('Invalid layer source metadata'); + } + const entries = asFeatures(layer.geojson).map((feature, index) => { + const p = feature.properties || {}; + return { + id: `${layer.id}/${index}`, layerId: layer.id, layerName: layer.name, role: layer.role, + feature, + name: text(p.name) || `${layer.name} Β· ${index + 1}`, + category: text(p.category, p.type) || layer.role, + routeSection: text(p.routeSection, p.route_section), + note: text(p.note, p.description, p.desc), + sourceUrl: sourceUrl(p.source) || safeUrl(p.sourceUrl) || safeUrl(p.source_url) || sourceUrl(layer.source), + point: feature.geometry?.type === 'Point' && hasGeometry(feature.geometry) ? feature.geometry.coordinates : null + }; + }); + ids.add(layer.id); + layers.push({ id: layer.id, name: layer.name, role: layer.role, features: entries }); + for (const entry of entries) features.push(entry); + } catch (error) { + errors.push(`Layer ${layerIndex + 1}: ${error.message}`); + } + }); + if (input.layers.length && !layers.length) throw new Error(errors.join('; ')); + return { layers, features, errors }; +} diff --git a/src/geomap/dom.js b/src/geomap/dom.js new file mode 100644 index 0000000..3b19afa --- /dev/null +++ b/src/geomap/dom.js @@ -0,0 +1,27 @@ +export function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +export function externalLink(url, label) { + const link = element('a', '', label); + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + // Links are independent actions inside an otherwise selectable row. + link.addEventListener('click', event => event.stopPropagation()); + return link; +} + +export function popupContent(feature) { + const content = element('div', 'geomap-popup'); + content.append(element('strong', '', feature.name)); + for (const [label, value] of [['Category', feature.category], ['Layer', feature.layerName], ['Route section', feature.routeSection]]) { + if (value) content.append(element('div', '', `${label}: ${value}`)); + } + if (feature.note) content.append(element('p', '', feature.note)); + if (feature.sourceUrl) content.append(externalLink(feature.sourceUrl, 'Source β†—')); + return content; +} diff --git a/src/geomap/geomap.css b/src/geomap/geomap.css new file mode 100644 index 0000000..c594e16 --- /dev/null +++ b/src/geomap/geomap.css @@ -0,0 +1,86 @@ +.geomap { + margin: 28px 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + overflow: hidden; +} + +.geomap-map { + height: 420px; + width: 100%; + scroll-margin-top: calc(var(--header) + 16px); + /* Leaflet's panes must stay below the document's sticky navigation. */ + isolation: isolate; + z-index: 0; + background: var(--soft); + color: #222; +} + +.geomap-toolbar { padding: 8px 12px; border-bottom: 1px solid var(--line); } +.geomap-toolbar:empty { display: none; } +.geomap-layers { font-size: 14px; } +.geomap summary { cursor: pointer; font-weight: 600; } +.geomap-layers fieldset { border: 0; padding: 8px 0 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px 20px; } +.geomap-layers label { display: inline-flex; gap: 8px; align-items: center; min-height: 32px; cursor: pointer; } +.geomap input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); } +.geomap-layer-swatch { width: 22px; border-top: 3px solid; } +.geomap-data > summary { padding: 12px 16px; border-top: 1px solid var(--line); } +.geomap-table-wrapper { width: 100%; overflow-x: auto; } +.article .geomap-table { display: table; margin: 0; width: 100%; font-size: 13px; } +.article .geomap-table th, .article .geomap-table td { padding: 10px 12px; vertical-align: top; overflow-wrap: anywhere; } +.article .geomap-table th { text-transform: none; letter-spacing: normal; } +.geomap-table td { min-width: 100px; } +.geomap-table td:first-child { min-width: 140px; } +.geomap-table tbody tr { cursor: pointer; } +.geomap-table tr.is-hovered, .geomap-table tr:hover { background: var(--soft); } +.geomap-table tr.is-selected { background: var(--soft); box-shadow: inset 4px 0 var(--accent); } +.geomap-layer-state { display: block; font-size: 11px; font-style: italic; color: var(--muted); } +.geomap .geomap-feature-button { border: 0; background: transparent; padding: 0; border-radius: 0; text-align: left; color: var(--accent); text-decoration: underline; text-underline-offset: 3px; } +.geomap .geomap-feature-button[aria-current] { font-weight: 700; } +.article .geomap-table .map-link-cell, .article .geomap-table .map-link-header { + width: 1%; + min-width: 0; + white-space: nowrap; + text-align: center; + padding-left: 6px; + padding-right: 6px; +} +.map-link-cell a { display: inline-flex; align-items: center; justify-content: center; min-width: 24px; min-height: 24px; text-decoration: none; } +.geomap-table-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; font-size: 13px; } +.geomap-table-controls input { font: inherit; color: var(--text); background: var(--surface); border: 1px solid var(--line); padding: 8px 10px; min-width: 0; max-width: 100%; border-radius: var(--radius); } +.geomap-filter-status { color: var(--muted); } +.geomap-message, .geomap-error { padding: 12px 16px; color: var(--muted); font-size: 14px; } +.geomap-error { border: 1px solid var(--line); border-radius: var(--radius); } +.geomap .geomap-marker { + border: 2px solid #fff; + border-radius: 50%; + background: #28614b; + color: #fff; + display: grid; + place-items: center; + font: bold 20px/1 Arial, sans-serif; + box-shadow: 0 1px 5px #0009; +} +.geomap .geomap-marker.is-hovered { outline: 3px solid #222; outline-offset: 2px; } +.geomap .geomap-marker.is-selected { outline: 4px double #222; outline-offset: 2px; background: #153c2d; } +.geomap .leaflet-container :focus-visible { outline: 3px solid #1769aa; outline-offset: 3px; } +.geomap-popup { font: 14px/1.5 Arial, sans-serif; color: #222; overflow-wrap: anywhere; max-height: 240px; overflow-y: auto; } +.geomap-popup strong { display: block; margin-bottom: 6px; } +.article .geomap-popup p { margin: 6px 0; } +.geomap-popup a { color: #1769aa; } +.geomap-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + +@media (max-width: 700px) { + .geomap-map { height: 380px; } + .geomap-layers fieldset { flex-direction: column; gap: 4px; } + .geomap-table-controls { align-items: stretch; flex-direction: column; } +} + +@media print { + .geomap-map, .geomap-toolbar, .geomap-table-controls { display: none; } + .geomap { overflow: visible; } + .geomap-table-wrapper { overflow: visible; } + .geomap-data > summary { list-style: none; } + .geomap-table tr { break-inside: avoid; } +} diff --git a/src/geomap/index.js b/src/geomap/index.js new file mode 100644 index 0000000..91e6d55 --- /dev/null +++ b/src/geomap/index.js @@ -0,0 +1,81 @@ +import { normalizeGeoMap } from './data.js'; +import { element } from './dom.js'; +import { FeatureTable } from './table.js'; +import { FeatureMap } from './map.js'; +import { loadLeaflet } from './leaflet.js'; + +export function install(md, { loadMapLibrary = loadLeaflet } = {}) { + const originalFence = md.renderer.rules.fence; + const parsed = new WeakMap(), pending = new Map(), components = new WeakMap(); + let nextId = 0; + md.renderer.rules.fence = (tokens, index, ...args) => { + const token = tokens[index]; + if (token.info.trim().split(/\s+/)[0] !== 'geomap') return originalFence(tokens, index, ...args); + if (!parsed.has(token)) { + try { + const runtime = normalizeGeoMap(JSON.parse(token.content)); + runtime.errors.forEach(error => console.warn('GeoMap:', error)); + parsed.set(token, runtime); + } catch (error) { + console.error('GeoMap:', error); + parsed.set(token, null); + } + } + const runtime = parsed.get(token); + if (!runtime) return '

Map data could not be rendered.

'; + const id = `geomap-${++nextId}`; + pending.set(id, runtime); + return `
`; + }; + + function enhance(root, { interactive = true } = {}) { + const active = components.get(root) || []; + for (const node of root.querySelectorAll('[data-geomap-id]')) { + const id = node.dataset.geomapId, runtime = pending.get(id); + if (!runtime) continue; + pending.delete(id); + const component = { disposed: false }; + active.push(component); + const toolbar = element('div', 'geomap-toolbar'); + const container = element('div', 'geomap-map'); + container.id = `${id}-map`; + container.setAttribute('role', 'region'); + container.setAttribute('aria-label', 'Interactive geographic map'); + const status = element('p', 'geomap-message', interactive ? 'Loading map…' : 'Geographic features'); + status.setAttribute('role', 'status'); + container.append(status); + node.append(toolbar, container); + const table = new FeatureTable(node, runtime, id, { + onSelect: featureId => { table.select(featureId); component.map?.focus(featureId); }, + onHover: featureId => component.map?.hover(featureId) + }); + if (runtime.errors.length) node.prepend(element('p', 'geomap-message', 'Some map layers could not be rendered.')); + if (!interactive) { table.details.open = true; continue; } + Promise.resolve().then(loadMapLibrary).then(L => { + if (component.disposed || !node.isConnected) return; + status.remove(); + component.map = new FeatureMap(L, container, toolbar, runtime, { + onSelect: featureId => table.select(featureId), + onHover: featureId => table.hover(featureId), + onVisibility: (layer, visible) => table.setLayerVisible(layer, visible) + }); + }).catch(error => { + if (component.disposed) return; + console.error('GeoMap:', error); + toolbar.replaceChildren(); + container.replaceChildren(element('p', 'geomap-message', 'Map unavailable. Geographic features and links are listed below.')); + }); + } + components.set(root, active); + } + + function dispose(root) { + for (const component of components.get(root) || []) { + component.disposed = true; + component.map?.destroy(); + } + components.delete(root); + } + + return { enhance, dispose }; +} diff --git a/src/geomap/leaflet.js b/src/geomap/leaflet.js new file mode 100644 index 0000000..2607234 --- /dev/null +++ b/src/geomap/leaflet.js @@ -0,0 +1,34 @@ +let loading; + +export function loadLeaflet() { + if (loading) return loading; + // Load only on pages with maps; one failure must not block Markdown or its tables. + loading = Promise.all([ + loadAsset('link', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY='), + loadAsset('script', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=') + ]).then(() => { + if (!window.L) throw new Error('Leaflet did not initialize'); + return window.L; + }); + return loading; +} + +function loadAsset(tag, url, integrity) { + return new Promise((resolve, reject) => { + const node = document.createElement(tag); + if (tag === 'link') { node.rel = 'stylesheet'; node.href = url; } + else node.src = url; + node.integrity = integrity; + node.crossOrigin = 'anonymous'; + const timer = setTimeout(() => finish(new Error('Leaflet download timed out')), 15000); + const finish = error => { + clearTimeout(timer); + node.onload = node.onerror = null; + if (error) { node.remove(); reject(error); } + else resolve(); + }; + node.onload = () => finish(); + node.onerror = () => finish(new Error('Leaflet download failed')); + document.head.append(node); + }); +} diff --git a/src/geomap/map.js b/src/geomap/map.js new file mode 100644 index 0000000..9ad4bde --- /dev/null +++ b/src/geomap/map.js @@ -0,0 +1,132 @@ +import { drawableGeometry } from './data.js'; +import { element, popupContent } from './dom.js'; +import { emphasize, layerStyle, pointMarker } from './style.js'; + +export class FeatureMap { + constructor(L, container, toolbar, runtime, { onSelect, onHover, onVisibility }) { + this.L = L; + this.container = container; + this.layers = new Map(); + this.features = new Map(); + this.onSelect = onSelect; + this.onHover = onHover; + this.reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + this.map = L.map(container, { + scrollWheelZoom: false, + fadeAnimation: !this.reducedMotion, + zoomAnimation: !this.reducedMotion, + markerZoomAnimation: !this.reducedMotion + }).setView([20, 0], 2); + try { + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: 'Β© OpenStreetMap contributors' + }).addTo(this.map); + let routeIndex = 0; + const controls = element('details', 'geomap-layers'); + controls.append(element('summary', '', 'Layers')); + const fields = element('fieldset'); + fields.append(element('legend', 'geomap-sr-only', 'Visible map layers')); + for (const layer of runtime.layers) { + const style = layerStyle(layer.role, layer.role === 'route' ? routeIndex++ : 0); + const group = L.featureGroup().addTo(this.map); + const label = element('label'); + const checkbox = element('input'); + checkbox.type = 'checkbox'; + checkbox.checked = true; + checkbox.addEventListener('change', () => { + if (checkbox.checked) group.addTo(this.map); + else this.map.removeLayer(group); + onVisibility(layer, checkbox.checked); + this.refresh(this.selectedId); + this.refresh(this.hoveredId); + }); + const swatch = element('span', 'geomap-layer-swatch'); + swatch.style.borderTopColor = style.color; + swatch.style.borderTopStyle = style.dashArray ? 'dashed' : 'solid'; + swatch.setAttribute('aria-hidden', 'true'); + label.append(checkbox, swatch, document.createTextNode(layer.name)); + fields.append(label); + this.layers.set(layer.id, { group, checkbox, layer }); + for (const feature of layer.features) { + const drawable = drawableGeometry(feature.feature.geometry); + if (!drawable) continue; + const geometry = L.geoJSON({ ...feature.feature, geometry: drawable }, { + style: () => style, + pointToLayer: (_, latlng) => pointMarker(L, latlng, feature) + }); + geometry.bindPopup(() => popupContent(feature), { maxWidth: 300, autoPan: false }); + geometry.on('click', () => this.select(feature.id)); + geometry.on('mouseover', () => this.hover(feature.id)); + geometry.on('mouseout', () => this.hover(null)); + geometry.addTo(group); + this.features.set(feature.id, { geometry, style, feature }); + } + } + controls.append(fields); + toolbar.append(controls); + const bounds = L.latLngBounds([]); + for (const { group } of this.layers.values()) { + const layerBounds = group.getBounds(); + if (layerBounds.isValid()) bounds.extend(layerBounds); + } + if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [24, 24], maxZoom: 15 }); + // Navigation and responsive layout can resize an existing map without a window resize. + if (window.ResizeObserver) { + this.observer = new ResizeObserver(() => this.map.invalidateSize({ pan: false })); + this.observer.observe(container); + } + this.onVisibility = onVisibility; + } catch (error) { + this.destroy(); + throw error; + } + } + + refresh(id) { + const binding = this.features.get(id); + if (binding) emphasize(binding.geometry, binding.style, id === this.selectedId ? 2 : id === this.hoveredId ? 1 : 0); + } + + select(id) { + const previous = this.selectedId; + this.selectedId = id; + this.refresh(previous); + this.refresh(id); + this.onSelect(id); + } + + hover(id) { + const previous = this.hoveredId; + this.hoveredId = id; + this.refresh(previous); + this.refresh(id); + this.onHover(id); + } + + focus(id) { + const binding = this.features.get(id); + if (!binding) { this.select(id); return; } + const { geometry, feature } = binding; + const entry = this.layers.get(feature.layerId); + entry.group.addTo(this.map); + entry.checkbox.checked = true; + this.onVisibility(entry.layer, true); + this.select(id); + this.container.scrollIntoView({ behavior: this.reducedMotion ? 'instant' : 'smooth', block: 'start' }); + this.map.invalidateSize({ pan: false }); + if (feature.point) { + const [longitude, latitude] = feature.point; + this.map.flyTo([latitude, longitude], Math.max(this.map.getZoom(), 15), { animate: !this.reducedMotion }); + } else { + const bounds = geometry.getBounds(); + if (bounds.isValid()) this.map.fitBounds(bounds, { padding: [30, 30], maxZoom: 16, animate: !this.reducedMotion }); + } + geometry.openPopup(); + } + + destroy() { + this.observer?.disconnect(); + this.map.remove(); + } +} diff --git a/src/geomap/style.js b/src/geomap/style.js new file mode 100644 index 0000000..16a6df2 --- /dev/null +++ b/src/geomap/style.js @@ -0,0 +1,39 @@ +import { element } from './dom.js'; + +const routeColors = ['#1769aa', '#a33a14', '#7041a0', '#00796b']; +const routePatterns = [null, '12 7', '3 7', '14 5 3 5']; +const categoryIcons = new Map(Object.entries({ + settlement: 'βŒ‚', accommodation: 'β–£', transport: '↔', water: 'β‰ˆ', pass: 'β‹ˆ', + viewpoint: 'β—‰', historic: 'β™œ', 'trail-access': 'β†—', summit: 'β–²', food: '♨' +})); + +export function layerStyle(role, routeIndex) { + if (role === 'route') return { color: routeColors[routeIndex % routeColors.length], weight: 4, dashArray: routePatterns[routeIndex % routePatterns.length], fillOpacity: 0.12 }; + if (role === 'boundary') return { color: '#79532b', weight: 2, dashArray: '6 5', fillOpacity: 0.14 }; + return { color: '#28614b', weight: 3, dashArray: null, fillOpacity: 0.18 }; +} + +export function pointMarker(L, latlng, feature) { + const icon = element('span', 'geomap-marker-symbol', categoryIcons.get(feature.category.toLowerCase()) || '●'); + icon.setAttribute('aria-hidden', 'true'); + return L.marker(latlng, { + icon: L.divIcon({ className: 'geomap-marker', html: icon, iconSize: [30, 30], iconAnchor: [15, 15] }), + title: feature.name, + alt: feature.name, + // Keyboard focus should not unexpectedly move the document or the map. + autoPanOnFocus: false, + bubblingMouseEvents: false + }); +} + +export function emphasize(group, style, level) { + const apply = layer => { + if (layer.eachLayer) { layer.eachLayer(apply); return; } + if (layer.setStyle) layer.setStyle({ ...style, weight: style.weight + (level === 2 ? 3 : level), fillOpacity: style.fillOpacity + level * 0.08 }); + const icon = layer.getElement?.(); + icon?.classList.toggle('is-selected', level === 2); + icon?.classList.toggle('is-hovered', level === 1); + if (layer.setZIndexOffset) layer.setZIndexOffset(level * 500); + }; + apply(group); +} diff --git a/src/geomap/table.js b/src/geomap/table.js new file mode 100644 index 0000000..f7d091b --- /dev/null +++ b/src/geomap/table.js @@ -0,0 +1,133 @@ +import { element, externalLink } from './dom.js'; + +function locationLink(feature) { + const [longitude, latitude] = feature.point; + const link = externalLink(`https://www.google.com/maps?q=${latitude},${longitude}`, 'πŸ“'); + link.setAttribute('aria-label', `Open ${feature.name} in Google Maps`); + link.title = 'Open in Google Maps'; + return link; +} + +export class FeatureTable { + constructor(root, runtime, id, { onSelect, onHover } = {}) { + this.rows = new Map(); + this.details = element('details', 'geomap-data'); + this.details.open = runtime.features.length <= 8; + this.details.append(element('summary', '', `Map locations and features (${runtime.features.length})`)); + const wrapper = element('div', 'geomap-table-wrapper'); + wrapper.tabIndex = 0; + wrapper.setAttribute('role', 'region'); + wrapper.setAttribute('aria-label', 'Map locations and features table'); + const table = element('table', 'geomap-table'); + table.id = `${id}-table`; + const caption = element('caption', 'geomap-sr-only', 'Geographic features. Select a name to focus it on the map.'); + table.append(caption); + const columns = [ + { key: 'name', label: 'Name' }, + { key: 'category', label: 'Category / Type' }, + { key: 'layerName', label: 'Layer' }, + { key: 'routeSection', label: 'Route section' }, + { key: 'note', label: 'Details' }, + { key: 'point', label: 'πŸ“' }, + { key: 'sourceUrl', label: 'Source' } + ].filter(column => ['name', 'category', 'layerName'].includes(column.key) || runtime.features.some(f => f[column.key])); + const head = element('thead'), heading = element('tr'); + for (const column of columns) { + const th = element('th', column.key === 'point' ? 'map-link-header' : '', column.label); + th.scope = 'col'; + if (column.key === 'point') th.setAttribute('aria-label', 'Google Maps location'); + heading.append(th); + } + head.append(heading); + table.append(head); + const body = element('tbody'); + for (const feature of runtime.features) { + const row = element('tr'); + row.dataset.featureId = `${id}/${feature.id}`; + for (const column of columns) { + const cell = element('td', column.key === 'point' ? 'map-link-cell' : ''); + if (column.key === 'name') { + const button = element('button', 'geomap-feature-button', feature.name); + button.type = 'button'; + button.setAttribute('aria-label', `Show ${feature.name} on map`); + button.addEventListener('click', event => { event.stopPropagation(); onSelect?.(feature.id); }); + cell.append(button); + } else if (column.key === 'point') { + if (feature.point) cell.append(locationLink(feature)); + } else if (column.key === 'sourceUrl') { + if (feature.sourceUrl) cell.append(externalLink(feature.sourceUrl, 'Source β†—')); + } else { + cell.textContent = feature[column.key] || 'β€”'; + if (column.key === 'layerName') { + const state = element('span', 'geomap-layer-state', ' (hidden on map)'); + state.hidden = true; + cell.append(state); + } + } + row.append(cell); + } + row.addEventListener('click', event => { + if (!event.target.closest('a, button') && !window.getSelection()?.toString()) onSelect?.(feature.id); + }); + row.addEventListener('mouseenter', () => onHover?.(feature.id)); + row.addEventListener('mouseleave', () => onHover?.(null)); + row.addEventListener('focusin', () => onHover?.(feature.id)); + row.addEventListener('focusout', () => onHover?.(null)); + this.rows.set(feature.id, row); + body.append(row); + } + table.append(body); + wrapper.append(table); + if (runtime.features.length > 8) this.addSearch(runtime.features, id); + this.details.append(wrapper); + if (!runtime.features.length) this.details.append(element('p', 'geomap-message', 'No geographic features.')); + root.append(this.details); + } + + addSearch(features, id) { + const controls = element('div', 'geomap-table-controls'); + const label = element('label', '', 'Search locations'); + const input = element('input'); + input.id = `${id}-search`; + input.type = 'search'; + input.placeholder = 'Search locations…'; + label.htmlFor = input.id; + const status = element('span', 'geomap-filter-status', `${features.length} of ${features.length} features`); + status.setAttribute('role', 'status'); + const searchText = new Map(features.map(f => [f.id, [f.name, f.category, f.layerName, f.routeSection, f.note].join(' ').toLocaleLowerCase()])); + input.addEventListener('input', () => { + const query = input.value.trim().toLocaleLowerCase(); + let count = 0; + for (const [id, row] of this.rows) { + row.hidden = !searchText.get(id).includes(query); + if (!row.hidden) count += 1; + } + status.textContent = `${count} of ${features.length} features`; + }); + controls.append(label, input, status); + this.details.append(controls); + } + + select(id) { + this.rows.get(this.selectedId)?.classList.remove('is-selected'); + this.rows.get(this.selectedId)?.querySelector('button').removeAttribute('aria-current'); + this.rows.get(id)?.classList.add('is-selected'); + this.rows.get(id)?.querySelector('button').setAttribute('aria-current', 'true'); + this.selectedId = id; + // Map selection never changes disclosure, filtering or document scroll position. + } + + hover(id) { + this.rows.get(this.hoveredId)?.classList.remove('is-hovered'); + this.rows.get(id)?.classList.add('is-hovered'); + this.hoveredId = id; + } + + setLayerVisible(layer, visible) { + for (const feature of layer.features) { + const row = this.rows.get(feature.id); + row.classList.toggle('is-layer-hidden', !visible); + row.querySelector('.geomap-layer-state').hidden = visible; + } + } +} diff --git a/test/geomap-data.test.js b/test/geomap-data.test.js new file mode 100644 index 0000000..f9fc285 --- /dev/null +++ b/test/geomap-data.test.js @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { drawableGeometry, normalizeGeoMap, safeUrl } from '../src/geomap/data.js'; + +const point = { type: 'Point', coordinates: [2.623, 39.7108] }; +const line = { type: 'LineString', coordinates: [[2, 39], [3, 40]] }; +const polygon = { type: 'Polygon', coordinates: [[[2, 39], [3, 39], [3, 40], [2, 39]]] }; +const feature = (geometry, properties = {}) => ({ type: 'Feature', properties, geometry }); +const layer = (geojson, extra = {}) => ({ id: 'places', name: 'Places', role: 'poi', geojson, ...extra }); +const normalize = (...layers) => normalizeGeoMap({ layers }); + +test('normalization supports every GeoJSON geometry and preserves feature granularity', () => { + const geometries = [point, line, polygon, + { type: 'MultiPoint', coordinates: [point.coordinates, [3, 40]] }, + { type: 'MultiLineString', coordinates: [line.coordinates] }, + { type: 'MultiPolygon', coordinates: [polygon.coordinates] }, + { type: 'GeometryCollection', geometries: [point, line, polygon] } + ]; + const input = { layers: geometries.map((geometry, i) => layer(geometry, { id: String(i) })) }; + const before = JSON.stringify(input); + const runtime = normalizeGeoMap(input); + assert.equal(runtime.layers.length, 7); + assert.equal(runtime.features.length, 7); + assert.equal(runtime.features.filter(f => f.point).length, 1); + assert.deepEqual(runtime.features[0].point, [2.623, 39.7108]); + assert.equal(JSON.stringify(input), before); +}); + +test('large authoritative tracks produce one feature and retain every coordinate', () => { + const coordinates = Array.from({ length: 12000 }, (_, i) => [2 + i / 12000, 39 + i / 12000]); + const runtime = normalize(layer(feature({ type: 'LineString', coordinates }), { role: 'route' })); + assert.equal(runtime.features.length, 1); + assert.equal(runtime.features[0].feature.geometry.coordinates, coordinates); + assert.equal(runtime.features[0].point, null); +}); + +test('property aliases and safe feature provenance precede layer fallback', () => { + const runtime = normalize(layer({ type: 'FeatureCollection', features: [ + feature(point, { name: 'Same name', category: '', type: 'custom category', route_section: 'Core', desc: 'A village', source: { url: 'https://feature.example/path' }, technical: { large: [] } }), + feature(point, { name: 'Same name', routeSection: 'Detour', note: null, description: 'A refuge', source: { url: 'javascript:alert(1)' } }), + feature(null, null) + ] }, { source: { url: 'https://layer.example/route.gpx', originalFormat: 'gpx' } })); + const [first, second, anonymous] = runtime.features; + assert.equal(first.sourceUrl, 'https://feature.example/path'); + assert.equal(second.sourceUrl, 'https://layer.example/route.gpx'); + assert.equal(first.category, 'custom category'); + assert.equal(first.routeSection, 'Core'); + assert.equal(first.note, 'A village'); + assert.equal(second.note, 'A refuge'); + assert.notEqual(first.id, second.id); + assert.equal(anonymous.name, 'Places Β· 3'); + assert.equal(anonymous.point, null); + assert.equal(first.technical, undefined); +}); + +test('malformed roots fail locally and invalid layers do not discard valid siblings', () => { + for (const input of [null, [], 1, {}, { layers: {} }]) assert.throws(() => normalizeGeoMap(input), /layers array/); + const invalid = [ + null, layer(point, { id: '' }), layer(point, { name: null }), layer(point, { role: 3 }), + layer(point, { source: { retrievedAt: 42 } }), layer({ type: 'FeatureCollection', features: {} }), + layer(feature(point, [])), layer({ type: 'Unknown', coordinates: [] }), + layer({ type: 'Point', coordinates: [2, 91] }), layer({ type: 'Point', coordinates: [181, 39] }), + layer({ type: 'Point', coordinates: ['2', 39] }), layer({ type: 'Point', coordinates: [2, NaN] }), + layer({ type: 'LineString', coordinates: [[2, 39]] }), + layer({ type: 'Polygon', coordinates: [[[2, 39], [3, 39], [3, 40], [4, 40]]] }), + layer({ type: 'GeometryCollection', geometries: [null] }) + ]; + for (const bad of invalid) { + const runtime = normalize(bad, layer(point, { id: 'valid' })); + assert.equal(runtime.layers.length, 1); + assert.equal(runtime.layers[0].id, 'valid'); + assert.equal(runtime.errors.length, 1); + } + assert.throws(() => normalize(layer({ type: 'NoSuchGeometry' })), /Unsupported geometry/); +}); + +test('duplicate IDs are rejected while duplicate names and special object keys stay safe', () => { + const runtime = normalize(layer(point, { id: '__proto__' }), layer(line, { id: '__proto__' }), layer(line, { id: 'constructor' })); + assert.equal(runtime.layers.length, 2); + assert.equal(runtime.errors.length, 1); + assert.equal(new Set(runtime.features.map(f => f.id)).size, 2); +}); + +test('empty and unlocated GeoJSON remain valid and do not reach Leaflet as invalid points', () => { + assert.equal(normalize().features.length, 0); + assert.equal(normalize(layer({ type: 'FeatureCollection', features: [] })).features.length, 0); + const empty = { type: 'Point', coordinates: [] }; + const collection = { type: 'GeometryCollection', geometries: [empty, point, { type: 'GeometryCollection', geometries: [] }] }; + assert.equal(normalize(layer(feature(null))).features.length, 1); + assert.equal(normalize(layer(empty)).features[0].point, null); + assert.equal(drawableGeometry(empty), null); + assert.deepEqual(drawableGeometry(collection).geometries, [point]); + assert.equal(collection.geometries.length, 3); +}); + +test('source URLs permit only absolute HTTP and HTTPS URLs', () => { + for (const value of ['javascript:alert(1)', 'data:text/html,hi', 'file:///tmp/a', '/relative', '//example.com', {}, null]) assert.equal(safeUrl(value), ''); + assert.equal(safeUrl('https://example.com/a'), 'https://example.com/a'); + assert.equal(safeUrl('http://example.com/a'), 'http://example.com/a'); +}); diff --git a/test/geomap-map.test.js b/test/geomap-map.test.js new file mode 100644 index 0000000..2e86e38 --- /dev/null +++ b/test/geomap-map.test.js @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { after, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import { normalizeGeoMap } from '../src/geomap/data.js'; +import { FeatureMap } from '../src/geomap/map.js'; +import { FeatureTable } from '../src/geomap/table.js'; + +const require = createRequire(import.meta.url); +const dom = new JSDOM('', { runScripts: 'outside-only', pretendToBeVisual: true }); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +dom.window.eval(await readFile(require.resolve('leaflet/dist/leaflet.js'), 'utf8')); +const L = dom.window.L; +L.Browser.svg = true; +Object.defineProperties(dom.window.HTMLElement.prototype, { + clientWidth: { get: () => 640 }, clientHeight: { get: () => 420 } +}); +let scrolls = 0; +dom.window.HTMLElement.prototype.scrollIntoView = () => scrolls++; +after(() => dom.window.close()); + +function runtime() { + const feature = (geometry, name) => ({ type: 'Feature', properties: { name }, geometry }); + const layer = (id, role, features) => ({ id, name: id, role, geojson: { type: 'FeatureCollection', features } }); + const ring = [[10, 40], [11, 40], [11, 41], [10, 40]]; + return normalizeGeoMap({ layers: [ + layer('places', 'poi', [feature({ type: 'Point', coordinates: [2.623, 39.7108] }, 'Same name')]), + layer('route', 'route', [feature({ type: 'LineString', coordinates: [[2, 39], [8, 42]] }, 'Same name')]), + layer('alternative', 'route', [feature({ type: 'MultiLineString', coordinates: [[[3, 39], [5, 40]]] }, 'Detour')]), + layer('area', 'boundary', [feature({ type: 'Polygon', coordinates: [ring] }, 'Area'), feature({ type: 'MultiPolygon', coordinates: [[ring]] }, 'Areas')]), + layer('collection', 'poi', [feature({ type: 'GeometryCollection', geometries: [ + { type: 'MultiPoint', coordinates: [[4, 40], [5, 41]] }, { type: 'Point', coordinates: [] } + ] }, 'Collection')]) + ] }); +} + +function render(data = runtime()) { + const root = document.createElement('div'), container = document.createElement('div'), toolbar = document.createElement('div'); + root.append(toolbar, container); + document.body.append(root); + let map; + const table = new FeatureTable(root, data, `map-${document.body.children.length}`, { + onSelect: id => map.focus(id), onHover: id => map.hover(id) + }); + map = new FeatureMap(L, container, toolbar, data, { + onSelect: id => table.select(id), onHover: id => table.hover(id), onVisibility: (layer, visible) => table.setLayerVisible(layer, visible) + }); + map.reducedMotion = true; + return { map, table, root, toolbar, container }; +} + +test('real Leaflet renders all geometry types, fits routes and boundaries, and distinguishes routes', () => { + const { map } = render(); + try { + assert.equal(map.features.size, 6); + assert.equal(map.layers.size, 5); + assert.ok(map.map.getBounds().contains([42, 8])); + assert.ok(map.map.getBounds().contains([41, 11])); + assert.ok(map.map.getBounds().contains([39.7108, 2.623])); + const main = map.features.get('route/0'), alternative = map.features.get('alternative/0'); + assert.notEqual(main.style.color, alternative.style.color); + assert.notEqual(main.style.dashArray, alternative.style.dashArray); + assert.equal(map.map.options.scrollWheelZoom, false); + } finally { map.destroy(); } +}); + +test('map clicks and hover remain local while table selection reveals, focuses and selects', () => { + const { map, table, toolbar } = render(); + try { + table.details.open = false; + const pointGroup = map.features.get('places/0').geometry; + const point = pointGroup.getLayers()[0]; + const before = scrolls; + point.fire('click', { latlng: point.getLatLng() }, true); + assert.equal(table.details.open, false); + assert.equal(scrolls, before); + assert.equal(table.selectedId, 'places/0'); + assert.equal(pointGroup.isPopupOpen(), true); + assert.equal(pointGroup.getPopup().options.autoPan, false); + const center = map.map.getCenter(); + table.rows.get('route/0').dispatchEvent(new dom.window.MouseEvent('mouseenter')); + assert.equal(map.map.getCenter().equals(center), true); + assert.equal(table.details.open, false); + assert.equal(scrolls, before); + const toggle = toolbar.querySelector('input'); + toggle.checked = false; + toggle.dispatchEvent(new dom.window.Event('change')); + assert.equal(map.map.hasLayer(map.layers.get('places').group), false); + assert.equal(table.rows.get('places/0').classList.contains('is-layer-hidden'), true); + table.rows.get('places/0').querySelector('button').click(); + assert.equal(map.map.hasLayer(map.layers.get('places').group), true); + assert.equal(toggle.checked, true); + assert.equal(table.rows.get('places/0').classList.contains('is-layer-hidden'), false); + assert.equal(scrolls, before + 1); + assert.ok(map.map.getZoom() >= 15); + assert.ok(map.map.getCenter().equals([39.7108, 2.623])); + assert.equal(pointGroup.isPopupOpen(), true); + toggle.checked = false; + toggle.dispatchEvent(new dom.window.Event('change')); + toggle.checked = true; + toggle.dispatchEvent(new dom.window.Event('change')); + assert.equal(point.getElement().classList.contains('is-selected'), true); + } finally { map.destroy(); } +}); + +test('selection restores normal styles, fits lines and isolates multiple map instances', () => { + const first = render(), second = render(); + try { + const route = first.map.features.get('route/0'), path = route.geometry.getLayers()[0]; + first.map.focus('route/0'); + assert.equal(path.options.weight, route.style.weight + 3); + assert.ok(first.map.map.getBounds().contains([42, 8])); + first.map.focus('area/0'); + assert.equal(path.options.weight, route.style.weight); + assert.equal(path.options.dashArray, route.style.dashArray); + assert.equal(second.table.selectedId, undefined); + assert.equal(second.map.selectedId, undefined); + const secondZoom = second.map.map.getZoom(); + first.map.focus('places/0'); + assert.equal(second.map.map.getZoom(), secondZoom); + } finally { first.map.destroy(); second.map.destroy(); } +}); + +test('a single point has a sensible initial zoom and empty maps have a usable default', () => { + const data = runtime(); + data.layers = data.layers.slice(0, 1); + data.features = data.layers[0].features; + const single = render(data), empty = render(normalizeGeoMap({ layers: [] })); + try { + assert.equal(single.map.map.getZoom(), 15); + assert.equal(empty.map.map.getZoom(), 2); + assert.equal(empty.table.rows.size, 0); + } finally { single.map.destroy(); empty.map.destroy(); } +}); diff --git a/test/geomap-renderer.test.js b/test/geomap-renderer.test.js new file mode 100644 index 0000000..b8fff25 --- /dev/null +++ b/test/geomap-renderer.test.js @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { after, mock, test } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; +import { JSDOM } from 'jsdom'; +import MarkdownIt from 'markdown-it'; +import { embedDocument } from '../lib/pageleaf.js'; +import { install } from '../src/geomap/index.js'; + +const dom = new JSDOM(''); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +after(() => dom.window.close()); +mock.method(console, 'error', () => {}); +after(() => mock.restoreAll()); +const data = { layers: [{ id: 'poi', name: 'Places', role: 'poi', geojson: { type: 'Point', coordinates: [2.623, 39.7108] } }] }; +const fence = value => '```geomap\n' + JSON.stringify(value) + '\n```\n'; + +function renderer(loadMapLibrary = () => Promise.reject(new Error('CDN offline'))) { + const md = new MarkdownIt({ html: false }); + const extension = install(md, { loadMapLibrary }); + const root = document.createElement('article'); + document.body.append(root); + return { md, extension, root }; +} + +test('fences fail independently, preserve ordinary code, and use unique component IDs', () => { + const { md, extension, root } = renderer(); + root.innerHTML = md.render('Before\n\n' + fence(data) + '\n```geomap\n{bad json\n```\n\n' + fence([]) + '\n```json\n{}\n```\n\n' + fence(data) + '\nAfter'); + extension.enhance(root, { interactive: false }); + assert.equal(root.querySelectorAll('.geomap').length, 2); + assert.equal(root.querySelectorAll('.geomap-error').length, 2); + assert.equal(root.querySelector('pre code').className, 'language-json'); + assert.ok(root.textContent.includes('Before')); + assert.ok(root.textContent.includes('After')); + const ids = [...root.querySelectorAll('[id]')].map(node => node.id); + assert.equal(new Set(ids).size, ids.length); + assert.equal(root.querySelectorAll('tbody tr').length, 2); +}); + +test('tables and links survive CDN failure and map initialization failure', async () => { + for (const loader of [() => Promise.reject(new Error('CDN offline')), () => ({ map() { throw new Error('Map unavailable'); } })]) { + const { md, extension, root } = renderer(loader); + root.innerHTML = md.render(fence(data)); + extension.enhance(root); + assert.equal(root.querySelectorAll('tbody tr').length, 1); + assert.ok(root.querySelector('.map-link-cell a')); + await setImmediate(); + assert.match(root.querySelector('.geomap-map').textContent, /Map unavailable/); + root.querySelector('tbody button').click(); + assert.equal(root.querySelectorAll('.is-selected').length, 1); + } +}); + +test('navigation disposes maps waiting on a download and print reuses normalized data', async () => { + let resolve, initialized = 0; + const promise = new Promise(done => { resolve = done; }); + const { md, extension, root } = renderer(() => promise); + const tokens = md.parse(fence(data), {}); + root.innerHTML = md.renderer.render(tokens, md.options, {}); + extension.enhance(root); + extension.dispose(root); + root.replaceChildren(); + resolve({ map() { initialized++; throw new Error('Must not initialize a disposed map'); } }); + await setImmediate(); + assert.equal(initialized, 0); + // Rendering for print must not interpret the same JSON a second time. + tokens[0].content = 'invalid after initial parse'; + root.innerHTML = md.renderer.render(tokens, md.options, {}); + extension.enhance(root, { interactive: false }); + assert.equal(root.querySelectorAll('tbody tr').length, 1); + assert.equal(root.querySelector('details').open, true); +}); + +test('the packaged single HTML runs GeoMaps without adjacent scripts and protects script boundaries', async () => { + const template = await readFile(new URL('../dist/template.html', import.meta.url), 'utf8'); + assert.doesNotMatch(template, /(?:src|href)="(?:src\/geomap|dist\/geomap)/); + const input = structuredClone(data); + input.layers[0].geojson = { type: 'Feature', geometry: data.layers[0].geojson, properties: { name: '' } }; + const html = embedDocument(template, '# Map\n\n' + fence(input)); + const page = new JSDOM(html, { runScripts: 'outside-only', pretendToBeVisual: true, url: 'https://pageleaf.example/' }); + try { + page.window.markdownit = MarkdownIt; + // External assets stay blocked, but the real bundled app still renders its table. + for (const script of page.window.document.querySelectorAll('script:not([src]):not([type="text/plain"])')) page.window.eval(script.textContent); + assert.equal(page.window.document.getElementById('status').hidden, true); + assert.equal(page.window.document.querySelectorAll('#article tbody tr').length, 1); + assert.equal(page.window.pwned, undefined); + assert.equal(page.window.document.querySelector('#article button').textContent, input.layers[0].geojson.properties.name); + page.window.dispatchEvent(new page.window.Event('beforeprint')); + assert.equal(page.window.document.querySelectorAll('#print-content tbody tr').length, 1); + assert.equal(page.window.document.querySelector('#print-content details').open, true); + await setImmediate(); + } finally { page.window.close(); } +}); diff --git a/test/geomap-table.test.js b/test/geomap-table.test.js new file mode 100644 index 0000000..34b27da --- /dev/null +++ b/test/geomap-table.test.js @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { JSDOM } from 'jsdom'; +import { normalizeGeoMap } from '../src/geomap/data.js'; +import { popupContent } from '../src/geomap/dom.js'; +import { FeatureTable } from '../src/geomap/table.js'; + +const dom = new JSDOM(''); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +after(() => dom.window.close()); + +function data(count = 2) { + return normalizeGeoMap({ layers: [{ + id: 'places', name: 'Points of interest', role: 'poi', + source: { url: 'https://example.com/source' }, + geojson: { type: 'FeatureCollection', features: Array.from({ length: count }, (_, i) => ({ + type: 'Feature', properties: { name: `Place ${i}`, category: i === 1 ? 'water' : 'settlement', route_section: 'Core', note: i === 1 ? 'Spring' : '' }, + geometry: i === 1 ? { type: 'LineString', coordinates: [[2, 39], [3, 40]] } : { type: 'Point', coordinates: [2.623, 39.7108] } + })) } + }] }); +} + +function render(runtime = data(), handlers = {}) { + const root = document.createElement('div'); + document.body.append(root); + const table = new FeatureTable(root, runtime, 'geomap-test', handlers); + return { root, table }; +} + +test('Google Maps is an icon-only point column with longitude and latitude reversed', () => { + const selected = []; + const { root } = render(data(), { onSelect: id => selected.push(id) }); + const cells = root.querySelectorAll('.map-link-cell'); + const link = cells[0].querySelector('a'); + assert.equal(cells[0].textContent, 'πŸ“'); + assert.equal(cells[1].textContent, ''); + assert.equal(link.href, 'https://www.google.com/maps?q=39.7108,2.623'); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal(link.getAttribute('aria-label'), 'Open Place 0 in Google Maps'); + // Cancel browser navigation after exercising the real bubbling path. + link.addEventListener('click', event => event.preventDefault()); + link.click(); + const source = root.querySelector('td:last-child a'); + source.addEventListener('click', event => event.preventDefault()); + source.click(); + assert.deepEqual(selected, []); + root.querySelector('tbody td').click(); + root.querySelector('tbody button').click(); + assert.deepEqual(selected, ['places/0', 'places/0']); +}); + +test('selection never expands the table or scrolls and hidden layers keep their rows', () => { + const runtime = data(9); + const { root, table } = render(runtime); + let scrolls = 0; + dom.window.HTMLElement.prototype.scrollIntoView = () => scrolls++; + assert.equal(table.details.open, false); + table.select('places/0'); + table.select('places/1'); + assert.equal(table.details.open, false); + assert.equal(scrolls, 0); + assert.equal(root.querySelectorAll('.is-selected').length, 1); + assert.equal(table.rows.get('places/1').querySelector('button').getAttribute('aria-current'), 'true'); + table.setLayerVisible(runtime.layers[0], false); + assert.equal(root.querySelectorAll('tbody tr').length, 9); + assert.equal(root.querySelector('tbody tr').hidden, false); + assert.equal(root.querySelector('.geomap-layer-state').hidden, false); + table.setLayerVisible(runtime.layers[0], true); + assert.equal(root.querySelector('.geomap-layer-state').hidden, true); +}); + +test('filtering searches human properties without changing layer state or selection', () => { + const { root, table } = render(data(10)); + const input = root.querySelector('input'); + for (const [query, expected] of [['SPRING', 1], ['water', 1], ['core', 10], ['points of interest', 10], ['missing', 0], ['', 10]]) { + input.value = query; + input.dispatchEvent(new dom.window.Event('input')); + assert.equal([...root.querySelectorAll('tbody tr')].filter(row => !row.hidden).length, expected); + } + input.value = 'missing'; + input.dispatchEvent(new dom.window.Event('input')); + table.select('places/0'); + assert.equal(table.rows.get('places/0').hidden, true); + assert.equal(input.value, 'missing'); + assert.equal(root.querySelector('.geomap-filter-status').textContent, '0 of 10 features'); +}); + +test('table and popup properties are text and never execute or expose arbitrary metadata', () => { + const runtime = data(1), feature = runtime.features[0]; + feature.name = ''; + feature.note = ''; + feature.category = ''; + const { root } = render(runtime); + const popup = popupContent(feature); + assert.equal(root.querySelector('img, script, svg'), null); + assert.equal(popup.querySelector('img, script, svg'), null); + assert.ok(root.textContent.includes(feature.name)); + assert.ok(popup.textContent.includes(feature.note)); + assert.equal(popup.querySelector('[href*="google.com"]'), null); +}); + +test('small tables open by default and absent optional columns stay absent', () => { + const runtime = data(1); + Object.assign(runtime.features[0], { note: '', routeSection: '', sourceUrl: '', point: null }); + const { root, table } = render(runtime); + assert.equal(table.details.open, true); + assert.deepEqual([...root.querySelectorAll('th')].map(th => th.textContent), ['Name', 'Category / Type', 'Layer']); + assert.equal(root.querySelector('input'), null); + assert.equal(root.querySelector('summary').textContent, 'Map locations and features (1)'); +});