Migrate to vector maps

This commit is contained in:
Jonathon Leight
2026-08-27 20:02:15 -04:00
parent 3fbaf7f5ab
commit ef70b6089e
49 changed files with 1866 additions and 1588 deletions
+2 -1
View File
@@ -225,7 +225,8 @@ Security is a first-class concern; we run a strict CSP and gate CI on vuln scans
a `<script nonce="{{.Nonce}}">` block or a self-hosted `.js`. `TestTemplatesHaveNoInlineJS`
enforces this — don't work around it.
- **Self-host all assets.** No external scripts/styles/fonts (CSP `*-src 'self'`);
the only external resource is CARTO map tiles (`img-src`). No SRI needed.
the only external resource is the CARTO vector basemap (`connect-src` — style JSON,
`.mvt` tiles, sprite and glyphs are all fetched, not image loads). No SRI needed.
- **html/template autoescapes** — don't defeat it. Reach for `template.HTML`/`JS`/`URL`
only with server-controlled, non-user data, and comment why.
- Cookies stay `HttpOnly` + `SameSite=Lax` + `Secure` (gated on TLS). State-changing
+4 -4
View File
@@ -67,10 +67,10 @@ MESHTENDER_MAIL_FROM=MeshTender <noreply@leighthaus.dev>
# MESHTENDER_RESEND_API_KEY=
# MESHTENDER_MAIL_REPLY_TO=
# CARTO's API key for the basemap tiles, sent as ?key= on each tile request.
# Without it CARTO watermark the tiles they serve. The browser fetches tiles
# directly, so this key is served in the page. Unset => the key parameter is
# omitted from the tile URL.
# CARTO's API key for the basemap, appended as ?key= to every request a map style
# makes — the style JSON, the vector tiles, the sprite and the glyphs. Without it
# CARTO watermark what they serve. The browser fetches directly, so this key is
# served in the page. Unset => the key parameter is omitted entirely.
# MESHTENDER_CARTO_KEY=
# The OCI digest of the image this server runs as — reported by GET /version and
+13 -5
View File
@@ -126,7 +126,7 @@ links, markdown content, a repeater map, and optionally a verified custom domain
per-region and root flood policy (`region allowf` / `region denyf`). A repeater's coordinates select
every region whose polygon contains it. The console's "apply organization configuration" flow shows
the resulting command list — profile steps plus region commands — marking any line the user can't
run and why. Regions are drawn on a Leaflet/Geoman map editor.
run and why. Regions are drawn on a MapLibre/Terra Draw map editor.
**Command catalog.** Every repeater CLI command, seeded from the firmware and modeled per-parameter
(`set.tx`, `set.radio`, …) with feature/operation grouping, a `risky` tag, and per-tier org flags.
@@ -151,7 +151,7 @@ build step — migrations, templates, and static assets are all `go:embed`ed.
- Go 1.27.0 (pinned exactly — see "Verifying a build"), [meshcore-go](https://github.com/meshcore-go/meshcore-go)
for the MeshCore protocol/crypto
- Postgres via `pgx` with raw SQL (no ORM) and goose migrations; `chi` router; `coder/websocket`
- Hand-written JS only where the platform demands it (WebSerial, Leaflet maps, small delegated
- Hand-written JS only where the platform demands it (WebSerial, MapLibre maps, small delegated
handlers in `ui.js`)
### Vendored front-end assets
@@ -160,9 +160,17 @@ The strict CSP forbids external scripts/styles (`*-src 'self'`), so every third-
library is **self-hosted** in `internal/web/static/` and embedded into the binary. They're served
content-hash fingerprinted with a one-year `immutable` Cache-Control and pre-compressed (gzip +
brotli) by the asset manifest in `internal/web/assets.go`. The only external resource anywhere is
CARTO map tiles, allowlisted in `img-src`. Those tiles take a CARTO API key
(`MESHTENDER_CARTO_KEY`), sent as `?key=` on each tile request; without it CARTO watermark the
tiles. The browser fetches tiles directly, so the key is served in the page.
the CARTO basemap, allowlisted in `connect-src` — not `img-src`, because the vector basemaps that
replaced CARTO's deprecated raster tiles are *fetched*: the style JSON, the `.mvt` tiles, the sprite
and the glyph PBFs all go out as fetch/XHR, and none of them is an image load. They take a CARTO API
key (`MESHTENDER_CARTO_KEY`), which a MapLibre `transformRequest` appends as `?key=` to every one of
those requests — the URLs inside a style carry none of their own. The browser fetches them directly,
so the key is served in the page.
Maps are MapLibre GL, not Leaflet, and for the same reason: a CARTO vector basemap *is* a MapLibre
style, and Leaflet cannot render one. We vendor MapLibre's **CSP build**, whose worker is loaded from
an ordinary same-origin URL (passed to the page as `<html data-maplibre-worker>`) instead of the
`blob:` the default bundle uses — that is what keeps `worker-src` at `'self'`.
Which libraries, at which versions, from which upstream artifact, is recorded once in
`internal/licenses/manifest.go` — with a SHA-256 per file — and rendered into
+130 -66
View File
@@ -38,56 +38,83 @@ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
```
### Leaflet 1.9.4 — BSD-2-Clause
### MapLibre GL JS 5.24.0 — BSD-3-Clause
- Homepage: <https://leafletjs.com>
- Source: https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/
- File: `internal/web/static/leaflet.js` (sha256 `db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a`)
- File: `internal/web/static/leaflet.css` (sha256 `498bd934faeb2cb455d6db2d9304d18d5aea69afe43fd2ac933c3f3753724617`)
- Modified: leaflet.css carries a hand-restored @preserve banner; upstream ships the stylesheet without one. Body is byte-identical to upstream.
- Homepage: <https://maplibre.org>
- Source: https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/
- File: `internal/web/static/maplibre-gl.js` (sha256 `039b46f7a84489bce207f55cf376e1022cf8d213190ff9a93b02554f1785248f`)
- File: `internal/web/static/maplibre-gl-worker.js` (sha256 `4f64bf26dab4a953ceb06c49a8d6efef32d5015a3f4c872a1f73e13f60ac9dba`)
- File: `internal/web/static/maplibre-gl.css` (sha256 `3f40ab71b5b3fc985eb7f6b8926e0e8fbec2792852e4f12d8247abf0aabb8a94`)
- Modified: All three carry hand-extended or hand-restored @preserve banners (upstream's script banner names the license by URL and states no copyright; the stylesheet ships none) and have their trailing sourceMappingURL comment stripped, since the .map files are not vendored. Bodies are byte-identical to upstream.
- Note: This is the CSP build: maplibre-gl.js is upstream dist/maplibre-gl-csp.js and maplibre-gl-worker.js is dist/maplibre-gl-csp-worker.js. The default bundle spawns its worker from a blob: URL, which the strict CSP forbids; the CSP build takes an explicit same-origin worker URL via maplibregl.setWorkerUrl instead (see basemap.js). Upstream's LICENSE.txt, reproduced whole in the notices, also covers the third-party code MapLibre embeds — mapbox-gl-js v1.13 and earlier (BSD-3-Clause, (c) 2020 Mapbox), glfx.js (MIT) and a portion of d3-color (BSD-3-Clause) — all permissive.
```
BSD 2-Clause License
Copyright (c) 2023, MapLibre contributors
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### Leaflet-Geoman 2.20.0 — MIT
-------------------------------------------------------------------------------
- Homepage: <https://geoman.io>
- Source: https://cdn.jsdelivr.net/npm/@geoman-io/leaflet-geoman-free@2.20.0/dist/
- File: `internal/web/static/leaflet-geoman.js` (sha256 `50bce5ec0c880d7edc912254f645aa77364fd6c29d66ef92296f855b8b615498`)
- File: `internal/web/static/leaflet-geoman.css` (sha256 `51e45cbdf47dccb437bb34c9aa96b2017957a2471e17f41a72f4ec15a3b8c3f2`)
- Modified: Both files carry hand-restored banners: the upstream esbuild bundle strips its own. Bodies are byte-identical to leaflet-geoman.min.js and leaflet-geoman.css upstream.
- Note: This is the free MIT package (@geoman-io/leaflet-geoman-free). Geoman also sells a commercially licensed product — do not upgrade into it.
Contains code from mapbox-gl-js v1.13 and earlier
```
MIT License
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2017 Sumit Kumar
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -96,47 +123,84 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
### Leaflet.markercluster 1.5.3 — MIT
### Terra Draw 1.32.3 — MIT
- Homepage: <https://github.com/Leaflet/Leaflet.markercluster>
- Source: https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/
- File: `internal/web/static/leaflet.markercluster.js` (sha256 `b687c3bd8b9239b1dbe4bc4241c2940426cf15ca8543c73e5d4e31e3346fab25`)
- File: `internal/web/static/leaflet.markercluster.css` (sha256 `882ea5266422a7ff57e5641f78a7e8464f81b575f0665634808d60ae6f5ed41d`)
- Modified: The stylesheet is upstream MarkerCluster.css + MarkerCluster.Default.css concatenated, plus a banner; the script is upstream plus a banner. Both bodies are byte-identical to upstream.
- Homepage: <https://github.com/JamesLMilner/terra-draw>
- Source: https://cdn.jsdelivr.net/npm/terra-draw@1.32.3/dist/terra-draw.umd.js
- File: `internal/web/static/terra-draw.js` (sha256 `1d6fcaf87f34ec1515e7f960d39915be83365e1c521bdc10e64e25071df7a18d`)
- Modified: Hand-restored banner (the upstream UMD bundle ships none) and a stripped trailing sourceMappingURL comment. Body is byte-identical to upstream.
- Note: Drives polygon drawing and editing on the region area editor, replacing Leaflet-Geoman. Headless by design — it renders shapes into the map and ships no toolbar UI, so the controls are ours (see regionmap.js and config_region_area.html). The npm package ships no LICENSE file; the text here is the one at the root of the monorepo that publishes it, which the adapter shares.
```
Copyright 2012 David Leaver
Copyright 2022 James Milner
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
### Terra Draw MapLibre GL adapter 1.4.1 — MIT
- Homepage: <https://github.com/JamesLMilner/terra-draw>
- Source: https://cdn.jsdelivr.net/npm/terra-draw-maplibre-gl-adapter@1.4.1/dist/terra-draw-maplibre-gl-adapter.umd.js
- File: `internal/web/static/terra-draw-maplibre-gl-adapter.js` (sha256 `ac90d8efe1d367e483e11decdffc421a7150d57686aec016b261f3f32e49bec7`)
- Modified: Hand-restored banner (the upstream UMD bundle ships none) and a stripped trailing sourceMappingURL comment. Body is byte-identical to upstream.
- Note: Published from the same monorepo as Terra Draw and under the same root MIT license, which is why the two license texts here are identical.
```
Copyright 2022 James Milner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
### Tabler 1.4.0 — MIT
@@ -247,8 +311,8 @@ part of the published container; the service is called by the browser at runtime
### CARTO basemaps
- Homepage: <https://carto.com>
- Source: https://{s}.basemaps.cartocdn.com (allowlisted in the CSP img-src), keyed with MESHTENDER_CARTO_KEY
- Note: Raster map tiles fetched by the browser at runtime; no code is redistributed, so no license applies. Attribution ("(c) OpenStreetMap (c) CARTO") is rendered by basemap.js. Requests carry an API key (MESHTENDER_CARTO_KEY, per deployment); CARTO watermark tiles served without one. CARTO have said these raster tiles are deprecated in favour of vector tiles, which Leaflet cannot render. Terms of use are CARTO's and are not verified by any test.
- Source: https://*.basemaps.cartocdn.com (allowlisted in the CSP connect-src), keyed with MESHTENDER_CARTO_KEY
- Note: Vector basemaps (MapLibre GL styles) rendered in the browser at runtime; no code is redistributed, so no license applies. A style pulls four kinds of resource from CARTO — the style JSON, .mvt tiles, a sprite sheet and glyph PBFs — all fetched rather than loaded as images, which is why the CSP allowlists the host in connect-src rather than img-src. Attribution ("(c) OpenStreetMap (c) CARTO") is rendered by basemap.js, and CARTO require it stay visible. Every request carries an API key (MESHTENDER_CARTO_KEY, per deployment) appended by a MapLibre transformRequest, because the URLs inside a style do not carry one. The raster tiles this replaced are deprecated upstream. Terms of use are CARTO's and are not verified by any test.
<!-- END GENERATED: assets -->
+6 -4
View File
@@ -75,11 +75,13 @@ type Config struct {
MailReplyTo string
MailEnabled bool
// CartoKey is CARTO's API key for the basemap tiles, sent as ?key= on each tile
// request. Without it CARTO watermark the tiles they serve. The browser fetches
// tiles directly, so the key is served in the page.
// CartoKey is CARTO's API key for the basemap, appended as ?key= to every request
// a map style makes — the style JSON, the vector tiles, the sprite and the glyph
// PBFs, since the URLs inside a style carry no key of their own. Without it CARTO
// watermark what they serve. The browser fetches directly, so the key is served in
// the page (see basemap.js).
//
// Empty is a valid state: the tile URL omits the key parameter.
// Empty is a valid state: the key parameter is omitted entirely.
CartoKey string
// ImageDigest is the OCI digest of the image this server runs as, reported by
+2 -3
View File
@@ -279,9 +279,8 @@
}
document.getElementById("region").addEventListener("change", applyRegion);
</script>
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-styles"}}
{{template "map-scripts"}}
<script src="{{ asset "/static/regionmap.js" }}"></script>
<script src="{{ asset "/static/serial-port.js" }}"></script>
<script src="{{ asset "/static/serial-setup.js" }}"></script>
@@ -30,8 +30,7 @@
</div>
{{end}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<link rel="stylesheet" href="{{ asset "/static/leaflet-geoman.css" }}">
{{template "map-styles"}}
<form method="post" action="/orgs/{{.Org.Slug}}/config/regions/{{.Region.ID}}/area" id="region-area-form">
<div class="row g-3">
@@ -55,9 +54,10 @@
<span class="fw-bold" id="region-area-status"></span>
</div>
<p class="text-secondary small">
Draw with the tools at the top-left of the map. A region needs an area
before it applies to anything — until then it's a draft and no repeater
picks it up.
Draw with the tools at the top-right of the map: click each corner, then
close the outline by clicking the first corner again or pressing Enter.
A region needs an area before it applies to anything — until then it's a
draft and no repeater picks it up.
</p>
{{if .Siblings}}
<p class="text-secondary small mb-0">
@@ -79,9 +79,8 @@
<input type="hidden" name="region_geojson" id="region_geojson" value="{{.Region.GeofenceJSON}}">
</form>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/leaflet-geoman.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
{{template "map-draw-scripts"}}
<script src="{{ asset "/static/regionmap.js" }}"></script>
{{/* Values are interpolated in a JS context, so html/template JSON-encodes and
escapes them — the region names and shapes are user data. */}}
+2 -3
View File
@@ -148,9 +148,8 @@ swap needs no new markup. The flex row is nested so the [hidden] attribute wins
</div>
{{if .ShowConfig}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-styles"}}
{{template "map-scripts"}}
<script src="{{ asset "/static/regionmap.js" }}"></script>
<script src="{{ asset "/static/console-config.js" }}"></script>
{{end}}
+2 -3
View File
@@ -126,10 +126,9 @@
<div class="card-header"><h2 class="card-title">Repeater map</h2></div>
{{if .Mapped}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
{{template "map-styles"}}
<div id="map" style="height:260px" role="region" aria-label="Map of your repeater locations"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
meshMap('map', [
+2 -3
View File
@@ -171,10 +171,9 @@
<div class="card">
<div class="card-header"><h2 class="card-title">Repeaters</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
{{template "map-styles"}}
<div id="map" style="height:260px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
meshMap('map', [
+2 -3
View File
@@ -138,10 +138,9 @@
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">Location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
{{template "map-styles"}}
<div id="map" style="height:240px" role="region" aria-label="Map of this repeater's location"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
meshMap('map', [{name: {{.Repeater.Name}}, lat: {{.Repeater.Latitude}}, lon: {{.Repeater.Longitude}}}]);
+79
View File
@@ -0,0 +1,79 @@
//go:build browser
package e2e
import (
"testing"
"time"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// TestE2EBasemapToggleKeepsOverlays covers the sharpest edge in the MapLibre
// migration.
//
// Switching basemap is map.setStyle(), and setStyle discards every source and layer
// the page added along with the old style — so the repeaters, the region outlines,
// everything we drew, silently vanish the first time someone presses Light. Nothing
// errors; the map just goes empty. basemap.js exists partly to prevent that: it owns
// the swap and re-runs the page's `overlays` callback afterwards.
//
// The test presses Light for real and asserts the points came back, then checks the
// choice was remembered — the whole point of the control being sticky across pages.
func TestE2EBasemapToggleKeepsOverlays(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ebasemap")
rep := srv.newRepeater(t, user.ID, "Toggle Node")
if err := srv.store.SetRepeaterLocation(srv.ctx, rep.ID, 42.9, -78.8); err != nil {
t.Fatalf("set location: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var before, after sourceCounts
var styleBefore, styleAfter, remembered string
pageURL := srv.appURL + "/repeaters/" + rep.PublicID
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(pageURL),
mapReady("map", "points"),
mapEval("map", countIn("points"), &before),
mapEval("map", `return map.getStyle().name;`, &styleBefore),
// Press Light, then wait for the new style to finish loading *and* the
// overlays to be put back on it.
chromedp.Click(`.mesh-basemap-ctrl button[data-basemap="light"]`, chromedp.ByQuery),
chromedp.Poll(`(function () {
var m = window.MESHTENDER_MAPS["map"].map;
return m.isStyleLoaded() && m.getStyle().name !== `+"`"+`Dark Matter`+"`"+` && !!m.getLayer("points");
})()`, nil, chromedp.WithPollingTimeout(30*time.Second)),
mapEval("map", countIn("points"), &after),
mapEval("map", `return map.getStyle().name;`, &styleAfter),
chromedp.Evaluate(`localStorage.getItem("mt_map_base")`, &remembered),
); err != nil {
t.Fatalf("browser run against %s: %v", pageURL, err)
}
if styleBefore == styleAfter {
t.Errorf("basemap style is still %q after pressing Light; the swap did not happen", styleAfter)
}
if before.Features == 0 {
t.Fatal("no repeater point rendered before the swap; the rest of this test proves nothing")
}
if after.Features != before.Features {
t.Errorf("after switching basemap the map has %d points, want the %d it started with — "+
"setStyle discards page layers, so basemap.js must re-add them", after.Features, before.Features)
}
if remembered != "light" {
t.Errorf("localStorage mt_map_base = %q, want %q so the choice carries to the next map", remembered, "light")
}
watch.assertClean(t)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// the button expands it, the recommended commands (profile base settings + region
// commands for the repeater's location) render, and — with no modem connected —
// the Run controls are disabled with a prompt to connect. Also asserts the page
// (Bootstrap collapse + Leaflet) runs clean under the strict CSP.
// (Bootstrap collapse + MapLibre) runs clean under the strict CSP.
func TestE2EConsoleConfigPanel(t *testing.T) {
srv := newE2EServer(t)
owner, cookie := srv.login(t, "cfg-owner")
+53
View File
@@ -36,6 +36,7 @@ import (
"net/url"
"os"
"regexp"
"strconv"
"strings"
"sync"
"testing"
@@ -347,6 +348,58 @@ func expandSection(toggleTestID, wantVisible string) chromedp.Tasks {
}
}
// ---- maps ---------------------------------------------------------------
//
// The maps are MapLibre GL, which paints to a WebGL canvas: there is no per-shape
// DOM node to select, the way there was with Leaflet's SVG overlay pane. So map
// assertions go through window.MESHTENDER_MAPS, the registry basemap.js fills in
// (see its comment) — a deliberate test surface rather than a reach into internals.
//
// Note this makes the map tests depend on CARTO being reachable from the browser
// container: a MapLibre style has to load before any layer can be added to it, so a
// blocked network shows up here as mapReady timing out.
// mapReady waits until the map in the given container has its style loaded and the
// page's own layer on it.
func mapReady(elID, layer string) chromedp.Action {
return chromedp.Poll(fmt.Sprintf(`(function () {
var e = window.MESHTENDER_MAPS && window.MESHTENDER_MAPS[%q];
return !!(e && e.map && e.map.isStyleLoaded() && e.map.getLayer(%q));
})()`, elID, layer), nil, chromedp.WithPollingTimeout(30*time.Second))
}
// mapEval evaluates expr with `map` bound to the named container's MapLibre map.
func mapEval(elID, expr string, res any) chromedp.Action {
return chromedp.Evaluate(fmt.Sprintf(
`(function (map) { %s })(window.MESHTENDER_MAPS[%q].map)`, expr, elID), res)
}
// countSourceFeatures counts the distinct features a source has rendered into the
// current viewport, separating clusters from individual features.
//
// Both sides dedupe on the feature id, because a source query returns a feature
// once per tile it touches — a region polygon spanning four tiles comes back four
// times, each clipped to its tile. That is why meshmap.js and regionmap.js give
// every feature an explicit id.
const countSourceFeatures = `
var seen = {}, clusters = {};
map.querySourceFeatures(SOURCE).forEach(function (f) {
if (f.properties.cluster) clusters[f.properties.cluster_id] = 1;
else seen[f.id] = 1;
});
return { features: Object.keys(seen).length, clusters: Object.keys(clusters).length };`
// sourceCounts is what countSourceFeatures returns.
type sourceCounts struct {
Features int `json:"features"`
Clusters int `json:"clusters"`
}
// countIn builds the counting expression for one source.
func countIn(source string) string {
return "var SOURCE = " + strconv.Quote(source) + ";" + countSourceFeatures
}
// newRepeater creates a repeater owned by ownerID with a valid MeshCore key.
func (e *e2eServer) newRepeater(t *testing.T, ownerID int64, name string) *store.Repeater {
t.Helper()
+23 -18
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"fmt"
"testing"
"time"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
@@ -16,11 +17,11 @@ import (
)
// TestE2EOrgPublicMap renders the public org Repeaters map in a real browser and
// verifies the full Batch 2+3 chain under the strict CSP: the page fetches its
// points from the cached JSON endpoint (connect-src 'self') and the vendored
// markercluster plugin clusters co-located markers. A visible cluster proves the
// fetch succeeded and the plugin loaded; assertClean proves no CSP violation
// (from the fetch or the new self-hosted assets).
// verifies the full chain under the strict CSP: the page fetches its points from
// the cached JSON endpoint (connect-src 'self'), the CARTO vector style loads
// (connect-src the CARTO host, plus the same-origin MapLibre worker), and the
// source's own clustering collapses the co-located repeaters. A cluster proves the
// fetch succeeded and the map is live; assertClean proves no CSP violation.
func TestE2EOrgPublicMap(t *testing.T) {
// Served anonymously by the root (marketing) surface.
srv := newE2EServer(t)
@@ -35,7 +36,7 @@ func TestE2EOrgPublicMap(t *testing.T) {
}
// Four repeaters at the *same* coordinates (they can never separate by zoom, so
// they always cluster) plus two elsewhere — guarantees a .marker-cluster.
// they always cluster) plus two elsewhere — guarantees at least one cluster.
locs := []struct{ lat, lon float64 }{
{40.0, -75.0}, {40.0, -75.0}, {40.0, -75.0}, {40.0, -75.0},
{41.0, -76.0}, {39.0, -74.0},
@@ -58,27 +59,31 @@ func TestE2EOrgPublicMap(t *testing.T) {
bctx, cancel, watch := startBrowser(t)
defer cancel()
var counts sourceCounts
mapURL := srv.rootURL + "/orgs/" + org.Slug + "/repeaters"
var clusterCount, markerCount int
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
chromedp.Navigate(mapURL),
// The map only paints markers after the fetch to /repeaters.json resolves.
chromedp.WaitVisible(`.leaflet-container`, chromedp.ByQuery),
chromedp.WaitVisible(`.marker-cluster`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('.marker-cluster').length`, &clusterCount),
chromedp.Evaluate(`document.querySelectorAll('.leaflet-interactive').length`, &markerCount),
// The layer only exists once the points fetch has resolved and the style has
// loaded, so this one wait covers both.
mapReady("map", "clusters"),
// Clustering happens on the worker, so the counts settle a beat after the
// layer appears.
chromedp.Poll(`(function () {
return window.MESHTENDER_MAPS["map"].map
.querySourceFeatures("points").length > 0;
})()`, nil, chromedp.WithPollingTimeout(30*time.Second)),
mapEval("map", countIn("points"), &counts),
); err != nil {
t.Fatalf("browser run against %s: %v", mapURL, err)
}
if clusterCount == 0 {
t.Errorf("no marker cluster rendered; the fetched points did not cluster")
if counts.Clusters < 1 {
t.Errorf("no marker cluster rendered; the four co-located repeaters should collapse into one")
}
if markerCount == 0 {
t.Errorf("no interactive map markers rendered")
if counts.Features+counts.Clusters < 2 {
t.Errorf("map rendered %d features and %d clusters, want the outliers drawn alongside the cluster",
counts.Features, counts.Clusters)
}
// The whole point of the exercise: the fetch + vendored assets ran cleanly.
watch.assertClean(t)
}
+390 -25
View File
@@ -107,19 +107,20 @@ func TestE2EConfigMapShowsRegionsAndPicksLocation(t *testing.T) {
// Signed out, on the public root host.
cfgURL := srv.rootURL + "/orgs/" + org.Slug + "/config"
var polygons, rows int
var counts sourceCounts
var rows int
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
chromedp.Navigate(cfgURL),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('#region-map .leaflet-overlay-pane path').length`, &polygons),
mapReady("region-map", "regions-fill"),
mapEval("region-map", countIn("regions"), &counts),
chromedp.Evaluate(`document.querySelectorAll('[data-testid="config-region-row"]').length`, &rows),
); err != nil {
t.Fatalf("browser run against %s: %v", cfgURL, err)
}
if polygons != 3 {
t.Errorf("drew %d polygons, want 3 (one per shaped region)", polygons)
if counts.Features != 3 {
t.Errorf("drew %d polygons, want 3 (one per shaped region)", counts.Features)
}
if rows != 3 {
t.Errorf("legend has %d rows, want 3", rows)
@@ -347,14 +348,12 @@ func TestE2ERegionAreaDraftEnablesDrawing(t *testing.T) {
bctx, cancel, watch := startBrowser(t)
defer cancel()
// Geoman marks a disabled toolbar button on the anchor wrapping the icon, with
// both .pm-disabled and aria-disabled="true" (see _updateDisabled in
// leaflet-geoman.js). Reading aria-disabled keeps the assertion unambiguous.
// Terra Draw ships no UI, so the toolbar is ours (DrawControl in regionmap.js)
// and a disabled tool is a plain disabled <button>.
drawButton := `[data-testid="region-draw-polygon"]`
drawDisabled := `(function () {
var icon = document.querySelector('.leaflet-pm-icon-polygon');
if (!icon) return 'missing';
var btn = icon.closest('a');
return btn ? btn.getAttribute('aria-disabled') : 'no-anchor';
var btn = document.querySelector('` + drawButton + `');
return btn ? String(btn.disabled) : 'missing';
})()`
areaURL := func(rid int64) string {
return srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
@@ -368,7 +367,8 @@ func TestE2ERegionAreaDraftEnablesDrawing(t *testing.T) {
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(areaURL(draft)),
chromedp.WaitVisible(`.leaflet-pm-icon-polygon`, chromedp.ByQuery),
mapReady("region-map", "siblings-fill"),
chromedp.WaitVisible(drawButton, chromedp.ByQuery),
chromedp.Text(`#region-area-status`, &draftStatus, chromedp.ByQuery),
chromedp.Evaluate(drawDisabled, &draftDrawDisabled),
); err != nil {
@@ -386,7 +386,11 @@ func TestE2ERegionAreaDraftEnablesDrawing(t *testing.T) {
var shapedDrawDisabled string
if err := chromedp.Run(bctx,
chromedp.Navigate(areaURL(shaped)),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
mapReady("region-map", "siblings-fill"),
chromedp.Poll(`(function () {
var e = window.MESHTENDER_MAPS["region-map"];
return !!(e && e.draw && e.draw.getSnapshot().length);
})()`, nil, chromedp.WithPollingTimeout(30*time.Second)),
chromedp.Evaluate(drawDisabled, &shapedDrawDisabled),
); err != nil {
t.Fatalf("browser run against the shaped region's area page: %v", err)
@@ -400,7 +404,8 @@ func TestE2ERegionAreaDraftEnablesDrawing(t *testing.T) {
// TestE2ERegionAreaEditor: the area workspace loads the region's shape into the map,
// draws its siblings as context, and saves the geometry the map produces — proving
// the Leaflet/Geoman wiring works under the strict CSP (no inline handlers).
// the MapLibre/Terra Draw wiring works under the strict CSP (no inline handlers,
// and a worker loaded from a same-origin URL rather than a blob:).
func TestE2ERegionAreaEditor(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergnarea")
@@ -427,11 +432,14 @@ func TestE2ERegionAreaEditor(t *testing.T) {
defer cancel()
var status, hidden string
var paths int
// countPaths counts rendered vector layers: this region plus its one sibling. It
// is scoped to the overlay pane because Leaflet's attribution control contains a
// decorative SVG of its own.
countPaths := `document.querySelectorAll('#region-map .leaflet-overlay-pane path').length`
var siblings sourceCounts
var editable int
// The two shapes live in different places now: the read-only sibling is a layer
// the page adds, while the one under edit belongs to Terra Draw's own store.
countEditable := `(function () {
return window.MESHTENDER_MAPS["region-map"].draw.getSnapshot()
.filter(function (f) { return f.geometry.type === "Polygon"; }).length;
})()`
areaURL := srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
if err := chromedp.Run(bctx,
network.Enable(),
@@ -439,10 +447,10 @@ func TestE2ERegionAreaEditor(t *testing.T) {
setSessionCookie(cookie),
chromedp.Navigate(areaURL),
// The map initializes and reports the existing area.
chromedp.WaitVisible(`#region-map .leaflet-container, #region-map.leaflet-container`, chromedp.ByQuery),
chromedp.WaitVisible(`#region-map .leaflet-overlay-pane path`, chromedp.ByQuery),
mapReady("region-map", "siblings-fill"),
chromedp.Text(`#region-area-status`, &status, chromedp.ByQuery),
chromedp.Evaluate(countPaths, &paths),
mapEval("region-map", countIn("siblings"), &siblings),
chromedp.Evaluate(countEditable, &editable),
chromedp.Value(`#region_geojson`, &hidden, chromedp.ByQuery),
); err != nil {
t.Fatalf("browser run against %s: %v", areaURL, err)
@@ -450,8 +458,11 @@ func TestE2ERegionAreaEditor(t *testing.T) {
if status != "Custom area" {
t.Errorf("area status = %q, want %q", status, "Custom area")
}
if paths != 2 {
t.Errorf("rendered %d map polygons, want exactly 2 (the region + its sibling)", paths)
if siblings.Features != 1 {
t.Errorf("drew %d sibling outlines, want exactly 1", siblings.Features)
}
if editable != 1 {
t.Errorf("loaded %d editable polygons, want exactly 1 (the region under edit)", editable)
}
if !strings.Contains(hidden, "Polygon") {
t.Errorf("hidden geofence field = %q, want it to hold the polygon geometry", hidden)
@@ -488,3 +499,357 @@ func TestE2ERegionAreaEditor(t *testing.T) {
}
watch.assertClean(t)
}
// TestE2ERegionAreaDrawsAPolygon draws an area the way an operator does — press the
// tool, click the corners on the map, press Enter — and checks the geometry lands in
// the field the form submits.
//
// This is the interaction the migration rebuilt from scratch: Leaflet-Geoman brought
// its own toolbar and edit handles, while Terra Draw is headless, so the tool
// buttons, the mode switching and the serialization are all ours (DrawControl and
// initRegionArea in regionmap.js). None of that is exercised by asserting on a
// pre-loaded shape, and all of it runs on click handlers the strict CSP would reject
// if they were ever written inline.
func TestE2ERegionAreaDrawsAPolygon(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergndraw")
org, err := srv.store.CreateOrg(srv.ctx, "Region Draw Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
// A draft: no area yet, so the draw tools start enabled.
rid, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "buf", DisplayName: "Buffalo", Layer: 3, AllowFlood: true,
})
if err != nil {
t.Fatalf("create region: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
// The corners, as offsets inside the map container.
corners := []struct{ dx, dy float64 }{{120, 90}, {260, 110}, {200, 220}}
var rect struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
var status, geojson string
areaURL := srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
tasks := chromedp.Tasks{
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(areaURL),
mapReady("region-map", "siblings-fill"),
chromedp.Click(`[data-testid="region-draw-polygon"]`, chromedp.ByQuery),
chromedp.Evaluate(`(function () {
var r = document.getElementById("region-map").getBoundingClientRect();
return { x: r.left, y: r.top };
})()`, &rect),
}
if err := chromedp.Run(bctx, tasks); err != nil {
t.Fatalf("browser run against %s: %v", areaURL, err)
}
clicks := chromedp.Tasks{}
for _, c := range corners {
clicks = append(clicks, chromedp.MouseClickXY(rect.X+c.dx, rect.Y+c.dy))
}
if err := chromedp.Run(bctx,
clicks,
// Terra Draw's polygon mode closes the ring on Enter, which is far steadier
// than trying to land a click back on the first vertex.
chromedp.KeyEvent("\r"),
chromedp.Poll(`document.getElementById("region_geojson").value !== ""`, nil,
chromedp.WithPollingTimeout(15*time.Second)),
chromedp.Text(`#region-area-status`, &status, chromedp.ByQuery),
chromedp.Value(`#region_geojson`, &geojson, chromedp.ByQuery),
); err != nil {
t.Fatalf("drawing the polygon: %v", err)
}
if status != "Custom area" {
t.Errorf("status after drawing = %q, want %q", status, "Custom area")
}
if !strings.Contains(geojson, `"Polygon"`) {
t.Errorf("drawn geometry = %q, want a GeoJSON Polygon", geojson)
}
// Saving persists what was drawn, which is the whole point of the workspace.
if err := chromedp.Run(bctx,
chromedp.Click(`[data-testid="save-area"]`, chromedp.ByQuery),
chromedp.WaitVisible(`[data-testid="config-region-row"]`, chromedp.ByQuery),
); err != nil {
t.Fatalf("saving the drawn area: %v", err)
}
z, err := srv.store.GetRegion(srv.ctx, org.ID, rid)
if err != nil {
t.Fatalf("get region: %v", err)
}
if z.Geofence == nil {
t.Fatal("the drawn area did not persist; the region is still a draft")
}
watch.assertClean(t)
}
// TestE2ERegionAreaDrawsAConcaveArea is the regression test for a reported bug: an
// operator drawing a coastline-shaped region — south down the state line, east along
// the bottom, then farther south around a peninsula — found the tool simply stopped
// adding points, with no message.
//
// The cause was where the self-intersection rule was enforced. Terra Draw runs a
// mode's `validation` on provisional updates as well as on finish, and a polygon
// under construction is auto-closed back to its first point. An outline that has gone
// south then east crosses that closing edge the moment it heads south again — so the
// intermediate ring is self-intersecting even though the finished shape never is, and
// the click was rejected silently. Only the finished shape is judged now.
//
// The second half asserts the rule still does its job: a genuinely crossed outline is
// refused, says why, and — the part that matters most — never reaches the field the
// form submits, so Save cannot persist a geofence with no inside.
func TestE2ERegionAreaDrawsAConcaveArea(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergnconcave")
org, err := srv.store.CreateOrg(srv.ctx, "Concave Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
rid, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "ny", DisplayName: "New York", Layer: 2, AllowFlood: true,
})
if err != nil {
t.Fatalf("create region: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
// mapOrigin re-reads where the map sits in the viewport. It has to be re-read
// after anything that scrolls (clicking a footer button scrolls it into view),
// or clicks computed from a stale origin land outside the map entirely.
var origin struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
mapOrigin := chromedp.Tasks{
chromedp.Evaluate(`(function () {
document.getElementById("region-map").scrollIntoView({ block: "start" });
return true;
})()`, nil),
chromedp.Sleep(300 * time.Millisecond),
chromedp.Evaluate(`(function () {
var r = document.getElementById("region-map").getBoundingClientRect();
return { x: r.left, y: r.top };
})()`, &origin),
}
// ringLength is how many coordinates the shape under the cursor currently has.
const ringLength = `(function () {
var f = window.MESHTENDER_MAPS["region-map"].draw.getSnapshot()
.filter(function (x) { return x.geometry.type === "Polygon"; })[0];
return f ? f.geometry.coordinates[0].length : 0;
})()`
areaURL := srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(areaURL),
mapReady("region-map", "siblings-fill"),
chromedp.Click(`[data-testid="region-draw-polygon"]`, chromedp.ByQuery),
mapOrigin,
); err != nil {
t.Fatalf("browser run against %s: %v", areaURL, err)
}
// The concave outline. Corner 4 — heading farther south after going east — is
// the one the bug swallowed.
concave := []struct{ dx, dy float64 }{
{120, 80}, {120, 200}, {330, 200}, {330, 270}, {400, 270}, {400, 120}, {250, 120},
}
for i, c := range concave {
var ring int
if err := chromedp.Run(bctx,
chromedp.MouseClickXY(origin.X+c.dx, origin.Y+c.dy),
chromedp.Sleep(250*time.Millisecond),
chromedp.Evaluate(ringLength, &ring),
); err != nil {
t.Fatalf("corner %d: %v", i+1, err)
}
// Terra Draw seeds a closed ring from the first click, so the count runs
// ahead of the corner number; what matters is that it keeps growing.
if want := i + 2; i > 0 && ring < want {
t.Fatalf("after corner %d the ring has %d coordinates, want at least %d — "+
"the click was rejected, which is the concave-drawing bug", i+1, ring, want)
}
}
var status, geojson string
if err := chromedp.Run(bctx,
chromedp.KeyEvent("\r"),
chromedp.Poll(`document.getElementById("region_geojson").value !== ""`, nil,
chromedp.WithPollingTimeout(15*time.Second)),
chromedp.Text(`#region-area-status`, &status, chromedp.ByQuery),
chromedp.Value(`#region_geojson`, &geojson, chromedp.ByQuery),
); err != nil {
t.Fatalf("finishing the concave outline: %v", err)
}
if status != "Custom area" {
t.Errorf("status = %q, want %q", status, "Custom area")
}
// Seven corners plus the repeated closing coordinate.
if n := strings.Count(geojson, "],["); n != 7 {
t.Errorf("drawn ring has %d segments, want 7 — corners were dropped: %s", n, geojson)
}
// Now a genuinely self-intersecting outline: clear, then draw a bowtie.
if err := chromedp.Run(bctx,
chromedp.Click(`#region-area-clear`, chromedp.ByQuery),
mapOrigin,
); err != nil {
t.Fatalf("clearing before the crossed outline: %v", err)
}
for _, c := range []struct{ dx, dy float64 }{{120, 80}, {330, 80}, {120, 220}, {330, 220}} {
if err := chromedp.Run(bctx,
chromedp.MouseClickXY(origin.X+c.dx, origin.Y+c.dy),
chromedp.Sleep(250*time.Millisecond),
); err != nil {
t.Fatalf("crossed outline: %v", err)
}
}
var crossedStatus, crossedValue string
if err := chromedp.Run(bctx,
chromedp.KeyEvent("\r"),
chromedp.Sleep(700*time.Millisecond),
chromedp.Text(`#region-area-status`, &crossedStatus, chromedp.ByQuery),
chromedp.Value(`#region_geojson`, &crossedValue, chromedp.ByQuery),
); err != nil {
t.Fatalf("finishing the crossed outline: %v", err)
}
if crossedValue != "" {
t.Errorf("a self-intersecting outline reached the submitted field (%q); Save would "+
"persist a geofence with no well-defined inside", crossedValue)
}
if !strings.Contains(crossedStatus, "cross") {
t.Errorf("status after a refused outline = %q, want it to say why the shape was "+
"rejected — a silent refusal is the bug this whole test exists for", crossedStatus)
}
watch.assertClean(t)
}
// TestE2ERegionAreaVertexSpacing pins both sides of one trade-off.
//
// Terra Draw's pointerDistance is the radius, in screen pixels, within which a click
// counts as "the same point". It is measured against the previous vertex as well as
// the first, so at its default of 40 no two corners could be placed closer than that
// — tracing a shoreline or a county line meant zooming far in and stitching. The
// editor lowers it (POINTER_DISTANCE in regionmap.js).
//
// The cost is that closing the ring by clicking the first corner is a smaller target,
// so both halves are asserted here: fine detail has to be drawable, and closing by
// click has to keep working. Tightening the constant further without checking the
// second half would quietly make the outline hard to finish.
func TestE2ERegionAreaVertexSpacing(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2ergnspacing")
org, err := srv.store.CreateOrg(srv.ctx, "Spacing Org", user.ID)
if err != nil {
t.Fatalf("create org: %v", err)
}
rid, err := srv.store.CreateRegion(srv.ctx, org.ID, store.RegionInput{
Token: "sp", DisplayName: "Spacing", Layer: 2, AllowFlood: true,
})
if err != nil {
t.Fatalf("create region: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
var origin struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
areaURL := srv.appURL + "/orgs/" + org.Slug + "/config/regions/" + strconv.FormatInt(rid, 10) + "/area"
startDrawing := chromedp.Tasks{
chromedp.Navigate(areaURL),
mapReady("region-map", "siblings-fill"),
chromedp.Click(`[data-testid="region-draw-polygon"]`, chromedp.ByQuery),
chromedp.Evaluate(`(function () {
document.getElementById("region-map").scrollIntoView({ block: "start" });
return true;
})()`, nil),
chromedp.Sleep(300 * time.Millisecond),
chromedp.Evaluate(`(function () {
var r = document.getElementById("region-map").getBoundingClientRect();
return { x: r.left, y: r.top };
})()`, &origin),
}
const ringLength = `(function () {
var f = window.MESHTENDER_MAPS["region-map"].draw.getSnapshot()
.filter(function (x) { return x.geometry.type === "Polygon"; })[0];
return f ? f.geometry.coordinates[0].length : 0;
})()`
// Detail: a staircase whose corners are ~14px apart — well inside the old 40px
// floor. Every click must land.
const gap = 10.0
var ring int
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
startDrawing,
); err != nil {
t.Fatalf("browser run against %s: %v", areaURL, err)
}
for i := range 5 {
if err := chromedp.Run(bctx,
chromedp.MouseClickXY(origin.X+150+float64(i)*gap, origin.Y+150+float64(i%2)*gap),
chromedp.Sleep(220*time.Millisecond),
chromedp.Evaluate(ringLength, &ring),
); err != nil {
t.Fatalf("close-spaced corner %d: %v", i+1, err)
}
}
// Five placed corners, plus the ring's repeated closing coordinate and the one
// tracking the cursor.
if ring != 7 {
t.Errorf("after five corners ~%.0fpx apart the ring has %d coordinates, want 7 — "+
"clicks that close together are being swallowed, so fine detail can't be drawn",
gap*1.414, ring)
}
// Closing: a click back on the first corner, slightly off-centre, still finishes.
var st struct {
Mode string `json:"mode"`
Filled bool `json:"filled"`
}
if err := chromedp.Run(bctx,
startDrawing,
chromedp.MouseClickXY(origin.X+150, origin.Y+150),
chromedp.Sleep(200*time.Millisecond),
chromedp.MouseClickXY(origin.X+320, origin.Y+150),
chromedp.Sleep(200*time.Millisecond),
chromedp.MouseClickXY(origin.X+320, origin.Y+300),
chromedp.Sleep(200*time.Millisecond),
chromedp.MouseClickXY(origin.X+155, origin.Y+155),
chromedp.Sleep(600*time.Millisecond),
chromedp.Evaluate(`(function () {
var e = window.MESHTENDER_MAPS["region-map"];
return { mode: e.draw.getMode(),
filled: document.getElementById("region_geojson").value !== "" };
})()`, &st),
); err != nil {
t.Fatalf("closing by click: %v", err)
}
if st.Mode != "select" || !st.Filled {
t.Errorf("clicking back on the first corner left mode=%q filled=%v, want the ring "+
"closed and handed to the edit tool — the closing target has become too small",
st.Mode, st.Filled)
}
watch.assertClean(t)
}
+1 -1
View File
@@ -290,7 +290,7 @@ func TestNoticesFileIsCurrent(t *testing.T) {
}
// TestNoticesUsesUnixLineEndings is a regression test. Leaflet's license file
// ships with CRLF, and the generator used to copy those bytes straight into the
// shipped with CRLF, and the generator used to copy those bytes straight into the
// Markdown. The committed file then got normalized to LF by an editor, so it no
// longer matched the generator's output and TestNoticesFileIsCurrent failed on
// every run with no way to fix it by regenerating. A mixed-line-ending
+29 -28
View File
@@ -118,47 +118,48 @@ var Deps = []Dep{
},
},
{
Name: "Leaflet",
Version: "1.9.4",
SPDX: "BSD-2-Clause",
Homepage: "https://leafletjs.com",
Name: "MapLibre GL JS",
Version: "5.24.0",
SPDX: "BSD-3-Clause",
Homepage: "https://maplibre.org",
Kind: KindAsset,
Source: "https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/",
LicenseText: "leaflet-1.9.4.txt",
Source: "https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/",
LicenseText: "maplibre-gl-5.24.0.txt",
Files: []File{
{Path: "internal/web/static/leaflet.js", SHA256: "db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a", Notice: true},
{Path: "internal/web/static/leaflet.css", SHA256: "498bd934faeb2cb455d6db2d9304d18d5aea69afe43fd2ac933c3f3753724617", Notice: true},
{Path: "internal/web/static/maplibre-gl.js", SHA256: "039b46f7a84489bce207f55cf376e1022cf8d213190ff9a93b02554f1785248f", Notice: true},
{Path: "internal/web/static/maplibre-gl-worker.js", SHA256: "4f64bf26dab4a953ceb06c49a8d6efef32d5015a3f4c872a1f73e13f60ac9dba", Notice: true},
{Path: "internal/web/static/maplibre-gl.css", SHA256: "3f40ab71b5b3fc985eb7f6b8926e0e8fbec2792852e4f12d8247abf0aabb8a94", Notice: true},
},
Modified: "leaflet.css carries a hand-restored @preserve banner; upstream ships the stylesheet without one. Body is byte-identical to upstream.",
Modified: "All three carry hand-extended or hand-restored @preserve banners (upstream's script banner names the license by URL and states no copyright; the stylesheet ships none) and have their trailing sourceMappingURL comment stripped, since the .map files are not vendored. Bodies are byte-identical to upstream.",
Note: "This is the CSP build: maplibre-gl.js is upstream dist/maplibre-gl-csp.js and maplibre-gl-worker.js is dist/maplibre-gl-csp-worker.js. The default bundle spawns its worker from a blob: URL, which the strict CSP forbids; the CSP build takes an explicit same-origin worker URL via maplibregl.setWorkerUrl instead (see basemap.js). Upstream's LICENSE.txt, reproduced whole in the notices, also covers the third-party code MapLibre embeds — mapbox-gl-js v1.13 and earlier (BSD-3-Clause, (c) 2020 Mapbox), glfx.js (MIT) and a portion of d3-color (BSD-3-Clause) — all permissive.",
},
{
Name: "Leaflet-Geoman",
Version: "2.20.0",
Name: "Terra Draw",
Version: "1.32.3",
SPDX: "MIT",
Homepage: "https://geoman.io",
Homepage: "https://github.com/JamesLMilner/terra-draw",
Kind: KindAsset,
Source: "https://cdn.jsdelivr.net/npm/@geoman-io/leaflet-geoman-free@2.20.0/dist/",
LicenseText: "leaflet-geoman-free-2.20.0.txt",
Source: "https://cdn.jsdelivr.net/npm/terra-draw@1.32.3/dist/terra-draw.umd.js",
LicenseText: "terra-draw-1.32.3.txt",
Files: []File{
{Path: "internal/web/static/leaflet-geoman.js", SHA256: "50bce5ec0c880d7edc912254f645aa77364fd6c29d66ef92296f855b8b615498", Notice: true},
{Path: "internal/web/static/leaflet-geoman.css", SHA256: "51e45cbdf47dccb437bb34c9aa96b2017957a2471e17f41a72f4ec15a3b8c3f2", Notice: true},
{Path: "internal/web/static/terra-draw.js", SHA256: "1d6fcaf87f34ec1515e7f960d39915be83365e1c521bdc10e64e25071df7a18d", Notice: true},
},
Modified: "Both files carry hand-restored banners: the upstream esbuild bundle strips its own. Bodies are byte-identical to leaflet-geoman.min.js and leaflet-geoman.css upstream.",
Note: "This is the free MIT package (@geoman-io/leaflet-geoman-free). Geoman also sells a commercially licensed product — do not upgrade into it.",
Modified: "Hand-restored banner (the upstream UMD bundle ships none) and a stripped trailing sourceMappingURL comment. Body is byte-identical to upstream.",
Note: "Drives polygon drawing and editing on the region area editor, replacing Leaflet-Geoman. Headless by design — it renders shapes into the map and ships no toolbar UI, so the controls are ours (see regionmap.js and config_region_area.html). The npm package ships no LICENSE file; the text here is the one at the root of the monorepo that publishes it, which the adapter shares.",
},
{
Name: "Leaflet.markercluster",
Version: "1.5.3",
Name: "Terra Draw MapLibre GL adapter",
Version: "1.4.1",
SPDX: "MIT",
Homepage: "https://github.com/Leaflet/Leaflet.markercluster",
Homepage: "https://github.com/JamesLMilner/terra-draw",
Kind: KindAsset,
Source: "https://cdn.jsdelivr.net/npm/leaflet.markercluster@1.5.3/dist/",
LicenseText: "leaflet.markercluster-1.5.3.txt",
Source: "https://cdn.jsdelivr.net/npm/terra-draw-maplibre-gl-adapter@1.4.1/dist/terra-draw-maplibre-gl-adapter.umd.js",
LicenseText: "terra-draw-maplibre-gl-adapter-1.4.1.txt",
Files: []File{
{Path: "internal/web/static/leaflet.markercluster.js", SHA256: "b687c3bd8b9239b1dbe4bc4241c2940426cf15ca8543c73e5d4e31e3346fab25", Notice: true},
{Path: "internal/web/static/leaflet.markercluster.css", SHA256: "882ea5266422a7ff57e5641f78a7e8464f81b575f0665634808d60ae6f5ed41d", Notice: true},
{Path: "internal/web/static/terra-draw-maplibre-gl-adapter.js", SHA256: "ac90d8efe1d367e483e11decdffc421a7150d57686aec016b261f3f32e49bec7", Notice: true},
},
Modified: "The stylesheet is upstream MarkerCluster.css + MarkerCluster.Default.css concatenated, plus a banner; the script is upstream plus a banner. Both bodies are byte-identical to upstream.",
Modified: "Hand-restored banner (the upstream UMD bundle ships none) and a stripped trailing sourceMappingURL comment. Body is byte-identical to upstream.",
Note: "Published from the same monorepo as Terra Draw and under the same root MIT license, which is why the two license texts here are identical.",
},
{
Name: "Tabler",
@@ -209,8 +210,8 @@ var Deps = []Dep{
Name: "CARTO basemaps",
Homepage: "https://carto.com",
Kind: KindService,
Source: "https://{s}.basemaps.cartocdn.com (allowlisted in the CSP img-src), keyed with MESHTENDER_CARTO_KEY",
Note: "Raster map tiles fetched by the browser at runtime; no code is redistributed, so no license applies. Attribution (\"(c) OpenStreetMap (c) CARTO\") is rendered by basemap.js. Requests carry an API key (MESHTENDER_CARTO_KEY, per deployment); CARTO watermark tiles served without one. CARTO have said these raster tiles are deprecated in favour of vector tiles, which Leaflet cannot render. Terms of use are CARTO's and are not verified by any test.",
Source: "https://*.basemaps.cartocdn.com (allowlisted in the CSP connect-src), keyed with MESHTENDER_CARTO_KEY",
Note: "Vector basemaps (MapLibre GL styles) rendered in the browser at runtime; no code is redistributed, so no license applies. A style pulls four kinds of resource from CARTO — the style JSON, .mvt tiles, a sprite sheet and glyph PBFs — all fetched rather than loaded as images, which is why the CSP allowlists the host in connect-src rather than img-src. Attribution (\"(c) OpenStreetMap (c) CARTO\") is rendered by basemap.js, and CARTO require it stay visible. Every request carries an API key (MESHTENDER_CARTO_KEY, per deployment) appended by a MapLibre transformRequest, because the URLs inside a style do not carry one. The raster tiles this replaced are deprecated upstream. Terms of use are CARTO's and are not verified by any test.",
},
}
+2 -1
View File
@@ -113,7 +113,8 @@ func writeDep(b *strings.Builder, d Dep, withText bool) error {
}
// normalizeEOL rewrites CRLF to LF. Some upstream license files ship with
// Windows line endings (Leaflet's does), and Dep.Text deliberately returns them
// Windows line endings (Leaflet's did, back when we vendored it), and Dep.Text
// deliberately returns them
// verbatim so the SPDX check reads exactly what upstream published. Writing
// those bytes straight into the Markdown, though, leaves the generated file
// with mixed line endings — which any editor or `git add` with autocrlf will
-26
View File
@@ -1,26 +0,0 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2017 Sumit Kumar
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,20 +0,0 @@
Copyright 2012 David Leaver
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,116 @@
Copyright (c) 2023, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,8 @@
Copyright 2022 James Milner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,8 @@
Copyright 2022 James Milner
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+3 -3
View File
@@ -134,9 +134,9 @@
your address leaves our server.
</li>
<li>
<strong>CARTO</strong> serves the map tiles. When a page with a map loads, your browser
fetches tiles from CARTO directly, so CARTO sees your IP address and which tiles you asked
for. Pages without a map involve them not at all.
<strong>CARTO</strong> serves the map. When a page with a map loads, your browser fetches
the map data from CARTO directly, so CARTO sees your IP address and which part of the map you
asked for. Pages without a map involve them not at all.
</li>
</ul>
<p>
@@ -64,10 +64,9 @@
<div class="card mt-3">
<div class="card-header"><h2 class="card-title">Location</h2></div>
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
{{template "map-styles"}}
<div id="map" style="height:240px" role="region" aria-label="Map of this repeater's location"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
meshMap('map', [{name: {{.Repeater.Name}}, lat: {{.Repeater.Latitude}}, lon: {{.Repeater.Longitude}}}]);
+86 -23
View File
@@ -65,13 +65,38 @@ func TestCartoKeyAbsentRendersEmptyAttribute(t *testing.T) {
}
}
// TestMapPagesLoadBasemapFirst: meshmap.js and regionmap.js both call
// meshBaseLayers, which now lives in basemap.js. A page that loads one of them
// without basemap.js throws a ReferenceError at map-init time and renders a blank
// container — no build error, no failing Go test, and nothing in the CSP report.
// Since map pages span all three surfaces, pin the include structurally rather
// than trusting the next one to remember it.
func TestMapPagesLoadBasemapFirst(t *testing.T) {
// TestEveryLayoutCarriesTheWorkerURL: MapLibre's CSP build will not start without
// an explicit worker URL, and the URL is content-fingerprinted, so it can only come
// from the server. It rides on the root element next to the CARTO key, which means
// every layout has to carry it — a map rendered under a layout that forgot it dies
// with "Failed to initialize worker" and paints nothing.
func TestEveryLayoutCarriesTheWorkerURL(t *testing.T) {
t.Parallel()
b, err := os.ReadFile(filepath.Join(moduleRoot(t), "internal/web/templates/base.html"))
if err != nil {
t.Fatalf("read base.html: %v", err)
}
src := string(b)
roots := strings.Count(src, "<html lang=")
workers := strings.Count(src, `data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}"`)
if roots == 0 {
t.Fatal("base.html declares no <html> root; this test can no longer see the layouts")
}
if workers != roots {
t.Errorf("base.html has %d layout roots but %d carry data-maplibre-worker; "+
"every layout needs it or a map rendered under it cannot start its worker", roots, workers)
}
}
// TestMapPagesLoadTheMapStack: meshmap.js and regionmap.js both build on
// meshCreateMap, which lives in basemap.js, which in turn needs the maplibregl
// global — the "map-scripts" define loads both in that order. A page that loads a
// map module without it throws a ReferenceError at map-init time and renders a
// blank container — no build error, no failing Go test, and nothing in the CSP
// report. Since map pages span all three surfaces, pin the include structurally
// rather than trusting the next one to remember it.
func TestMapPagesLoadTheMapStack(t *testing.T) {
t.Parallel()
root := moduleRoot(t)
@@ -89,22 +114,40 @@ func TestMapPagesLoadBasemapFirst(t *testing.T) {
src := string(b)
rel, _ := filepath.Rel(root, path)
base := strings.Index(src, "/static/basemap.js")
// base.html defines the shared blocks; it is not a page that renders a map.
if rel == filepath.Join("internal", "web", "templates", "base.html") {
return nil
}
stack := strings.Index(src, `{{template "map-scripts"}}`)
for _, dep := range []string{"/static/meshmap.js", "/static/regionmap.js"} {
at := strings.Index(src, dep)
if at < 0 {
continue
}
if base < 0 {
t.Errorf("%s loads %s but not /static/basemap.js, which defines the "+
"meshBaseLayers it calls", rel, dep)
if stack < 0 {
t.Errorf(`%s loads %s but not {{template "map-scripts"}}, which loads the `+
`maplibregl global and the meshCreateMap it calls`, rel, dep)
continue
}
if base > at {
t.Errorf("%s loads /static/basemap.js after %s; it must come first so "+
"meshBaseLayers is defined when the map initializes", rel, dep)
if stack > at {
t.Errorf(`%s loads {{template "map-scripts"}} after %s; it must come first so `+
`meshCreateMap is defined when the map initializes`, rel, dep)
}
}
// Terra Draw is only shipped where a shape is actually drawn, so the page that
// draws one has to ask for it explicitly.
if strings.Contains(src, "initRegionArea(") &&
!strings.Contains(src, `{{template "map-draw-scripts"}}`) {
t.Errorf(`%s calls initRegionArea but does not include {{template "map-draw-scripts"}}, `+
`so terraDraw is undefined when the editor starts`, rel)
}
// ...and a page that does not draw should not pay for it.
if !strings.Contains(src, "initRegionArea(") &&
strings.Contains(src, `{{template "map-draw-scripts"}}`) {
t.Errorf("%s ships Terra Draw but draws nothing; drop the include", rel)
}
return nil
})
if err != nil {
@@ -112,21 +155,41 @@ func TestMapPagesLoadBasemapFirst(t *testing.T) {
}
}
// TestBasemapTileURLOmitsEmptyKey guards the one branch in basemap.js that a Go
// test can reach: the tile URL must not carry a "?key=" when no key is configured.
func TestBasemapTileURLOmitsEmptyKey(t *testing.T) {
// TestBasemapKeysEveryCartoRequest guards the branches in basemap.js that a Go test
// can reach.
//
// The key has to be attached by a transformRequest rather than written into the
// style URL: a CARTO style names its tiles, sprite and glyphs as absolute URLs that
// carry no key of their own, so keying only the style leaves every request that
// actually fetches map data unkeyed — and CARTO answer those with watermarked
// output rather than an error, which is exactly the kind of failure nobody notices.
func TestBasemapKeysEveryCartoRequest(t *testing.T) {
t.Parallel()
root := moduleRoot(t)
b, err := os.ReadFile(filepath.Join(root, "internal/web/static/basemap.js"))
b, err := os.ReadFile(filepath.Join(moduleRoot(t), "internal/web/static/basemap.js"))
if err != nil {
t.Fatalf("read basemap.js: %v", err)
}
src := string(b)
if !strings.Contains(src, `key ? url + "?key=" + encodeURIComponent(key) : url`) {
t.Fatal("basemap.js no longer guards the key parameter on a non-empty key; " +
"an unset MESHTENDER_CARTO_KEY must omit ?key= entirely, not send it empty")
if !strings.Contains(src, "transformRequest: transformRequest") {
t.Error("basemap.js no longer registers a transformRequest on the map, so the style's " +
"tile/sprite/glyph requests would go out unkeyed")
}
if !strings.Contains(src, `if (!key) return { url: url };`) {
t.Error("basemap.js no longer short-circuits on an empty key; an unset " +
"MESHTENDER_CARTO_KEY must omit ?key= entirely, not send it empty")
}
if !strings.Contains(src, `getAttribute("data-carto-key")`) {
t.Fatal("basemap.js no longer reads the key from <html data-carto-key>")
t.Error("basemap.js no longer reads the key from <html data-carto-key>")
}
if !strings.Contains(src, `getAttribute("data-maplibre-worker")`) {
t.Error("basemap.js no longer reads the worker URL from <html data-maplibre-worker>")
}
// The raster tiles are deprecated upstream; a .png tile URL here means someone
// reintroduced them, and they would be blocked by the CSP now that img-src no
// longer names CARTO.
if strings.Contains(src, "basemaps.cartocdn.com/rastertiles") || strings.Contains(src, "{z}/{x}/{y}") {
t.Error("basemap.js references CARTO raster tiles, which are deprecated and no longer " +
"allowed by the CSP (img-src is 'self')")
}
}
+16 -3
View File
@@ -20,12 +20,25 @@ type nonceCtxKey struct{}
// a per-request nonce (so inline scripts must be nonce'd and inline on*= handlers
// are rejected — the guard against injected script). Styles keep 'unsafe-inline'
// (low XSS risk, and avoids nonce'ing every style= attribute + htmx's indicator
// style). The only external resource is CARTO map tiles (images).
// style). The only external resource is the CARTO basemap.
//
// That basemap is why connect-src, not img-src, names CARTO: the vector basemaps
// that replaced their deprecated raster tiles are fetched — the style JSON, the
// .mvt tiles, the sprite and the glyph PBFs all go out as XHR/fetch, and none of
// them is an image load. img-src stays 'self' as a result.
//
// worker-src is spelled out rather than left to fall through to default-src:
// MapLibre runs its tile decoding in a worker, and the reason we vendor its CSP
// build is that the default bundle spawns that worker from a blob: URL, which
// would mean allowing blob: here. The CSP build loads it from a same-origin URL
// instead (see basemap.js), so 'self' is enough — don't "fix" a future worker
// error by adding blob:; check that data-maplibre-worker is reaching the page.
var cspDirectives = strings.Join([]string{
"default-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://*.basemaps.cartocdn.com",
"connect-src 'self'",
"img-src 'self' data:",
"connect-src 'self' https://*.basemaps.cartocdn.com",
"worker-src 'self'",
"font-src 'self' data:",
"frame-ancestors 'none'",
"base-uri 'self'",
+63 -23
View File
@@ -173,41 +173,81 @@ code.pubkey {
border-left: 0;
}
/* ---------- Leaflet map (dark) ---------- */
/* ---------- MapLibre map (dark) ---------- */
#map {
height: 360px;
border-radius: var(--tblr-border-radius);
border: 1px solid var(--tblr-border-color);
}
.leaflet-container { background: #14171c; }
/* Scoped under .leaflet-popup so these beat Leaflet's own defaults, which load
after app.css (leaflet.css is pulled in per-page in the map card body). */
.leaflet-popup .leaflet-popup-content-wrapper,
.leaflet-popup .leaflet-popup-tip {
/* Painted behind the WebGL canvas, so it shows while tiles are still in flight
rather than a white flash on a dark page. */
.maplibregl-map { background: #14171c; }
/* Scoped under .maplibregl-popup so these beat MapLibre's own defaults, which load
after app.css (maplibre-gl.css is pulled in per-page in the map card body). */
.maplibregl-popup .maplibregl-popup-content {
background: var(--tblr-bg-surface);
color: var(--tblr-body-color);
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
}
.leaflet-popup .leaflet-popup-content { color: var(--tblr-body-color); }
.leaflet-container .leaflet-bar a,
.leaflet-container .leaflet-control-zoom a {
.maplibregl-popup-anchor-top .maplibregl-popup-tip { border-bottom-color: var(--tblr-bg-surface); }
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip { border-top-color: var(--tblr-bg-surface); }
.maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--tblr-bg-surface); }
.maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--tblr-bg-surface); }
.maplibregl-popup-close-button { color: var(--tblr-body-color); }
/* Controls: the zoom buttons, plus our own two button groups (the Dark/Light
basemap switch and the region-editor draw tools, both built in JS — see
basemap.js and regionmap.js). MapLibre's default control is a white box, which
is wrong on every page we render.
Everything here is scoped under .maplibregl-map purely to win the cascade:
maplibre-gl.css is pulled in per-page in the map card body, so it loads *after*
app.css and an equally specific rule of ours loses to it. The tell when this is
missing is subtle — the buttons we style by state still look right, and only the
unpressed ones stay white. */
.maplibregl-map .maplibregl-ctrl-group {
background: var(--tblr-bg-surface);
color: var(--tblr-body-color);
border-bottom-color: var(--tblr-border-color);
}
.leaflet-container .leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary); }
/* Base-layer (Dark/Light) switcher: match the dark UI so the native radios render
correctly. On the default white box + dark color-scheme, an unselected radio
shows as a black dot; a dark box fixes that, and accent-color tints the selected
one. Scoped under .leaflet-container to beat leaflet.css (loaded after app.css). */
.leaflet-container .leaflet-control-layers {
background: var(--tblr-bg-surface);
color: var(--tblr-body-color);
border-color: var(--tblr-border-color);
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
}
.leaflet-container .leaflet-control-layers-list label { margin: 0; color: var(--tblr-body-color); }
.leaflet-container .leaflet-control-layers-selector { accent-color: var(--tblr-primary); }
.maplibregl-map .maplibregl-ctrl-group button { color: var(--tblr-body-color); }
/* The zoom glyphs are background-image SVGs with a hard-coded #333 fill — fine on
MapLibre's white control, invisible on our dark one, and not restyleable because
the color lives inside the data: URI. Inverting the icon is the way to reach it. */
.maplibregl-map .maplibregl-ctrl-group button .maplibregl-ctrl-icon { filter: invert(1); }
.maplibregl-map .maplibregl-ctrl-group button + button { border-top-color: var(--tblr-border-color); }
.maplibregl-map .maplibregl-ctrl-group button:not(:disabled):hover { background: var(--tblr-bg-surface-secondary); }
/* MapLibre's icon buttons are square and unlabeled; ours carry text, so they need
width and horizontal padding or the label is clipped to a 29px box. */
.maplibregl-map .mesh-basemap-ctrl button,
.maplibregl-map .mesh-draw-ctrl button {
width: auto;
min-width: 4.5rem;
padding: 0 0.5rem;
font-size: 0.75rem;
line-height: 1.75rem;
}
/* aria-pressed is the state, so it is also what styles the active button — there
is no second class to keep in sync. */
.maplibregl-map .mesh-basemap-ctrl button[aria-pressed="true"],
.maplibregl-map .mesh-draw-ctrl button[aria-pressed="true"] {
background: var(--tblr-primary);
color: #fff;
}
.maplibregl-map .mesh-draw-ctrl button:disabled { opacity: 0.45; }
/* The attribution CARTO require stays visible; only its chrome is themed. It sits
over the map, not the page, so it is deliberately NOT painted in theme tokens:
the same strip has to stay legible over both the near-black Dark Matter basemap
and the near-white Voyager one, which a token that only knows the page theme
cannot do. A dark scrim with near-white text reads on both. */
.maplibregl-map .maplibregl-ctrl.maplibregl-ctrl-attrib {
/* Both class names, because upstream's own rule is .maplibregl-ctrl.maplibregl-
ctrl-attrib — matching only one of them ties on specificity and loses, which
shows up as white-on-white text rather than as nothing happening. */
background: rgba(0, 0, 0, 0.7);
color: #f4f6fa;
}
.maplibregl-map .maplibregl-ctrl-attrib a,
.maplibregl-map .maplibregl-ctrl-attrib a:hover { color: #f4f6fa; }
/* Profile list on the org config page: a long list scrolls inside its card
instead of pushing the location-preview map off the page. Rows are compact —
+238 -38
View File
@@ -1,48 +1,248 @@
// basemap.js — the CARTO basemap layers shared by every Leaflet map we render.
// basemap.js — the CARTO basemaps shared by every map we render, and the small
// MapLibre helpers built on top of them.
//
// meshmap.js and regionmap.js each used to carry their own copy of this, which meant
// the tile URL, the zoom ceiling and the localStorage key all had to be changed in
// two places to stay consistent. One definition here; both call it.
// CARTO deprecated their raster tiles; the replacement is a MapLibre GL *vector*
// style, which Leaflet cannot render. So the basemap decides the engine: every map
// in the app is MapLibre GL, and this file is the one place that knows which styles
// we use, how the API key is attached, and how the dark/light choice is remembered.
//
// Load this before meshmap.js / regionmap.js on any page with a map.
// Load this after maplibre-gl.js and before meshmap.js / regionmap.js.
// cartoTileURL builds a CARTO raster tile URL for the given basemap style.
//
// CARTO watermark tiles served without an API key. The server puts the key on
// <html data-carto-key> (see web.Renderer); when it is absent the key parameter is
// left off entirely rather than sent empty.
function cartoTileURL(style) {
var key = document.documentElement.getAttribute("data-carto-key") || "";
var url = "https://{s}.basemaps.cartocdn.com/" + style + "/{z}/{x}/{y}{r}.png";
return key ? url + "?key=" + encodeURIComponent(key) : url;
}
(function () {
// The two CARTO GL styles, addressed on the tiles. host so that one CSP source
// (https://*.basemaps.cartocdn.com) covers the style JSON as well as the tiles,
// sprite and glyphs it points at — the bare basemaps.cartocdn.com would need a
// second entry to match.
var STYLES = {
dark: "https://tiles.basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
light: "https://tiles.basemaps.cartocdn.com/gl/voyager-gl-style/style.json",
};
// meshBaseLayers adds the dark (default) and a light basemap to a map, with a
// layers control to toggle between them. The choice is remembered in localStorage
// so it carries across maps and pages. Dark matches the UI; the light layer (CARTO
// Voyager) reads better in bright conditions and for some eyes. The control is
// left expanded — Leaflet's collapsed toggle needs an icon asset we don't bundle.
function meshBaseLayers(map) {
// Fresh options per layer: Leaflet copies them onto the layer, but a plugin that
// reaches for the object we passed shouldn't be able to affect both layers.
function opts() {
return { maxZoom: 20, subdomains: "abcd", attribution: "&copy; OpenStreetMap &copy; CARTO" };
// Which basemap the operator last chose, remembered across maps and pages.
var STORAGE_KEY = "mt_map_base";
// Fontstack for any label we draw ourselves (cluster counts). Glyphs come from
// the CARTO glyph host the style declares, so this has to be a face CARTO serves.
var FONT = ["Open Sans Bold"];
// MESHTENDER_MAPS is the registry of live maps, keyed by container element id.
// MapLibre paints to a WebGL canvas, so — unlike the Leaflet DOM this replaced —
// there are no per-shape nodes for a browser test to select. Registering the map
// (and, on the area editor, its draw instance) gives the e2e suite a stable,
// deliberate way to assert what is actually on the map.
window.MESHTENDER_MAPS = window.MESHTENDER_MAPS || {};
function cartoKey() {
return document.documentElement.getAttribute("data-carto-key") || "";
}
var dark = L.tileLayer(cartoTileURL("dark_all"), opts());
var light = L.tileLayer(cartoTileURL("rastertiles/voyager"), opts());
var pref = null;
try {
pref = localStorage.getItem("mt_map_base");
} catch (e) {
/* storage unavailable (private mode); fall back to dark */
// transformRequest attaches the CARTO API key to every request a style makes.
//
// It has to happen here rather than on the style URL: the style JSON names its
// tiles, sprite and glyphs as absolute URLs that carry no key, so keying only the
// style would leave the four requests that actually fetch map data unkeyed. With
// no key configured the parameter is left off entirely rather than sent empty.
function transformRequest(url) {
var key = cartoKey();
if (!key) return { url: url };
if (url.indexOf("//") === -1) return { url: url };
var host = url.split("/")[2] || "";
if (host !== "cartocdn.com" && host.slice(-13) !== ".cartocdn.com") return { url: url };
return { url: url + (url.indexOf("?") === -1 ? "?" : "&") + "key=" + encodeURIComponent(key) };
}
(pref === "light" ? light : dark).addTo(map);
L.control.layers({ Dark: dark, Light: light }, null, { position: "topright", collapsed: false }).addTo(map);
map.on("baselayerchange", function (e) {
function storedBase() {
try {
localStorage.setItem("mt_map_base", e.name === "Light" ? "light" : "dark");
return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark";
} catch (e) {
return "dark"; // storage unavailable (private mode); dark matches the UI
}
}
function storeBase(name) {
try {
localStorage.setItem(STORAGE_KEY, name);
} catch (e) {
/* ignore */
}
});
}
}
// The worker is a same-origin fingerprinted asset whose URL the server puts on
// <html data-maplibre-worker>. This is why we vendor MapLibre's CSP build: the
// default bundle spawns its worker from a blob:, which would force blob: into the
// CSP's worker-src. Setting it is global and must happen before the first Map.
var workerSet = false;
function ensureWorker() {
if (workerSet) return;
var url = document.documentElement.getAttribute("data-maplibre-worker");
if (url) maplibregl.setWorkerUrl(url);
workerSet = true;
}
// BasemapControl is the dark/light switch. MapLibre ships no layer switcher, so
// this replaces Leaflet's L.control.layers — same two choices, same remembered
// preference. It is left expanded (two visible buttons) for the same reason the
// Leaflet control was: a collapsed toggle needs an icon asset we don't bundle.
function BasemapControl(onChange) {
this._onChange = onChange;
}
BasemapControl.prototype.onAdd = function (map) {
var self = this;
this._map = map;
var el = document.createElement("div");
el.className = "maplibregl-ctrl maplibregl-ctrl-group mesh-basemap-ctrl";
this._buttons = {};
["dark", "light"].forEach(function (name) {
var b = document.createElement("button");
b.type = "button";
b.textContent = name === "dark" ? "Dark" : "Light";
b.setAttribute("data-basemap", name);
b.setAttribute("aria-pressed", String(storedBase() === name));
b.addEventListener("click", function () {
if (storedBase() === name) return;
storeBase(name);
self.sync();
self._onChange(name);
});
el.appendChild(b);
self._buttons[name] = b;
});
this._el = el;
return el;
};
BasemapControl.prototype.sync = function () {
var current = storedBase();
for (var name in this._buttons) {
if (Object.prototype.hasOwnProperty.call(this._buttons, name)) {
this._buttons[name].setAttribute("aria-pressed", String(name === current));
}
}
};
BasemapControl.prototype.onRemove = function () {
if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
this._map = null;
};
// meshCreateMap builds the MapLibre map every page in the app uses. opts:
// scrollZoom — false to require a deliberate zoom gesture (page-embedded maps);
// interactive — false for a completely static map;
// overlays — function(map) that adds this page's sources and layers.
//
// overlays is a callback rather than something the caller runs once because
// switching basemap calls map.setStyle(), and **setStyle discards every source and
// layer the page added**. Handing the work to meshCreateMap means it can be re-run
// after each swap; a caller that added its layers inline would silently lose them
// the first time someone pressed Light.
window.meshCreateMap = function (elId, opts) {
opts = opts || {};
ensureWorker();
var map = new maplibregl.Map({
container: elId,
style: STYLES[storedBase()],
transformRequest: transformRequest,
interactive: opts.interactive !== false,
// The attribution comes from the style itself, which is how CARTO ship the
// credit they require ("(c) CARTO, (c) OpenStreetMap contributors", with the
// links). Kept non-compact so it is always visible rather than behind an
// info button. Adding our own string on top would only duplicate it.
attributionControl: { compact: false },
// No rotation or pitch anywhere in the app: these are flat reference maps,
// and a map that has quietly rotated is a map you can't read a bearing off.
pitchWithRotate: false,
rollEnabled: false,
dragRotate: false,
// Match the old Leaflet behavior of painting once, in the final position:
// fitBounds/setCenter calls below all pass animate:false, and this stops the
// basemap itself fading in over the top.
fadeDuration: 0,
});
map.touchZoomRotate.disableRotation();
map.keyboard.disableRotation();
if (opts.scrollZoom === false) map.scrollZoom.disable();
if (opts.interactive !== false) {
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-left");
var control = new BasemapControl(function (name) {
map.setStyle(STYLES[name], { diff: false });
// style.load, not styledata: styledata fires repeatedly through a style's
// life (including before its sources are ready, where addLayer throws),
// whereas style.load fires once the swapped-in style is actually loaded —
// which is the moment the overlays can go back on.
map.once("style.load", function () {
if (opts.overlays) opts.overlays(map);
});
});
map.addControl(control, "top-right");
}
if (opts.overlays) {
map.on("load", function () {
opts.overlays(map);
});
}
window.MESHTENDER_MAPS[elId] = { map: map };
return map;
};
// meshFont is the fontstack for labels we draw ourselves.
window.meshFont = FONT;
// meshFrame points the map at a set of [lon, lat] coordinates, once and without
// animating, so the map paints in its final position with no fit/zoom flash.
// opts:
// padding — pixels of breathing room around the fitted box. Pixels rather
// than degrees on purpose: a geographic pad scales with the area,
// so a large region would zoom straight back out;
// pad — an extra *geographic* nudge (as a fraction of the box) applied
// before fitting, for the one case that wants it — see
// PRIMARY_CONTEXT in regionmap.js;
// pointZoom — zoom for a degenerate box (a single point can't be fit).
//
// With no coordinates at all it falls back to the whole world, which is what both
// callers did before.
window.meshFrame = function (map, coords, opts) {
opts = opts || {};
if (!coords || !coords.length) {
map.jumpTo({ center: [0, 20], zoom: 1 });
return;
}
var w = coords[0][0], e = coords[0][0], s = coords[0][1], n = coords[0][1];
coords.forEach(function (c) {
if (c[0] < w) w = c[0];
if (c[0] > e) e = c[0];
if (c[1] < s) s = c[1];
if (c[1] > n) n = c[1];
});
if (opts.pad) {
var dx = (e - w) * opts.pad, dy = (n - s) * opts.pad;
w -= dx; e += dx; s -= dy; n += dy;
}
if (w === e && s === n) {
map.jumpTo({ center: [w, s], zoom: opts.pointZoom || 11 });
return;
}
map.fitBounds([[w, s], [e, n]], { animate: false, padding: opts.padding || 24 });
};
// meshCoordsOf flattens any GeoJSON geometry to a flat array of [lon, lat] pairs,
// which is what meshFrame frames on. Used for region polygons, whose nesting depth
// differs between Polygon and MultiPolygon.
window.meshCoordsOf = function (geometry) {
var out = [];
(function walk(node) {
if (!node || !node.length) return;
if (typeof node[0] === "number") {
out.push([node[0], node[1]]);
return;
}
node.forEach(walk);
})(geometry && geometry.coordinates);
return out;
};
})();
+6 -4
View File
@@ -27,7 +27,7 @@
let data = null; // last config payload
let rows = []; // per-command row state: { line, runnable, statusEl }
let mapView = null; // Leaflet map instance (created lazily when the picker is shown)
let mapView = null; // MapLibre map instance (created lazily when the picker is shown)
let running = false; // a run (single or batch) is in progress
const consoleReady = () => !!(window.MeshConsole && window.MeshConsole.ready);
@@ -163,8 +163,10 @@
preview: loc.known ? { lat: loc.lat, lon: loc.lon } : undefined,
onPick: (lat, lon) => saveLocation(lat, lon),
});
} else if (mapView && mapView.invalidateSize) {
mapView.invalidateSize();
} else if (mapView) {
// The map was built while the panel was collapsed, so its container had no
// size; MapLibre needs telling once it does.
mapView.resize();
}
}
@@ -416,7 +418,7 @@
// Load the config the first time the panel is expanded (not before — it's opt-in).
panel.addEventListener("shown.bs.collapse", () => {
if (!data) load(false);
if (mapView && mapView.invalidateSize) mapView.invalidateSize();
if (mapView) mapView.resize();
});
document.addEventListener("mesh:ready", () => {
updateConnectBtn();
-258
View File
@@ -1,258 +0,0 @@
/* @preserve
* Leaflet-Geoman (leaflet-geoman-free) 2.20.0 stylesheet, https://geoman.io
* (c) 2017 Sumit Kumar, MIT License.
* Banner restored by hand: the upstream esbuild bundle ships none.
* Vendored (self-hosted per CSP); see THIRD-PARTY-NOTICES.md.
*/
/* src/css/layers.css */
.marker-icon {
background-color: #ffffff;
border: 1px solid #3388ff;
border-radius: 50%;
margin: -8px 0 0 -8px !important;
width: 14px !important;
height: 14px !important;
outline: 0;
transition: opacity ease 0.3s;
}
.marker-icon-middle {
opacity: 0.7;
margin: -6px 0 0 -6px !important;
width: 10px !important;
height: 10px !important;
}
.leaflet-pm-draggable {
cursor: move !important;
}
.cursor-marker {
cursor: crosshair;
pointer-events: none;
opacity: 0;
}
.cursor-marker.visible {
opacity: 1 !important;
}
.geoman-draw-cursor {
cursor: crosshair;
}
.geoman-draw-cursor .leaflet-interactive {
cursor: crosshair;
}
.rect-style-marker,
.rect-start-marker {
opacity: 0;
}
.rect-style-marker.visible,
.rect-start-marker.visible {
opacity: 1 !important;
}
.vertexmarker-disabled {
opacity: 0.7;
}
.pm-text-marker {
width: 0;
height: 0;
}
.pm-textarea {
box-sizing: content-box;
background-color: #fff;
color: #000;
resize: none;
border: none;
outline: 0;
cursor: pointer;
border-radius: 3px;
padding-left: 7px;
padding-bottom: 0;
padding-top: 4px;
}
.leaflet-pm-draggable .pm-textarea {
cursor: move;
}
.pm-textarea:focus,
.pm-textarea:focus-within,
.pm-textarea:focus-visible,
.pm-textarea:active {
border: 2px solid #000;
outline: 0;
}
.pm-textarea.pm-disabled {
border: none;
user-select: none;
}
.pm-textarea.pm-hasfocus {
cursor: auto;
}
.leaflet-pm-touch-hint {
position: absolute;
top: var(--pm-touch-hint-top, 10px);
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.95);
color: #333;
padding: 8px 16px;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
z-index: 1000;
font-size: 14px;
pointer-events: none;
}
/* src/css/controls.css */
.leaflet-pm-toolbar {
}
.leaflet-pm-toolbar .leaflet-buttons-control-button {
padding: 5px;
box-sizing: border-box;
position: relative;
z-index: 3;
}
.leaflet-pm-toolbar .leaflet-pm-actions-container a.leaflet-pm-action:first-child:not(.pos-right),
.leaflet-pm-toolbar .leaflet-pm-actions-container a.leaflet-pm-action:last-child.pos-right {
border-radius: 0;
}
.leaflet-pm-toolbar .button-container a.leaflet-buttons-control-button {
border-radius: 0;
}
.leaflet-pm-toolbar .button-container:last-child a.leaflet-buttons-control-button {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
.leaflet-pm-toolbar .button-container:first-child a.leaflet-buttons-control-button {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-pm-toolbar .button-container:last-child a.leaflet-buttons-control-button {
border-bottom: none;
}
.leaflet-pm-toolbar .control-fa-icon {
font-size: 19px;
line-height: 24px;
}
.leaflet-pm-toolbar .control-icon {
width: 100%;
height: 100%;
box-sizing: border-box;
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
}
.leaflet-pm-toolbar .leaflet-pm-icon-marker {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">%0A <!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->%0A <title>Atoms/Icons/Tools/Marker</title>%0A <desc>Created with Sketch.</desc>%0A <defs>%0A <path d="M15.5,24.8782959 C15.2909201,24.8772219 15.1744857,24.8467817 14.6590866,24.2354163 C10.2196955,19.4118054 8,15.5014392 8,12.5043177 C8,8.35979746 11.3578644,5 15.5,5 C19.6421356,5 23,8.35979746 23,12.5043177 C23,17 18.2878217,21.9268378 16.3336601,24.2440186 C15.8224622,24.8501802 15.7090799,24.8793699 15.5,24.8782959 Z M15.5,15.5326948 C17.275201,15.5326948 18.7142857,14.1180004 18.7142857,12.3728864 C18.7142857,10.6277723 17.275201,9.21307792 15.5,9.21307792 C13.724799,9.21307792 12.2857143,10.6277723 12.2857143,12.3728864 C12.2857143,14.1180004 13.724799,15.5326948 15.5,15.5326948 Z" id="path-1"></path>%0A </defs>%0A <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">%0A <g id="Atoms/Icons/Tools/Marker" transform="translate(-3.000000, -3.000000)">%0A <mask id="mask-2" fill="white">%0A <use xlink:href="%23path-1"></use>%0A </mask>%0A <use id="Mask" fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23path-1"></use>%0A </g>%0A </g>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-polygon {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="polygon-a" d="M19.4206892,9.16509725 C19.1523681,8.66992914 19,8.10275831 19,7.5 C19,5.56700338 20.5670034,4 22.5,4 C24.4329966,4 26,5.56700338 26,7.5 C26,9.26323595 24.6961471,10.7219407 23,10.9645556 L23,19.0354444 C24.6961471,19.2780593 26,20.736764 26,22.5 C26,24.4329966 24.4329966,26 22.5,26 C20.736764,26 19.2780593,24.6961471 19.0354444,23 L10.9645556,23 C10.7219407,24.6961471 9.26323595,26 7.5,26 C5.56700338,26 4,24.4329966 4,22.5 C4,20.5670034 5.56700338,19 7.5,19 C8.10275831,19 8.66992914,19.1523681 9.16509725,19.4206892 L19.4206892,9.16509725 Z M20.8349073,10.5793063 L10.5793108,20.8349027 C10.6086731,20.8890888 10.6366469,20.9441372 10.6631844,21 L19.3368156,21 C19.6825775,20.272154 20.272154,19.6825775 21,19.3368156 L21,10.6631844 C20.9441372,10.6366469 20.8890888,10.6086731 20.8349027,10.5793108 Z M22.5,9 C23.3284271,9 24,8.32842712 24,7.5 C24,6.67157288 23.3284271,6 22.5,6 C21.6715729,6 21,6.67157288 21,7.5 C21,8.32842712 21.6715729,9 22.5,9 Z M22.5,24 C23.3284271,24 24,23.3284271 24,22.5 C24,21.6715729 23.3284271,21 22.5,21 C21.6715729,21 21,21.6715729 21,22.5 C21,23.3284271 21.6715729,24 22.5,24 Z M7.5,24 C8.32842712,24 9,23.3284271 9,22.5 C9,21.6715729 8.32842712,21 7.5,21 C6.67157288,21 6,21.6715729 6,22.5 C6,23.3284271 6.67157288,24 7.5,24 Z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(-3 -3)">%0A <mask id="polygon-b" fill="%23fff">%0A <use xlink:href="%23polygon-a"/>%0A </mask>%0A <use fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23polygon-a"/>%0A <g fill="%235B5B5B" mask="url(%23polygon-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-polyline {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="line-a" d="M9.16509725,19.4206892 L18.4206892,10.1650973 C18.1523681,9.66992914 18,9.10275831 18,8.5 C18,6.56700338 19.5670034,5 21.5,5 C23.4329966,5 25,6.56700338 25,8.5 C25,10.4329966 23.4329966,12 21.5,12 C20.8972417,12 20.3300709,11.8476319 19.8349027,11.5793108 L10.5793108,20.8349027 C10.8476319,21.3300709 11,21.8972417 11,22.5 C11,24.4329966 9.43299662,26 7.5,26 C5.56700338,26 4,24.4329966 4,22.5 C4,20.5670034 5.56700338,19 7.5,19 C8.10275831,19 8.66992914,19.1523681 9.16509725,19.4206892 Z M21.5,10 C22.3284271,10 23,9.32842712 23,8.5 C23,7.67157288 22.3284271,7 21.5,7 C20.6715729,7 20,7.67157288 20,8.5 C20,9.32842712 20.6715729,10 21.5,10 Z M7.5,24 C8.32842712,24 9,23.3284271 9,22.5 C9,21.6715729 8.32842712,21 7.5,21 C6.67157288,21 6,21.6715729 6,22.5 C6,23.3284271 6.67157288,24 7.5,24 Z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(-3 -3)">%0A <mask id="line-b" fill="%23fff">%0A <use xlink:href="%23line-a"/>%0A </mask>%0A <use fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23line-a"/>%0A <g fill="%235B5B5B" mask="url(%23line-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-circle {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">%0A <!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->%0A <title>Atoms/Icons/Tools/Circle</title>%0A <desc>Created with Sketch.</desc>%0A <defs>%0A <path d="M18.2897751,6.78602275 C18.8924131,6.29464981 19.661797,6 20.5,6 C22.4329966,6 24,7.56700338 24,9.5 C24,10.338203 23.7053502,11.1075869 23.2139772,11.7102249 C23.719599,12.8712053 24,14.1528571 24,15.5 C24,20.7467051 19.7467051,25 14.5,25 C9.25329488,25 5,20.7467051 5,15.5 C5,10.2532949 9.25329488,6 14.5,6 C15.8471429,6 17.1287947,6.28040098 18.2897751,6.78602275 Z M17.1504228,8.4817586 C16.3263581,8.17039236 15.4330777,8 14.5,8 C10.3578644,8 7,11.3578644 7,15.5 C7,19.6421356 10.3578644,23 14.5,23 C18.6421356,23 22,19.6421356 22,15.5 C22,14.5669223 21.8296076,13.6736419 21.5182414,12.8495772 C21.1960383,12.9473968 20.8541622,13 20.5,13 C18.5670034,13 17,11.4329966 17,9.5 C17,9.14583778 17.0526032,8.80396169 17.1504228,8.4817586 Z M14.5,17 C13.6715729,17 13,16.3284271 13,15.5 C13,14.6715729 13.6715729,14 14.5,14 C15.3284271,14 16,14.6715729 16,15.5 C16,16.3284271 15.3284271,17 14.5,17 Z M20.5,11 C21.3284271,11 22,10.3284271 22,9.5 C22,8.67157288 21.3284271,8 20.5,8 C19.6715729,8 19,8.67157288 19,9.5 C19,10.3284271 19.6715729,11 20.5,11 Z" id="path-1"></path>%0A </defs>%0A <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">%0A <g id="Atoms/Icons/Tools/Circle" transform="translate(-3.000000, -3.000000)">%0A <mask id="mask-2" fill="white">%0A <use xlink:href="%23path-1"></use>%0A </mask>%0A <use id="Mask" fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23path-1"></use>%0A <g id="Atoms/Color/Grey" mask="url(%23mask-2)" fill="%235B5B5B">%0A <rect id="Rectangle" x="0" y="0" width="30" height="30"></rect>%0A </g>%0A </g>%0A </g>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-circle-marker {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A%0A<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" stroke="%235B5B5B" stroke-width="8"%0A fill="none">%0A<circle cx="50" cy="50" r="35"/>%0A <circle cx="50" cy="50" r="3" fill="%235B5B5B"/>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-rectangle {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="rectangle-a" d="M23,10.9645556 L23,19.0354444 C24.6961471,19.2780593 26,20.736764 26,22.5 C26,24.4329966 24.4329966,26 22.5,26 C20.736764,26 19.2780593,24.6961471 19.0354444,23 L10.9645556,23 C10.7219407,24.6961471 9.26323595,26 7.5,26 C5.56700338,26 4,24.4329966 4,22.5 C4,20.736764 5.30385293,19.2780593 7,19.0354444 L7,10.9645556 C5.30385293,10.7219407 4,9.26323595 4,7.5 C4,5.56700338 5.56700338,4 7.5,4 C9.26323595,4 10.7219407,5.30385293 10.9645556,7 L19.0354444,7 C19.2780593,5.30385293 20.736764,4 22.5,4 C24.4329966,4 26,5.56700338 26,7.5 C26,9.26323595 24.6961471,10.7219407 23,10.9645556 Z M21,10.6631844 C20.272154,10.3174225 19.6825775,9.72784598 19.3368156,9 L10.6631844,9 C10.3174225,9.72784598 9.72784598,10.3174225 9,10.6631844 L9,19.3368156 C9.72784598,19.6825775 10.3174225,20.272154 10.6631844,21 L19.3368156,21 C19.6825775,20.272154 20.272154,19.6825775 21,19.3368156 L21,10.6631844 Z M7.5,9 C8.32842712,9 9,8.32842712 9,7.5 C9,6.67157288 8.32842712,6 7.5,6 C6.67157288,6 6,6.67157288 6,7.5 C6,8.32842712 6.67157288,9 7.5,9 Z M22.5,9 C23.3284271,9 24,8.32842712 24,7.5 C24,6.67157288 23.3284271,6 22.5,6 C21.6715729,6 21,6.67157288 21,7.5 C21,8.32842712 21.6715729,9 22.5,9 Z M22.5,24 C23.3284271,24 24,23.3284271 24,22.5 C24,21.6715729 23.3284271,21 22.5,21 C21.6715729,21 21,21.6715729 21,22.5 C21,23.3284271 21.6715729,24 22.5,24 Z M7.5,24 C8.32842712,24 9,23.3284271 9,22.5 C9,21.6715729 8.32842712,21 7.5,21 C6.67157288,21 6,21.6715729 6,22.5 C6,23.3284271 6.67157288,24 7.5,24 Z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(-3 -3)">%0A <mask id="rectangle-b" fill="%23fff">%0A <use xlink:href="%23rectangle-a"/>%0A </mask>%0A <use fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23rectangle-a"/>%0A <g fill="%235B5B5B" mask="url(%23rectangle-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-delete {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">%0A <!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->%0A <title>Atoms/Icons/Tools/Eraser</title>%0A <desc>Created with Sketch.</desc>%0A <defs>%0A <path d="M17.7874219,18.4812552 L11.6480079,13.3498184 L6.40466009,19.3816001 L10.5539156,22.9884929 L13.86934,22.9884929 L17.7874219,18.4812552 Z M16.5074252,22.9884929 L26.0000002,22.9884929 L26.0000002,24.9884929 L10.0000002,24.9884929 L9.80708313,24.9884929 L5.09254204,20.8910192 C4.25891285,20.1663564 4.17057814,18.9031112 4.89524093,18.069482 L16.0482444,5.23941916 C16.7729072,4.40578998 18.0361525,4.31745526 18.8697816,5.04211806 L24.9074583,10.2905903 C25.7410875,11.0152531 25.8294222,12.2784983 25.1047594,13.1121275 L16.5074252,22.9884929 Z" id="path-1"></path>%0A </defs>%0A <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">%0A <g id="Atoms/Icons/Tools/Eraser" transform="translate(-3.000000, -3.000000)">%0A <mask id="mask-2" fill="white">%0A <use xlink:href="%23path-1"></use>%0A </mask>%0A <use id="Combined-Shape" fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23path-1"></use>%0A </g>%0A </g>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-edit {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="edit_anchor-a" d="M13.5,11 C11.5670034,11 10,9.43299662 10,7.5 C10,5.56700338 11.5670034,4 13.5,4 C15.4329966,4 17,5.56700338 17,7.5 C17,9.43299662 15.4329966,11 13.5,11 Z M13.5,9 C14.3284271,9 15,8.32842712 15,7.5 C15,6.67157288 14.3284271,6 13.5,6 C12.6715729,6 12,6.67157288 12,7.5 C12,8.32842712 12.6715729,9 13.5,9 Z M12.0002889,7.52973893 C12.0125983,8.16273672 12.4170197,8.6996643 12.9807111,8.90767966 L3,15 L3,13 L12.0002889,7.52973893 Z M14.2172722,6.18228472 L19.453125,3 L22.6589355,3 L14.989102,7.68173885 C14.9962971,7.62216459 15,7.56151472 15,7.5 C15,6.93138381 14.6836098,6.4366645 14.2172722,6.18228472 Z M23.4434042,19.2851736 L20.1282799,19.2851736 L21.8729983,23.5349525 C21.9945296,23.8295773 21.8556546,24.1599209 21.5778734,24.2849208 L20.0414675,24.9545142 C19.7550613,25.0795141 19.4338738,24.9366704 19.3123426,24.6509518 L17.6544367,20.6154541 L14.9461873,23.4010151 C14.5852811,23.7721711 14,23.4860463 14,22.9992653 L14,9.57183533 C14,9.05933561 14.6225311,8.809492 14.946156,9.17008555 L23.8340292,18.3120179 C24.1925291,18.6613615 23.9279979,19.2851736 23.4434042,19.2851736 Z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(-3 -3)">%0A <mask id="edit_anchor-b" fill="%23fff">%0A <use xlink:href="%23edit_anchor-a"/>%0A </mask>%0A <use fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23edit_anchor-a"/>%0A <g fill="%235B5B5B" mask="url(%23edit_anchor-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-drag {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="move-a" d="M21,14 L21,10 L27,15 L21,20 L21,16 L16,16 L16,21 L20,21 L15,27 L10,21 L14,21 L14,16 L9,16 L9,20 L3,15 L9,10 L9,14 L14,14 L14,9 L10,9 L15,3 L20,9 L16,9 L16,14 L21,14 Z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(-3 -3)">%0A <mask id="move-b" fill="%23fff">%0A <use xlink:href="%23move-a"/>%0A </mask>%0A <use fill="%23D8D8D8" xlink:href="%23move-a"/>%0A <g fill="%235B5B5B" mask="url(%23move-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-cut {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">%0A <!-- Generator: Sketch 52.5 (67469) - http://www.bohemiancoding.com/sketch -->%0A <title>Atoms/Icons/Tools/Scissors</title>%0A <desc>Created with Sketch.</desc>%0A <defs>%0A <path d="M12.9691574,13.4939435 L21.0317032,5.54167013 L23.4649499,5.67722957 L17.0470713,14.5106816 L27.5660336,17.1333535 L25.7891944,18.8012588 L14.5854951,17.8987506 L13.6487955,19.188007 C13.794639,19.2650958 13.9367985,19.3534417 14.0741377,19.4532245 C15.6379648,20.5894114 15.9846357,22.7782052 14.8484488,24.3420324 C13.7122619,25.9058595 11.5234681,26.2525304 9.95964096,25.1163435 C8.39581384,23.9801565 8.04914296,21.7913627 9.18532986,20.2275356 C9.74587276,19.4560145 10.5626188,18.9807475 11.4341218,18.8336407 L12.6805656,17.1180579 L12.5239724,16.3747216 L11.9506932,15.3012391 L9.89310646,14.7882251 C9.13093796,15.2357261 8.19977854,15.3966447 7.27445355,15.1659352 C5.39887519,14.698301 4.25751094,12.7987519 4.72514515,10.9231736 C5.19277935,9.04759519 7.09232846,7.90623094 8.96790682,8.37386515 C10.8434852,8.84149935 11.9848494,10.7410485 11.5172152,12.6166268 C11.4761464,12.7813449 11.4240335,12.9404001 11.3618627,13.0931999 L12.9691574,13.4939435 Z M7.75829735,13.2253438 C8.56211664,13.4257584 9.37620912,12.9366023 9.57662378,12.132783 C9.77703844,11.3289637 9.28788233,10.5148713 8.48406303,10.3144566 C7.68024373,10.1140419 6.86615126,10.603198 6.6657366,11.4070173 C6.46532194,12.2108366 6.95447805,13.0249291 7.75829735,13.2253438 Z M10.8033639,21.4031061 C10.3164266,22.0733177 10.4649998,23.0113722 11.1352115,23.4983095 C11.8054231,23.9852467 12.7434776,23.8366735 13.2304148,23.1664619 C13.7173521,22.4962502 13.5687788,21.5581957 12.8985672,21.0712585 C12.2283556,20.5843212 11.2903011,20.7328945 10.8033639,21.4031061 Z" id="path-1"></path>%0A </defs>%0A <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">%0A <g id="Atoms/Icons/Tools/Scissors" transform="translate(-3.000000, -3.000000)">%0A <mask id="mask-2" fill="white">%0A <use xlink:href="%23path-1"></use>%0A </mask>%0A <use id="Mask" fill="%235B5B5B" fill-rule="nonzero" transform="translate(16.093194, 15.663351) rotate(-32.000000) translate(-16.093194, -15.663351) " xlink:href="%23path-1"></use>%0A </g>%0A </g>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-snapping {
background-image: url('data:image/svg+xml,<?xml version="1.0" encoding="UTF-8"?>%0A<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">%0A <!-- Generator: Sketch 57.1 (83088) - https://sketch.com -->%0A <title>Atoms/Icons/Tools/Magnet</title>%0A <desc>Created with Sketch.</desc>%0A <defs>%0A <path d="M21.9994759,10.9428183 L21.9999985,16.3710417 C22,16.6872007 22,17.0058278 22,17.3269411 C22,21.5646545 18.6421356,25 14.5,25 C10.3578644,25 7,21.5646545 7,17.3269411 L7.00087508,10.9907507 L11.0022808,10.9984125 C11.0017033,11.6980114 11.001247,12.4168248 11.0008992,13.1554887 L11,17.3269411 C11,19.3756809 12.5876841,21 14.5,21 C16.4123159,21 18,19.3756809 18,17.3269411 C18,15.0702032 17.9995696,12.9619668 17.998539,10.9910032 L21.9994759,10.9428183 Z M10,7 C10.5522847,7 11,7.44771525 11,8 L11,10 L7,10 L7,8 C7,7.44771525 7.44771525,7 8,7 L10,7 Z M21,7 C21.5522847,7 22,7.44771525 22,8 L22,10 L18,10 L18,8 C18,7.44771525 18.4477153,7 19,7 L21,7 Z" id="path-1"></path>%0A </defs>%0A <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">%0A <g id="Atoms/Icons/Tools/Magnet" transform="translate(-3.000000, -3.000000)">%0A <mask id="mask-2" fill="white">%0A <use xlink:href="%23path-1"></use>%0A </mask>%0A <use id="Mask" fill="%235B5B5B" fill-rule="nonzero" transform="translate(14.500000, 16.000000) rotate(45.000000) translate(-14.500000, -16.000000) " xlink:href="%23path-1"></use>%0A </g>%0A </g>%0A</svg>');
}
.leaflet-pm-toolbar .leaflet-pm-icon-rotate {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">%0A <defs>%0A <path id="rotate" d="M21.2,5.8c-0.1-0.2-0.2-0.3-0.3-0.5l-0.1-0.2c-0.1-0.2-0.2-0.3-0.3-0.5l-0.1-0.2c-0.1-0.2-0.2-0.3-0.4-0.5l-0.2-0.3l2.8-3.1L18,0.6l-4.6,0.1l0.5,4.5l0.5,4.5l3.2-3.6v0.1l0.1,0.2c0.1,0.1,0.1,0.2,0.2,0.2l0.1,0.2C18,7,18,7.1,18.1,7.2c0.3,0.7,0.6,1.4,0.7,2.1c0.2,1.4,0,2.9-0.6,4.2L18,13.9L17.9,14l-0.3,0.5l-0.1,0.2c-0.2,0.2-0.4,0.5-0.6,0.7c-0.5,0.5-1.1,1-1.7,1.3c-0.6,0.4-1.3,0.6-2.1,0.8c-0.7,0.1-1.5,0.2-2.2,0.1c-0.8-0.1-1.5-0.3-2.2-0.5c-0.7-0.3-1.3-0.7-1.9-1.2l-0.4-0.4l-0.2-0.3L6,15c-0.1-0.1-0.2-0.2-0.2-0.3l-0.3-0.4l-0.1-0.1l-0.2-0.4c0-0.1-0.1-0.1-0.1-0.2l-0.3-0.5l-0.1-0.2c-0.1-0.3-0.2-0.6-0.3-0.9c-0.2-0.8-0.3-1.6-0.3-2.4c0-0.2,0-0.3,0-0.5V8.9c0-0.2,0-0.3,0.1-0.4l0.1-0.6l0.2-0.6c0.3-0.8,0.7-1.5,1.2-2.2c0.5-0.7,1.1-1.3,1.8-1.8c0.2-0.1,0.3-0.4,0.1-0.6C7.5,2.6,7.4,2.5,7.3,2.5H7.1L7,2.6C6.1,3,5.4,3.6,4.7,4.2C4,4.9,3.5,5.7,3,6.6c-0.9,1.8-1.2,3.8-0.8,5.8c0.1,0.5,0.2,0.9,0.3,1.4l0.3,0.8C2.9,14.7,3,14.8,3,15l0.2,0.4c0,0.1,0.1,0.2,0.1,0.2l0.3,0.5c0.1,0.2,0.2,0.3,0.3,0.5l0.1,0.2c0.1,0.1,0.2,0.3,0.3,0.4L5,17.8c0.7,0.7,1.6,1.3,2.5,1.8c0.9,0.5,1.9,0.8,3,0.9c0.5,0.1,1,0.1,1.5,0.1c0.6,0,1.1,0,1.6-0.1c1-0.2,2.1-0.5,3-1l0.2-0.1c0.2-0.1,0.3-0.2,0.5-0.3l0.7-0.4c0.2-0.1,0.3-0.2,0.4-0.3l0.2-0.2c0.2-0.1,0.4-0.3,0.5-0.5l0.1-0.1c0.3-0.3,0.7-0.7,0.9-1l0.6-0.9l0.4-0.6c1-1.9,1.4-4.1,1.1-6.2C22,7.8,21.7,6.7,21.2,5.8z"/>%0A </defs>%0A <g fill="none" fill-rule="evenodd" transform="translate(0 2)">%0A <mask id="rotate-b" fill="%23fff">%0A <use xlink:href="%23rotate"/>%0A </mask>%0A <use fill="%235B5B5B" fill-rule="nonzero" xlink:href="%23rotate"/>%0A <g fill="%235B5B5B" mask="url(%23rotate-b)">%0A <rect width="30" height="30"/>%0A </g>%0A </g>%0A</svg>%0A');
}
.leaflet-pm-toolbar .leaflet-pm-icon-text {
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">%0A <title>Text</title>%0A <g id="Ebene_2" data-name="Ebene 2">%0A <polyline points="19.64 7.27 19.64 4 12 4 12 20 15.91 20 8.09 20 12 20 12 4 4.36 4 4.36 7.27" fill="none" stroke="%235b5b5b" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5px"/>%0A </g>%0A</svg>');
}
.leaflet-buttons-control-button:hover,
.leaflet-buttons-control-button:focus {
cursor: pointer;
background-color: #f4f4f4;
}
.active > .leaflet-buttons-control-button {
box-shadow: inset 0 -1px 5px 2px rgba(81, 77, 77, 0.31);
}
.leaflet-buttons-control-text-hide {
display: none;
}
.button-container {
position: relative;
}
.button-container .leaflet-pm-actions-container {
z-index: 2;
position: absolute;
top: 0;
left: 100%;
display: none;
white-space: nowrap;
direction: ltr;
}
.leaflet-right .leaflet-pm-toolbar .button-container .leaflet-pm-actions-container {
right: 100%;
left: auto;
}
.button-container.active .leaflet-pm-actions-container {
display: block;
}
.button-container .leaflet-pm-actions-container:not(.pos-right) a.leaflet-pm-action:last-child {
border-radius: 0 3px 3px 0;
border-right: 0;
}
.button-container .leaflet-pm-actions-container.pos-right a.leaflet-pm-action:first-child {
border-radius: 3px 0 0 3px;
}
.button-container .leaflet-pm-actions-container.pos-right a.leaflet-pm-action:last-child {
border-right: 0;
}
.button-container .leaflet-pm-actions-container .leaflet-pm-action {
padding: 0 10px;
background-color: #666;
color: #fff;
display: inline-block;
width: auto;
border-right: 1px solid #eee;
user-select: none;
border-bottom: none;
height: 29px;
line-height: 29px;
vertical-align: middle;
}
.leaflet-pm-toolbar .button-container:first-child.pos-right.active a.leaflet-buttons-control-button {
border-top-left-radius: 0;
}
.leaflet-pm-toolbar .button-container:first-child.active:not(.pos-right) a.leaflet-buttons-control-button {
border-top-right-radius: 0;
}
.button-container .leaflet-pm-actions-container .leaflet-pm-action:hover,
.button-container .leaflet-pm-actions-container .leaflet-pm-action:focus {
cursor: pointer;
background-color: #777;
}
.button-container .leaflet-pm-actions-container .leaflet-pm-action.active-action {
background-color: #8e8e8e;
}
.leaflet-pm-toolbar.activeChild {
z-index: 801;
}
.leaflet-buttons-control-button.pm-disabled {
background-color: #f4f4f4;
}
.leaflet-buttons-control-button.pm-disabled > .control-icon {
filter: opacity(0.6);
}
.button-container .leaflet-pm-actions-container .pm-action-button-mode.control-icon {
filter: brightness(0) invert(1);
width: 18px;
}
File diff suppressed because one or more lines are too long
-666
View File
@@ -1,666 +0,0 @@
/* @preserve
* Leaflet 1.9.4 stylesheet (leaflet.css), https://leafletjs.com
* (c) 2010-2023 Volodymyr Agafonkin, (c) 2010-2011 CloudMade
* BSD-2-Clause. Vendored (self-hosted per CSP); see THIRD-PARTY-NOTICES.md.
*/
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -1,81 +0,0 @@
/* @preserve
* Leaflet.markercluster 1.5.3 styles, https://github.com/Leaflet/Leaflet.markercluster
* Copyright 2012 David Leaver, MIT License.
* Upstream MarkerCluster.css + MarkerCluster.Default.css concatenated.
* Vendored (self-hosted per CSP); see THIRD-PARTY-NOTICES.md.
*/
.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow {
-webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in;
-moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in;
-o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in;
transition: transform 0.3s ease-out, opacity 0.3s ease-in;
}
.leaflet-cluster-spider-leg {
/* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */
-webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in;
-moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in;
-o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in;
transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in;
}
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
}
/* IE 6-8 fallback colors */
.leaflet-oldie .marker-cluster-small {
background-color: rgb(181, 226, 140);
}
.leaflet-oldie .marker-cluster-small div {
background-color: rgb(110, 204, 57);
}
.leaflet-oldie .marker-cluster-medium {
background-color: rgb(241, 211, 87);
}
.leaflet-oldie .marker-cluster-medium div {
background-color: rgb(240, 194, 12);
}
.leaflet-oldie .marker-cluster-large {
background-color: rgb(253, 156, 115);
}
.leaflet-oldie .marker-cluster-large div {
background-color: rgb(241, 128, 23);
}
.marker-cluster {
background-clip: padding-box;
border-radius: 20px;
}
.marker-cluster div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
}
.marker-cluster span {
line-height: 30px;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+104 -40
View File
@@ -1,8 +1,8 @@
// meshmap.js — Leaflet maps of repeater points. Basemap layers come from
// basemap.js (meshBaseLayers), which the page must load first.
// meshmap.js — MapLibre maps of repeater points. The basemap, the map factory and
// the framing helper come from basemap.js, which the page must load first.
// meshMap renders a dark-mode Leaflet map of points into the element with the
// given id. pts is an array of {name, lat, lon}. Does nothing if pts is empty.
// meshMap renders a map of points into the element with the given id. pts is an
// array of {name, lat, lon}. Does nothing if pts is empty.
function meshMap(elId, pts) {
if (!pts || !pts.length) return;
renderMeshMap(elId, pts);
@@ -28,42 +28,106 @@ function meshMapFromURL(elId, url) {
// renderMeshMap draws the given points into the element; shared by meshMap (inline
// points) and meshMapFromURL (fetched points).
function renderMeshMap(elId, pts) {
// Turn off every Leaflet animation so the map paints once, in its final
// position, with no flash on load: zoomAnimation (zoom transitions),
// fadeAnimation (tiles fading in), markerZoomAnimation (markers scaling).
var map = L.map(elId, {
scrollWheelZoom: false,
zoomAnimation: false,
fadeAnimation: false,
markerZoomAnimation: false,
var ACCENT = "#4dabf7";
var features = pts.map(function (p, i) {
return {
type: "Feature",
// A stable feature id: clustering needs one to tell points apart, and it also
// means a query against the source counts a point once rather than once per
// tile it lands in.
id: i,
geometry: { type: "Point", coordinates: [p.lon, p.lat] },
properties: { name: String(p.name == null ? "" : p.name) },
};
});
meshBaseLayers(map);
var markers = pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], {
radius: 7,
color: "#4dabf7",
weight: 2,
fillColor: "#4dabf7",
fillOpacity: 0.6,
}).bindPopup(p.name);
});
// Cluster overlapping markers when the plugin is loaded (keeps large maps
// responsive); fall back to a plain feature group otherwise. Both expose
// getBounds() for the fit below. chunkedLoading keeps the UI responsive while a
// large set is added.
var group = L.markerClusterGroup
? L.markerClusterGroup({ chunkedLoading: true })
: L.featureGroup();
markers.forEach(function (m) {
group.addLayer(m);
});
group.addTo(map);
// Set the view exactly once, non-animated, so the map paints in its final
// position with no fit/zoom flash on load. A lone repeater would otherwise fit
// at max zoom, so give it a fixed neighborhood zoom for context instead.
if (pts.length === 1) {
map.setView([pts[0].lat, pts[0].lon], 15, { animate: false });
} else {
map.fitBounds(group.getBounds().pad(0.3), { animate: false });
// Clustering is MapLibre's own (the source does it), which is what retires the
// markercluster plugin: co-located repeaters collapse into one counted circle
// instead of stacking invisibly on top of each other.
function overlays(map) {
map.addSource("points", {
type: "geojson",
data: { type: "FeatureCollection", features: features },
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 16,
});
map.addLayer({
id: "clusters",
type: "circle",
source: "points",
filter: ["has", "point_count"],
paint: {
// Grow the disc with the count so a big cluster reads as big, but keep the
// steps small — these sit on top of the basemap, not over it.
"circle-radius": ["step", ["get", "point_count"], 14, 10, 18, 50, 22],
"circle-color": ACCENT,
"circle-opacity": 0.6,
"circle-stroke-width": 2,
"circle-stroke-color": ACCENT,
},
});
map.addLayer({
id: "cluster-count",
type: "symbol",
source: "points",
filter: ["has", "point_count"],
layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": window.meshFont, "text-size": 12 },
paint: { "text-color": "#ffffff" },
});
map.addLayer({
id: "points",
type: "circle",
source: "points",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-radius": 7,
"circle-color": ACCENT,
"circle-opacity": 0.6,
"circle-stroke-width": 2,
"circle-stroke-color": ACCENT,
},
});
}
var map = window.meshCreateMap(elId, { scrollZoom: false, overlays: overlays });
// Clicking a cluster zooms to the point where it splits up, which is the only way
// to reach the repeaters inside one.
map.on("click", "clusters", function (e) {
var f = e.features && e.features[0];
if (!f) return;
map.getSource("points").getClusterExpansionZoom(f.properties.cluster_id).then(function (zoom) {
map.easeTo({ center: f.geometry.coordinates, zoom: zoom });
});
});
map.on("click", "points", function (e) {
var f = e.features && e.features[0];
if (!f) return;
// setText, not setHTML: a repeater name is operator-supplied, and this is the
// boundary where it would otherwise be parsed as markup.
new maplibregl.Popup().setLngLat(f.geometry.coordinates).setText(f.properties.name).addTo(map);
});
["clusters", "points"].forEach(function (layer) {
map.on("mouseenter", layer, function () {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", layer, function () {
map.getCanvas().style.cursor = "";
});
});
// Frame once, without animating, so the map paints in its final position with no
// fit/zoom flash on load. A lone repeater would otherwise fit at max zoom, so it
// gets a fixed neighborhood zoom for context instead.
window.meshFrame(
map,
pts.map(function (p) {
return [p.lon, p.lat];
}),
{ padding: 40, pointZoom: 15 },
);
}
+391 -166
View File
@@ -1,4 +1,4 @@
// regionmap.js — Leaflet + Geoman map for org config regions.
// regionmap.js — MapLibre + Terra Draw maps for org config regions.
//
// A region's geofence is a GeoJSON Polygon/MultiPolygon geometry; no geometry means
// the region is a draft that applies nowhere until an area is drawn. Two entry
@@ -8,6 +8,9 @@
// region's polygon, with the org's other regions outlined as read-only context.
// regionMapView(mapId, opts) — read-only: a location picker (with an optional
// previewed location marker).
//
// Load after maplibre-gl.js, terra-draw.js, terra-draw-maplibre-gl-adapter.js and
// basemap.js.
(function () {
// Colors come from the server (web.RegionPalette) so a legend swatch and the
// polygon it labels can never drift apart. FALLBACK covers a caller that passes
@@ -20,16 +23,9 @@
// is the small geographic nudge that shows a little of what surrounds the primary
// region; keep it low, because fitBounds then rounds *down* to an integer zoom and
// any slack here gets magnified by that rounding.
var FIT_PADDING = [24, 24];
var FIT_PADDING = 24;
var PRIMARY_CONTEXT = 0.08;
// editableStyle is the one shape the current page can modify: drawn prominently so
// it always reads which polygon the tools will act on.
function editableStyle(color) {
var c = color || FALLBACK;
return { color: c, weight: 3, opacity: 1, fillColor: c, fillOpacity: 0.25 };
}
// Region fills stack: nested regions overlap, so N of them at opacity a cover
// 1-(1-a)^N of the basemap. Orgs run deep hierarchies (3 levels is common, 5-6 is
// planned), and at a=0.25 six levels reach 82% — the map underneath disappears.
@@ -40,230 +36,459 @@
var FILL_MATCHED = 0.09;
var FILL_CONTEXT = 0.04;
// outlineStyle is a region drawn for reference — a neighbor on the area editor, or
// any region on the read-only config map. Dimmed by default; a region that applies
// at the previewed location is brought forward, which is what explains why its
// token shows up in the assembled config.
function outlineStyle(color, matched) {
var c = color || FALLBACK;
return {
color: c,
weight: matched ? 3 : 1.5,
opacity: matched ? 1 : 0.55,
fillColor: c,
fillOpacity: matched ? FILL_MATCHED : FILL_CONTEXT,
interactive: false,
};
}
// The one shape the area editor can modify is drawn prominently, so it always
// reads which polygon the tools will act on.
var EDIT_FILL_OPACITY = 0.25;
var EDIT_OUTLINE_WIDTH = 3;
// layerFromGeoJSON turns a stored geometry string into a single Leaflet layer,
// or null for an empty/invalid shape. geometryToLayer handles the [lon,lat] ->
// [lat,lng] axis flip for us, so we never touch coordinate order by hand.
function layerFromGeoJSON(str) {
// The picked-location marker, on both the preview and the click-to-pick paths.
var PICK_COLOR = "#ffffff";
// How near, in screen pixels, a click counts as "the same point" while drawing.
//
// This is the floor on how finely an outline can be drawn, because Terra Draw
// measures it against the previous vertex as well as the first one: at its default
// of 40 the next click has to land more than 40px away or it is read as closing the
// ring rather than extending it, which makes tracing anything detailed — a
// shoreline, a county line — impossible without zooming way in. It is also the grab
// radius for a vertex in select mode, where 40 means dense vertices fight over the
// pointer.
//
// 12 leaves the closing point a 24px target, which is still a comfortable click and
// the usual floor for a touch target, so finishing by clicking the first vertex
// stays workable. Going much below this starts trading away that target.
var POINTER_DISTANCE = 12;
function parseGeometry(str) {
if (!str) return null;
try {
return L.GeoJSON.geometryToLayer(JSON.parse(str));
var g = JSON.parse(str);
return g && g.coordinates && g.coordinates.length ? g : null;
} catch (e) {
return null;
}
}
// geomString serializes a layer back to a GeoJSON geometry string. toGeoJSON
// emits [lon,lat] per spec — the same axis order the Go geo package expects.
function geomString(layer) {
return JSON.stringify(layer.toGeoJSON().geometry);
// polygonsOf splits any stored geometry into a list of Polygon coordinate arrays.
// Terra Draw edits Polygons only, so a stored MultiPolygon is loaded as one
// editable shape per part and reassembled on the way out (see serialize below) —
// that way existing multi-part regions stay editable rather than read-only.
function polygonsOf(geometry) {
if (!geometry) return [];
if (geometry.type === "Polygon") return [geometry.coordinates];
if (geometry.type === "MultiPolygon") return geometry.coordinates;
return [];
}
function fitToLayers(map, layers, fallback) {
var group = L.featureGroup(layers);
if (layers.length) {
map.fitBounds(group.getBounds().pad(0.3), { animate: false });
} else {
map.setView(fallback || [20, 0], fallback ? 13 : 2, { animate: false });
}
// regionFeatures turns the server's region list into one FeatureCollection. The
// matched/context distinction rides on each feature's properties and is resolved
// by a paint expression, so N nested regions still cost two layers, not 2N.
function regionFeatures(regions) {
var features = [];
(regions || []).forEach(function (r) {
if (!r) return;
var geom = parseGeometry(r.geojson);
if (!geom) return;
features.push({
type: "Feature",
// A stable feature id, so a polygon spanning several tiles is still one
// feature to anything querying the source rather than one hit per tile.
id: features.length,
geometry: geom,
properties: {
color: r.color || FALLBACK,
matched: !!r.matched,
primary: !!r.primary,
},
});
});
return features;
}
// addRegionLayers draws a set of regions as outlines: dimmed by default, with any
// region that applies at the previewed location brought forward — which is what
// explains why its token shows up in the assembled config.
//
// The outlines carry no name labels. A symbol layer over a polygon source labels
// each tile the polygon covers, so a nationwide region picks up one label per tile
// and reads as several regions rather than one. What names a region is the legend
// beside the map, whose swatch comes from the same server palette as the stroke.
function addRegionLayers(map, id, features) {
map.addSource(id, { type: "geojson", data: { type: "FeatureCollection", features: features } });
map.addLayer({
id: id + "-fill",
type: "fill",
source: id,
paint: {
"fill-color": ["get", "color"],
"fill-opacity": ["case", ["get", "matched"], FILL_MATCHED, FILL_CONTEXT],
},
});
map.addLayer({
id: id + "-line",
type: "line",
source: id,
paint: {
"line-color": ["get", "color"],
"line-width": ["case", ["get", "matched"], 3, 1.5],
"line-opacity": ["case", ["get", "matched"], 1, 0.55],
},
});
}
// markerFeature is the picked/previewed location, drawn as a small white dot.
function addMarkerLayer(map, id, lngLat) {
map.addSource(id, {
type: "geojson",
data: {
type: "FeatureCollection",
features: lngLat ? [{ type: "Feature", geometry: { type: "Point", coordinates: lngLat }, properties: {} }] : [],
},
});
map.addLayer({
id: id + "-point",
type: "circle",
source: id,
paint: {
"circle-radius": 7,
"circle-color": PICK_COLOR,
"circle-opacity": 0.9,
"circle-stroke-width": 2,
"circle-stroke-color": PICK_COLOR,
},
});
}
// notSelfIntersecting builds the geofence validity rule: a polygon with a crossed
// edge has no well-defined inside, which is the constraint Geoman enforced as
// allowSelfIntersection: false.
//
// Which updates it applies to is the whole subtlety. Terra Draw runs validation on
// provisional updates too — every pointer move, and every click while the ring is
// still open — and a polygon under construction is auto-closed back to its first
// point. So an outline that runs south, then east, and then wants to head farther
// south crosses that closing edge *in the intermediate state*, even though the
// finished shape never does. Enforcing there rejects the click with no feedback at
// all: the cursor simply stops adding points, and concave areas — a state whose
// coastline doubles back, say — cannot be drawn. Judge the finished shape instead.
function notSelfIntersecting(updateTypes, onInvalid) {
return function (feature, context) {
if (updateTypes.indexOf(context.updateType) === -1) return { valid: true };
var result = terraDraw.ValidateNotSelfIntersecting(feature);
// Terra Draw refuses the update and says nothing, so an operator who has just
// crossed their own outline sees the tool stop responding with no reason
// given. Report it.
if (!result.valid && onInvalid) onInvalid();
return result;
};
}
// ---- Area editor (one region) -------------------------------------------
// DrawControl is the editor's toolbar. Terra Draw is headless — it renders shapes
// and handles the pointer work but ships no UI at all — so unlike Leaflet-Geoman,
// which brought its own toolbar, the buttons are ours. They are built here rather
// than in the template so the modes and the buttons that switch them stay in one
// file, and they carry data-testid hooks for the browser tests.
function DrawControl(actions) {
this._actions = actions;
}
DrawControl.prototype.onAdd = function () {
var self = this;
var el = document.createElement("div");
el.className = "maplibregl-ctrl maplibregl-ctrl-group mesh-draw-ctrl";
this._buttons = {};
[
{ key: "polygon", label: "Draw" },
{ key: "rectangle", label: "Rectangle" },
{ key: "select", label: "Edit" },
].forEach(function (spec) {
var b = document.createElement("button");
b.type = "button";
b.textContent = spec.label;
b.setAttribute("data-testid", "region-draw-" + spec.key);
b.addEventListener("click", function () {
self._actions.setMode(spec.key);
});
el.appendChild(b);
self._buttons[spec.key] = b;
});
this._el = el;
return el;
};
// sync reflects the editor's state on the toolbar: with a shape present the draw
// tools are off (one region, one area, so a second draw would be ambiguous) and
// editing is on; with none, the reverse.
DrawControl.prototype.sync = function (mode, hasShape) {
if (!this._buttons) return;
this._buttons.polygon.disabled = hasShape;
this._buttons.rectangle.disabled = hasShape;
this._buttons.select.disabled = !hasShape;
for (var key in this._buttons) {
if (Object.prototype.hasOwnProperty.call(this._buttons, key)) {
this._buttons[key].setAttribute("aria-pressed", String(key === mode));
}
}
};
DrawControl.prototype.onRemove = function () {
if (this._el && this._el.parentNode) this._el.parentNode.removeChild(this._el);
};
// initRegionArea drives the per-region area workspace. It edits exactly one
// shape — the region whose page this is — and renders the org's other regions as
// non-interactive outlines for spatial reference. opts:
// read-only outlines for spatial reference. opts:
// input — id of the hidden field carrying the GeoJSON geometry (required);
// status — id of an element to show "Custom area" / "No area yet" in;
// clear — id of a button that removes the shape;
// name — this region's label, used in the shape's tooltip;
// name — this region's label, used on the drawn shape;
// color — this region's palette color (see web.RegionPalette);
// siblings — [{name, geojson, color}] drawn read-only.
//
// Everything the form submits lives in the hidden input, so the server sees the
// same shape whether it was drawn, edited, or cleared.
// same shape whether it was drawn, edited, or cleared — and, because the input is
// the single source of truth, the editor can be rebuilt from it after a basemap
// swap without losing work.
window.initRegionArea = function (mapId, opts) {
opts = opts || {};
var input = document.getElementById(opts.input);
if (!input) return;
var statusEl = opts.status ? document.getElementById(opts.status) : null;
var map = L.map(mapId);
meshBaseLayers(map);
var color = opts.color || FALLBACK;
var siblings = regionFeatures(opts.siblings);
// Geoman toolbar: only the tools that make sense for an area geofence. Drawing
// is enabled only while there's no shape — one region, one polygon, so a second
// draw would be ambiguous.
map.pm.addControls({
position: "topleft",
drawMarker: false,
drawCircleMarker: false,
drawCircle: false,
drawPolyline: false,
drawText: false,
drawPolygon: true,
drawRectangle: true,
editMode: true,
dragMode: true,
removalMode: true,
cutPolygon: false,
rotateMode: false,
var draw = null;
var control = new DrawControl({
setMode: function (mode) {
if (!draw) return;
draw.setMode(mode);
sync();
},
});
map.pm.setGlobalOptions({ allowSelfIntersection: false });
var layer = null;
// shapes are the polygons the operator is editing. Terra Draw's store also holds
// the select mode's own furniture — selection points and midpoints — so filter
// to polygons rather than taking the snapshot whole.
function shapes() {
if (!draw) return [];
return draw.getSnapshot().filter(function (f) {
return f.geometry && f.geometry.type === "Polygon";
});
}
function serialize() {
input.value = layer ? geomString(layer) : "";
if (statusEl) statusEl.textContent = layer ? "Custom area" : "No area yet";
// With a shape present, drawing is off (edit/drag/remove stay on); once it's
// cleared, the draw tools come back.
map.pm.Toolbar.setButtonDisabled("drawPolygon", !!layer);
map.pm.Toolbar.setButtonDisabled("drawRectangle", !!layer);
}
function bind(l) {
layer = l;
l.setStyle(editableStyle(opts.color));
if (opts.name) l.bindTooltip(String(opts.name), { sticky: true });
l.addTo(map);
// Re-serialize after any vertex edit or whole-shape drag.
l.on("pm:edit pm:update pm:dragend pm:markerdragend", serialize);
serialize();
}
function clear() {
if (layer) {
map.removeLayer(layer);
layer = null;
var polys = shapes();
if (!polys.length) {
input.value = "";
} else if (polys.length === 1) {
input.value = JSON.stringify(polys[0].geometry);
} else {
// Reassemble the parts of a multi-part region exactly as they arrived.
input.value = JSON.stringify({
type: "MultiPolygon",
coordinates: polys.map(function (f) {
return f.geometry.coordinates;
}),
});
}
if (statusEl) statusEl.textContent = polys.length ? "Custom area" : "No area yet";
sync();
}
function sync() {
control.sync(draw ? draw.getMode() : "select", shapes().length > 0);
}
// rejectCrossed explains a refused edit in the status line. serialize() overwrites
// it as soon as there is a valid shape again.
function rejectCrossed() {
if (statusEl) statusEl.textContent = "Edges cross — an area can't overlap itself";
}
function buildDraw(map) {
var styles = {
fillColor: color,
fillOpacity: EDIT_FILL_OPACITY,
outlineColor: color,
outlineWidth: EDIT_OUTLINE_WIDTH,
};
var flags = {
feature: {
// An edit lands as a commit, so that is where a dragged vertex is judged.
validation: notSelfIntersecting(["commit", "finish"], rejectCrossed),
draggable: true,
coordinates: { midpoints: true, draggable: true, deletable: true },
},
};
return new terraDraw.TerraDraw({
adapter: new terraDrawMaplibreGlAdapter.TerraDrawMapLibreGLAdapter({ map: map }),
modes: [
// Drawing is judged only when the operator finishes; see notSelfIntersecting.
new terraDraw.TerraDrawPolygonMode({
styles: styles,
validation: notSelfIntersecting(["finish"], rejectCrossed),
editable: true,
pointerDistance: POINTER_DISTANCE,
}),
new terraDraw.TerraDrawRectangleMode({ styles: styles }),
new terraDraw.TerraDrawSelectMode({
flags: { polygon: flags, rectangle: flags },
pointerDistance: POINTER_DISTANCE,
}),
],
});
}
function loadShapes(geometry) {
var polys = polygonsOf(geometry);
if (!polys.length) return;
draw.addFeatures(
polys.map(function (coords) {
return {
type: "Feature",
geometry: { type: "Polygon", coordinates: coords },
properties: { mode: "polygon" },
};
}),
);
}
// overlays runs on first load and again after every basemap swap, because
// setStyle discards the sibling layers *and* the layers Terra Draw's adapter
// renders into. Rebuilding the editor from the hidden input is lossless — the
// input is always current — and is simpler than trying to re-register an adapter
// against a style that no longer has its sources.
function overlays(map) {
addRegionLayers(map, "siblings", siblings);
var geometry = parseGeometry(input.value);
if (draw) {
try {
draw.stop();
} catch (e) {
/* already torn down with the old style */
}
}
draw = buildDraw(map);
draw.start();
// Only a finished shape reaches the field. Terra Draw keeps the ring currently
// being drawn in the same store as finished ones, so serializing on every
// change would put a half-drawn outline into the form — and would leave a
// self-intersecting one there after its finish was refused, letting Save
// persist a geofence with no inside. A change in select mode, by contrast, is
// a real edit of a real shape.
draw.on("change", function () {
if (draw.getMode() === "select") serialize();
else sync();
});
draw.on("finish", function () {
// Drawing is done: hand the operator the tool that edits what they drew.
draw.setMode("select");
serialize();
});
loadShapes(geometry);
window.MESHTENDER_MAPS[mapId].draw = draw;
draw.setMode(geometry ? "select" : "polygon");
serialize();
}
map.on("pm:create", function (e) {
// Belt and braces: the draw buttons are disabled while a shape exists, but if
// one is somehow drawn anyway, replace rather than orphan a second polygon.
if (layer) map.removeLayer(layer);
bind(e.layer);
});
map.on("pm:remove", function (e) {
if (e.layer === layer) clear();
});
// Siblings first, so the edited shape sits on top of them.
var context = [];
(opts.siblings || []).forEach(function (sib) {
if (!sib) return;
var l = layerFromGeoJSON(sib.geojson);
if (!l) return;
l.setStyle(outlineStyle(sib.color));
if (sib.name) l.bindTooltip(String(sib.name));
l.addTo(map);
context.push(l);
});
var existing = layerFromGeoJSON(input.value);
if (existing) bind(existing);
else serialize();
var map = window.meshCreateMap(mapId, { overlays: overlays });
map.addControl(control, "top-right");
var clearBtn = opts.clear ? document.getElementById(opts.clear) : null;
if (clearBtn) clearBtn.addEventListener("click", clear);
if (clearBtn) {
clearBtn.addEventListener("click", function () {
if (!draw) return;
draw.clear();
draw.setMode("polygon");
serialize();
});
}
// Frame the edited shape when there is one, otherwise the siblings — a brand-new
// region should open looking at where its neighbors are.
fitToLayers(map, layer ? [layer] : context);
var own = window.meshCoordsOf(parseGeometry(input.value));
var context = [];
siblings.forEach(function (f) {
context = context.concat(window.meshCoordsOf(f.geometry));
});
window.meshFrame(map, own.length ? own : context, { padding: FIT_PADDING, pointZoom: 13 });
return map;
};
// ---- Read-only viewer ---------------------------------------------------
// regionMapView renders a read-only region map. opts:
// regions — [{name, geojson, matched, color, primary}] drawn as non-interactive
// outlines, the ones matching the previewed location brought forward.
// Omit for a plain location picker (the console and serial-setup
// callers do);
// regions — [{name, geojson, matched, color, primary}] drawn as outlines, the
// ones matching the previewed location brought forward. Omit for a
// plain location picker (the console and serial-setup callers do);
// pickURL — clicking the map navigates here with lat/lon appended so the
// server resolves the region def for that point (must end "?"/"&");
// preview — {lat, lon} of an already-picked point, shown as a marker;
// bounds — [[minLat,minLon],[maxLat,maxLon]] to frame the org's regions.
window.regionMapView = function (mapId, opts) {
opts = opts || {};
var map = L.map(mapId, { scrollWheelZoom: false });
meshBaseLayers(map);
var features = regionFeatures(opts.regions);
var preview = opts.preview ? [opts.preview.lon, opts.preview.lat] : null;
var picked = preview;
function overlays(map) {
addRegionLayers(map, "regions", features);
// The marker source goes on after the regions so the picked location always
// sits on top of them.
addMarkerLayer(map, "picked", picked);
}
var map = window.meshCreateMap(mapId, { scrollZoom: false, overlays: overlays });
// Region outlines go on first so the picked-location marker stays on top. They
// are non-interactive (see outlineStyle), so a click still reaches the map and
// drops a pin rather than being swallowed by a polygon.
var drawn = [];
var primary = null;
(opts.regions || []).forEach(function (r) {
if (!r) return;
var l = layerFromGeoJSON(r.geojson);
if (!l) return;
l.setStyle(outlineStyle(r.color, r.matched));
if (r.name) l.bindTooltip(String(r.name));
l.addTo(map);
drawn.push(l);
if (r.primary) primary = l;
});
if (opts.pickURL) {
map.on("click", function (e) {
window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6);
window.location = opts.pickURL + "lat=" + e.lngLat.lat.toFixed(6) + "&lon=" + e.lngLat.lng.toFixed(6);
});
}
// onPick drops/moves a marker and reports the point instead of navigating —
// used by in-page pickers (e.g. the serial setup form) that keep the value
// client-side rather than round-tripping to the server.
if (opts.onPick) {
var picked = null;
map.on("click", function (e) {
if (picked) picked.setLatLng(e.latlng);
else picked = L.circleMarker(e.latlng, {
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
}).addTo(map);
opts.onPick(e.latlng.lat, e.latlng.lng);
picked = [e.lngLat.lng, e.lngLat.lat];
var src = map.getSource("picked");
if (src) {
src.setData({
type: "FeatureCollection",
features: [{ type: "Feature", geometry: { type: "Point", coordinates: picked }, properties: {} }],
});
}
opts.onPick(e.lngLat.lat, e.lngLat.lng);
});
}
var fit = [];
if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]);
// With no explicit bounds, open framed on the primary region — where the org
// actually operates — with room around it for context, rather than zooming out
// to contain a nationwide parent. Without a primary, frame everything drawn.
if (!opts.bounds && drawn.length) {
var rb = L.featureGroup(primary ? [primary] : drawn).getBounds();
if (primary) rb = rb.pad(PRIMARY_CONTEXT);
fit.push(rb.getSouthWest(), rb.getNorthEast());
}
if (opts.preview) {
L.circleMarker([opts.preview.lat, opts.preview.lon], {
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
}).addTo(map);
fit.push([opts.preview.lat, opts.preview.lon]);
}
if (fit.length) {
var b = L.latLngBounds(fit);
// A zero-size box (single point — one repeater, or a preview with no
// region box) can't be fit; center on it at a neighborhood zoom instead.
if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false });
else map.fitBounds(b, { animate: false, padding: FIT_PADDING });
} else {
map.setView([20, 0], 2, { animate: false });
// Framing. An explicit bounds wins; otherwise open framed on the primary region —
// where the org actually operates — with room around it for context, rather than
// zooming out to contain a nationwide parent. Without a primary, frame everything
// drawn. The previewed point is always kept in view.
var coords = [];
var pad = 0;
if (opts.bounds) {
coords.push([opts.bounds[0][1], opts.bounds[0][0]], [opts.bounds[1][1], opts.bounds[1][0]]);
} else if (features.length) {
// Read primary off the features, not the input list: regionFeatures drops
// regions with no drawn area, so the two are not index-aligned.
var primary = null;
features.forEach(function (f) {
if (f.properties.primary) primary = f;
});
(primary ? [primary] : features).forEach(function (f) {
coords = coords.concat(window.meshCoordsOf(f.geometry));
});
if (primary) pad = PRIMARY_CONTEXT;
}
if (preview) coords.push(preview);
window.meshFrame(map, coords, { padding: FIT_PADDING, pad: pad, pointZoom: 11 });
// Return the map so callers that show it inside an initially-hidden container
// (e.g. a modal) can invalidateSize() once it becomes visible.
// (e.g. a modal) can resize() once it becomes visible.
return map;
};
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+28 -4
View File
@@ -1,5 +1,5 @@
{{define "base"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}">
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -180,7 +180,7 @@
the standard page chrome (header block, content, footer). The marketing
surface defaults to this so every root page matches the rest of the site. */}}
{{define "rootbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}">
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -223,7 +223,7 @@
offer are owed on every surface, and someone signing in is as much a user of
this server as someone already signed in. */}}
{{define "authbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}">
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -253,7 +253,7 @@
sign-in / sign-up actions, the page content, and a simple footer. Pages opt
in via the "Layout":"landingbase" render key. */}}
{{define "landingbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}">
<html lang="en" data-bs-theme="dark" data-tz="{{.UserTZ}}" data-carto-key="{{.CartoKey}}" data-maplibre-worker="{{ asset "/static/maplibre-gl-worker.js" }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
@@ -297,3 +297,27 @@
{{- end -}}
{{- if and .AccessKnown (not .IsAdmin)}}<span class="badge bg-warning-lt">guest only</span>{{end -}}
{{end}}
{{/* map-styles / map-scripts are the vendored map stack, in one place rather than
hand-listed by each of the ten pages that render a map — which is how the
Leaflet list they replaced managed to drift. Include map-styles above the map
container and map-scripts before the page's own map call.
Order matters: maplibre-gl.js defines the global the adapter and basemap.js
both reach for, and basemap.js defines meshCreateMap, which meshmap.js and
regionmap.js build on. terra-draw is only needed where a shape is drawn, so
map-draw-scripts is separate — no reason to ship 244KB to a page that just
shows dots. */}}
{{define "map-styles"}}
<link rel="stylesheet" href="{{ asset "/static/maplibre-gl.css" }}">
{{end}}
{{define "map-scripts"}}
<script src="{{ asset "/static/maplibre-gl.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{end}}
{{define "map-draw-scripts"}}
<script src="{{ asset "/static/terra-draw.js" }}"></script>
<script src="{{ asset "/static/terra-draw-maplibre-gl-adapter.js" }}"></script>
{{end}}
+2 -3
View File
@@ -130,11 +130,10 @@
<div class="row g-0">
<div class="col-lg-8">
{{if .Config.HasRegionShapes}}
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
{{template "map-styles"}}
<div id="region-map" style="height:520px" role="region"
aria-label="Map of this organizations regions — click to preview a location"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/regionmap.js" }}"></script>
{{/* Region names and shapes are user data, interpolated in a JS context so
html/template JSON-encodes and escapes them. */}}
+2 -5
View File
@@ -66,12 +66,9 @@
<div class="card-header"><h2 class="card-title">Repeaters</h2></div>
{{if .HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<link rel="stylesheet" href="{{ asset "/static/leaflet.markercluster.css" }}">
{{template "map-styles"}}
<div id="map" style="height:260px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/leaflet.markercluster.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
{{if .MapURL}}meshMapFromURL('map', {{.MapURL}});{{else}}meshMap('map', [
+2 -5
View File
@@ -72,12 +72,9 @@
<div class="card-header"><h2 class="card-title">Map</h2></div>
{{if .Reps.HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="{{ asset "/static/leaflet.css" }}">
<link rel="stylesheet" href="{{ asset "/static/leaflet.markercluster.css" }}">
{{template "map-styles"}}
<div id="map" style="height:300px" role="region" aria-label="Map of organization repeater locations"></div>
<script src="{{ asset "/static/leaflet.js" }}"></script>
<script src="{{ asset "/static/leaflet.markercluster.js" }}"></script>
<script src="{{ asset "/static/basemap.js" }}"></script>
{{template "map-scripts"}}
<script src="{{ asset "/static/meshmap.js" }}"></script>
<script nonce="{{.Nonce}}">
{{if .MapURL}}meshMapFromURL('map', {{.MapURL}});{{else}}meshMap('map', [