defra_ruby_map 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +8 -0
- data/README.md +204 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/defra-ruby-map/grid_ref_sync.js +143 -0
- data/app/assets/javascripts/defra-ruby-map/grid_reference_converter.js +123 -0
- data/app/assets/javascripts/defra-ruby-map/map_init.js +275 -0
- data/app/controllers/defra_ruby_map/application_controller.rb +6 -0
- data/app/controllers/defra_ruby_map/proxy_controller.rb +102 -0
- data/config/routes.rb +7 -0
- data/lib/defra_ruby_map/asset_server.rb +61 -0
- data/lib/defra_ruby_map/configuration.rb +7 -0
- data/lib/defra_ruby_map/engine.rb +40 -0
- data/lib/defra_ruby_map/map_helper.rb +9 -0
- data/lib/defra_ruby_map/version.rb +5 -0
- data/lib/defra_ruby_map.rb +19 -0
- data/vendor/assets/images/defra-ruby-map/black-and-white-map-thumb.jpg +0 -0
- data/vendor/assets/images/defra-ruby-map/dark-map-thumb.jpg +0 -0
- data/vendor/assets/images/defra-ruby-map/os-logo-white.svg +1 -0
- data/vendor/assets/images/defra-ruby-map/os-logo.svg +1 -0
- data/vendor/assets/images/defra-ruby-map/outdoor-map-thumb.jpg +0 -0
- data/vendor/assets/javascripts/defra-interactive-map/im-core.js +2 -0
- data/vendor/assets/javascripts/defra-interactive-map/im-core.js.LICENSE.txt +31 -0
- data/vendor/assets/javascripts/defra-interactive-map/index.js +2 -0
- data/vendor/assets/javascripts/defra-interactive-map/index.js.LICENSE.txt +21 -0
- data/vendor/assets/javascripts/interact-plugin/im-interact-plugin.js +1 -0
- data/vendor/assets/javascripts/interact-plugin/index.js +2 -0
- data/vendor/assets/javascripts/interact-plugin/index.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/map-styles-plugin/im-map-styles-plugin.js +1 -0
- data/vendor/assets/javascripts/map-styles-plugin/index.js +2 -0
- data/vendor/assets/javascripts/map-styles-plugin/index.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/maplibre-provider/im-maplibre-framework.js +2 -0
- data/vendor/assets/javascripts/maplibre-provider/im-maplibre-framework.js.LICENSE.txt +4 -0
- data/vendor/assets/javascripts/maplibre-provider/im-maplibre-provider.js +2 -0
- data/vendor/assets/javascripts/maplibre-provider/im-maplibre-provider.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/maplibre-provider/index.js +2 -0
- data/vendor/assets/javascripts/maplibre-provider/index.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Black_and_White.json +11566 -0
- data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Dark.json +11309 -0
- data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Outdoor.json +11293 -0
- data/vendor/assets/javascripts/os-styles/sprites/dark.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/dark.png +0 -0
- data/vendor/assets/javascripts/os-styles/sprites/dark@2x.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/dark@2x.png +0 -0
- data/vendor/assets/javascripts/os-styles/sprites/greyscale.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/greyscale.png +0 -0
- data/vendor/assets/javascripts/os-styles/sprites/greyscale@2x.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/greyscale@2x.png +0 -0
- data/vendor/assets/javascripts/os-styles/sprites/sprite.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/sprite.png +0 -0
- data/vendor/assets/javascripts/os-styles/sprites/sprite@2x.json +1 -0
- data/vendor/assets/javascripts/os-styles/sprites/sprite@2x.png +0 -0
- data/vendor/assets/javascripts/proj4js/LICENSE.md +29 -0
- data/vendor/assets/javascripts/proj4js/proj4.js +1 -0
- data/vendor/assets/javascripts/scale-bar-plugin/im-scale-bar-plugin.js +1 -0
- data/vendor/assets/javascripts/scale-bar-plugin/index.js +2 -0
- data/vendor/assets/javascripts/scale-bar-plugin/index.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/search-plugin/im-search-plugin.js +2 -0
- data/vendor/assets/javascripts/search-plugin/im-search-plugin.js.LICENSE.txt +1 -0
- data/vendor/assets/javascripts/search-plugin/index.js +2 -0
- data/vendor/assets/javascripts/search-plugin/index.js.LICENSE.txt +1 -0
- data/vendor/assets/stylesheets/defra-interactive-map/interactive-map.css +1 -0
- data/vendor/assets/stylesheets/defra-interactive-map/map-styles-plugin.css +1 -0
- data/vendor/assets/stylesheets/defra-interactive-map/scale-bar-plugin.css +31 -0
- data/vendor/assets/stylesheets/defra-interactive-map/search-plugin.css +1 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4a2f98d96f22c559bf065e71a385e2e6c44e15a3f88849472894991d97bc8d84
|
|
4
|
+
data.tar.gz: 2c06e945bfc6341124f8a3d1de8660059a2e152bba0272322edea1af245e23ad
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fc3cac76f70d43922c65dac966120e095b198040cd5902f6c60a2329c2efc74a49aba372ccf2f0e1dc6e90eb869771585d6542e57e8b8fbec88ac112a2768997
|
|
7
|
+
data.tar.gz: 2e4089710844a5774c5b9c9ffc892c0cdfb347bf791bd4aa3418985f283623a6dae61cd883671b38a942691e1d8e1f3b8b58ea939b29c1dc1b6bfc4ab1845254
|
data/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The Open Government Licence (OGL) Version 3
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Environment Agency
|
|
4
|
+
|
|
5
|
+
This source code is licensed under the Open Government Licence v3.0. To view this
|
|
6
|
+
licence, visit www.nationalarchives.gov.uk/doc/open-government-licence/version/3
|
|
7
|
+
or write to the Information Policy Team, The National Archives, Kew, Richmond,
|
|
8
|
+
Surrey, TW9 4DU.
|
data/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# DefraRubyMap
|
|
2
|
+
|
|
3
|
+
[](https://sonarcloud.io/summary/new_code?id=DEFRA_defra-ruby-map)
|
|
4
|
+
|
|
5
|
+
Rails engine gem providing the [DEFRA Interactive Map](https://defra.github.io/interactive-map/) component for GOV.UK services, with OS grid reference conversion, bidirectional field sync, and a server-side proxy that keeps the OS API key out of the browser.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [@defra/interactive-map](https://github.com/DEFRA/interactive-map) — UMD build with the MapLibre provider and the interact, search, map-styles and scale-bar plugins
|
|
10
|
+
- [proj4js](https://github.com/proj4js/proj4js) — WGS84/OSGB36 coordinate conversion
|
|
11
|
+
- OS grid reference converter, ported from the [os_map_ref](https://github.com/DEFRA/os-map-ref) gem
|
|
12
|
+
- Ordnance Survey Vector Tile stylesheets (Outdoor / Dark / Black & White), vendored and pinned
|
|
13
|
+
- A generic map initializer with bidirectional grid reference sync
|
|
14
|
+
- A server-side proxy for the OS Places and OS Vector Tile APIs
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add to your Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "defra_ruby_map"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
Configure the OS Data Hub API key in an initializer. A single key is used for all three OS endpoints:
|
|
27
|
+
|
|
28
|
+
- `https://api.os.uk/maps/vector/v1` — vector tiles, glyphs and TileJSON
|
|
29
|
+
- `https://api.os.uk/search/places/v1/find` — address search
|
|
30
|
+
- `https://api.os.uk/search/places/v1/nearest` — nearest-address lookup
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# config/initializers/defra_ruby_map.rb
|
|
34
|
+
DefraRubyMap.configure do |config|
|
|
35
|
+
config.os_maps_api_key = ENV.fetch("OS_MAPS_API_KEY", nil)
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Mount the engine to expose the proxy endpoints. Mounting at `/defra-ruby-map` keeps the proxy URLs aligned with the asset URLs (see [How assets are served](#how-assets-are-served)):
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/routes.rb
|
|
43
|
+
mount DefraRubyMap::Engine => "/defra-ruby-map"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
No asset precompilation or copy step is required — the engine serves its vendored assets automatically (see below).
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
Add a map container to your view. `map_init.js` reads its configuration from `data-` attributes:
|
|
51
|
+
|
|
52
|
+
```erb
|
|
53
|
+
<div id="my-map"
|
|
54
|
+
class="govuk-!-display-none"
|
|
55
|
+
data-module="defra-interactive-map"
|
|
56
|
+
data-proxy-url="/defra-ruby-map"
|
|
57
|
+
data-images-url="<%= defra_map_asset_path("images") %>"
|
|
58
|
+
data-initial-grid-reference="<%= @form.grid_reference %>"
|
|
59
|
+
data-center="-1.5,52.5"
|
|
60
|
+
data-zoom="6">
|
|
61
|
+
</div>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Load the assets. Reference the **vendored bundles, CSS and images** with the `defra_map_asset_path` helper (they are served by the engine, not the asset pipeline), and the **first-party JS** (`grid_reference_converter`, `grid_ref_sync`, `map_init`) with `javascript_include_tag` (these go through Sprockets and are precompiled; load `map_init` last):
|
|
65
|
+
|
|
66
|
+
```erb
|
|
67
|
+
<link rel="stylesheet" href="<%= defra_map_asset_path("css/interactive-map.css") %>" media="all" />
|
|
68
|
+
<link rel="stylesheet" href="<%= defra_map_asset_path("css/search-plugin.css") %>" media="all" />
|
|
69
|
+
<link rel="stylesheet" href="<%= defra_map_asset_path("css/map-styles-plugin.css") %>" media="all" />
|
|
70
|
+
<link rel="stylesheet" href="<%= defra_map_asset_path("css/scale-bar-plugin.css") %>" media="all" />
|
|
71
|
+
|
|
72
|
+
<script src="<%= defra_map_asset_path("proj4js/proj4.js") %>"></script>
|
|
73
|
+
<script src="<%= defra_map_asset_path("defra-interactive-map/index.js") %>"></script>
|
|
74
|
+
<script src="<%= defra_map_asset_path("maplibre-provider/index.js") %>"></script>
|
|
75
|
+
<script src="<%= defra_map_asset_path("interact-plugin/index.js") %>"></script>
|
|
76
|
+
<script src="<%= defra_map_asset_path("search-plugin/index.js") %>"></script>
|
|
77
|
+
<script src="<%= defra_map_asset_path("map-styles-plugin/index.js") %>"></script>
|
|
78
|
+
<script src="<%= defra_map_asset_path("scale-bar-plugin/index.js") %>"></script>
|
|
79
|
+
|
|
80
|
+
<%= javascript_include_tag "defra-ruby-map/grid_reference_converter" %>
|
|
81
|
+
<%= javascript_include_tag "defra-ruby-map/grid_ref_sync" %>
|
|
82
|
+
<%= javascript_include_tag "defra-ruby-map/map_init" %>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> Do not load the vendored bundles with `javascript_include_tag`/`stylesheet_link_tag` — they are not on the Sprockets precompile list and would raise `AssetNotPrecompiled` in production.
|
|
86
|
+
|
|
87
|
+
Initialize the map from your application JS:
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
DefraMap.init(document.getElementById("my-map"), {
|
|
91
|
+
mapLabel: "Select waste activity location",
|
|
92
|
+
gridRefFieldId: "my-grid-reference-field" // optional, enables bidirectional sync
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## How assets are served
|
|
97
|
+
|
|
98
|
+
The engine mounts a Rack middleware (`DefraRubyMap::AssetServer`) that serves the vendored JS, CSS and images straight from the gem at `/defra-ruby-map/<gem-version>/…`, with `Cache-Control: public, max-age=31536000, immutable`. Nothing is copied into the host app's `public/` directory, so the mechanism is safe on read-only filesystems and during rolling deploys, and each running process serves the assets for its own gem version.
|
|
99
|
+
|
|
100
|
+
`defra_map_asset_path("search-plugin/index.js")` returns `/defra-ruby-map/<gem-version>/search-plugin/index.js`. The version segment makes each release's URLs unique, so the immutable caching is safe.
|
|
101
|
+
|
|
102
|
+
## JavaScript API
|
|
103
|
+
|
|
104
|
+
### `DefraMap.init(container, options)`
|
|
105
|
+
|
|
106
|
+
Initializes the interactive map on the given container element and returns the map instance (or `null` if the bundles failed to load).
|
|
107
|
+
|
|
108
|
+
| Option | Type | Description |
|
|
109
|
+
|--------|------|-------------|
|
|
110
|
+
| `mapLabel` | string | Accessible label for the map (default: `"Interactive map"`) |
|
|
111
|
+
| `gridRefFieldId` | string | ID of a grid reference input field for bidirectional sync (optional) |
|
|
112
|
+
| `onError` | function | Called with the error if initialization fails, e.g. for monitoring (optional) |
|
|
113
|
+
|
|
114
|
+
The container reads further configuration from `data-` attributes:
|
|
115
|
+
|
|
116
|
+
| Attribute | Description |
|
|
117
|
+
|-----------|-------------|
|
|
118
|
+
| `data-proxy-url` | Base URL of the engine's proxy endpoints (e.g. `/defra-ruby-map`). Required for address search, OS tiles and the default OS basemap. |
|
|
119
|
+
| `data-images-url` | Base URL of the vendored images (use `defra_map_asset_path("images")`). Also used to locate the vendored OS style JSONs. |
|
|
120
|
+
| `data-initial-grid-reference` | Pre-centre the map on this grid reference |
|
|
121
|
+
| `data-center` | Default centre as `lng,lat` (default: `-1.5,52.5`) |
|
|
122
|
+
| `data-zoom` | Default zoom level (default: `6`) |
|
|
123
|
+
|
|
124
|
+
When `data-proxy-url` and `data-images-url` are both present, the map defaults to the proxied OS Outdoor basemap; otherwise it falls back to an OpenStreetMap style.
|
|
125
|
+
|
|
126
|
+
### Proxy endpoints
|
|
127
|
+
|
|
128
|
+
The engine provides three server-side proxy endpoints (relative to the mount point) that inject the OS API key. The key is never exposed to the browser.
|
|
129
|
+
|
|
130
|
+
| Endpoint | Purpose |
|
|
131
|
+
|----------|---------|
|
|
132
|
+
| `GET geocode-proxy?query=Bristol` | Address search via the OS Places API |
|
|
133
|
+
| `GET nearest-proxy?easting=530070&northing=180358` | Nearest-address lookup for a clicked location |
|
|
134
|
+
| `GET os-tiles-proxy/*path` | OS Vector Tile API (tiles, glyphs, TileJSON), restricted to `maps/vector/v1/vts` paths |
|
|
135
|
+
|
|
136
|
+
### `DefraGridRef`
|
|
137
|
+
|
|
138
|
+
Grid reference conversion utilities (WGS84 ↔ OSGB36 ↔ OS grid reference). Grid references must be two letters plus ten digits:
|
|
139
|
+
|
|
140
|
+
```javascript
|
|
141
|
+
DefraGridRef.coordsToGridRef(lng, lat) // => "ST 58132 72695" or null
|
|
142
|
+
DefraGridRef.gridRefToCoords("ST 58132 72695") // => [lng, lat] or null
|
|
143
|
+
DefraGridRef.isValidGridRef("ST 58132 72695") // => true
|
|
144
|
+
DefraGridRef.eastingNorthingToGridRef(358132, 172695) // => "ST 58132 72695" or null
|
|
145
|
+
DefraGridRef.gridRefToEastingNorthing("ST 58132 72695") // => [easting, northing] or null
|
|
146
|
+
DefraGridRef.coordsToEastingNorthing(lng, lat) // => [easting, northing] or null
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Content Security Policy
|
|
150
|
+
|
|
151
|
+
With the default OS basemap, all map data — tiles, glyphs, TileJSON, style JSONs, sprites and address search — is served same-origin (via the engine's proxy or the asset middleware), so no external hosts need allowlisting. MapLibre GL itself needs:
|
|
152
|
+
|
|
153
|
+
| Directive | Value | Reason |
|
|
154
|
+
|-----------|-------|--------|
|
|
155
|
+
| `script-src` / `worker-src` | `blob:` | MapLibre GL web workers |
|
|
156
|
+
| `img-src` | `data:` `blob:` | MapLibre marker/canvas internals |
|
|
157
|
+
|
|
158
|
+
If you disable the proxy and fall back to the OpenStreetMap basemap, also allowlist `https://tiles.openfreemap.org` under `connect-src`.
|
|
159
|
+
|
|
160
|
+
## Rate limiting
|
|
161
|
+
|
|
162
|
+
The proxy endpoints are unauthenticated by design — they serve a public page — and each request spends the OS API quota.
|
|
163
|
+
|
|
164
|
+
Apply **per-IP rate limiting** at the host or WAF/CDN layer. A ready-to-tune [Rack::Attack](https://github.com/rack/rack-attack) config:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# config/initializers/rack_attack.rb
|
|
168
|
+
class Rack::Attack
|
|
169
|
+
# Address search / reverse lookup — user-driven, low volume.
|
|
170
|
+
throttle("defra_ruby_map/places", limit: 60, period: 1.minute) do |req|
|
|
171
|
+
req.ip if req.path.end_with?("/geocode-proxy", "/nearest-proxy")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Vector tiles — many requests per map view, but cacheable; throttle generously.
|
|
175
|
+
throttle("defra_ruby_map/tiles", limit: 600, period: 1.minute) do |req|
|
|
176
|
+
req.ip if req.path.include?("/os-tiles-proxy/")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Updating vendored assets
|
|
182
|
+
|
|
183
|
+
Third-party JS/CSS and the pinned OS stylesheets are managed via npm:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npm install # first time
|
|
187
|
+
npm update # update @defra/interactive-map and proj4
|
|
188
|
+
npm run vendor # copy the UMD bundles, CSS, images and pinned OS styles into vendor/assets/
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The `vendor/assets/` files are committed to git so the gem works without npm at install time. CI re-runs `npm run vendor` and fails if the committed output drifts from the locked package versions. The `app/assets/` files are first-party code and are not touched by `npm run vendor`.
|
|
192
|
+
|
|
193
|
+
## Testing
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
bundle exec rspec # Ruby: engine, proxy and asset middleware
|
|
197
|
+
npm test # JS: converter, search parsing and map/field sync
|
|
198
|
+
npm run test:coverage # JS tests plus lcov coverage report for SonarCloud
|
|
199
|
+
bundle exec rubocop # lint
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## License
|
|
203
|
+
|
|
204
|
+
Licensed under the [Open Government Licence v3.0](LICENSE) (`OGL-UK-3.0`).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// DefraGridRefSync — bidirectional sync between a DefraMap instance and a
|
|
2
|
+
// grid reference input field. Depends on DefraGridRef (loaded per-page via
|
|
3
|
+
// its own script tag) for coordinate conversions. Include this file before
|
|
4
|
+
// map_init.js.
|
|
5
|
+
|
|
6
|
+
(function () {
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const GRIDREF_MARKER_ID = "grid-ref-pin";
|
|
10
|
+
const SEARCH_MARKER_ID = "search";
|
|
11
|
+
const INTERACT_MARKER_ID = "location";
|
|
12
|
+
const PROXY_TIMEOUT_MS = 2000;
|
|
13
|
+
|
|
14
|
+
// Visually-hidden aria-live region so a screen-reader user hears when the map
|
|
15
|
+
// updates the grid reference field. Inline styles avoid depending on host CSS.
|
|
16
|
+
function createLiveRegion() {
|
|
17
|
+
const region = document.createElement("div");
|
|
18
|
+
region.setAttribute("role", "status");
|
|
19
|
+
region.setAttribute("aria-live", "polite");
|
|
20
|
+
region.style.cssText = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;" +
|
|
21
|
+
"overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;";
|
|
22
|
+
return region;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Extracts the nearest address's grid reference from a proxy response, or
|
|
26
|
+
// null when the response has no usable result.
|
|
27
|
+
function nearestGridRefFrom(data) {
|
|
28
|
+
const first = data?.results?.[0];
|
|
29
|
+
const record = first ? first.DPA || first.LPI : null;
|
|
30
|
+
if (!record?.X_COORDINATE || !record?.Y_COORDINATE) { return null; }
|
|
31
|
+
return window.DefraGridRef.eastingNorthingToGridRef(record.X_COORDINATE, record.Y_COORDINATE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Nearest address lookup via the proxy, falling back to the raw grid
|
|
35
|
+
// reference on timeout, error, or an empty result. `state.pendingController`
|
|
36
|
+
// is shared with the caller so a newer lookup aborts an in-flight one.
|
|
37
|
+
function lookupNearestGridRef(proxyUrl, en, rawGridRef, state, setField) {
|
|
38
|
+
if (state.pendingController) { state.pendingController.abort(); }
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
state.pendingController = controller;
|
|
41
|
+
const timeoutId = setTimeout(function () {
|
|
42
|
+
if (controller.signal.aborted) { return; }
|
|
43
|
+
controller.abort();
|
|
44
|
+
setField(rawGridRef);
|
|
45
|
+
}, PROXY_TIMEOUT_MS);
|
|
46
|
+
|
|
47
|
+
fetch(`${proxyUrl}/nearest-proxy?easting=${Math.round(en[0])}&northing=${Math.round(en[1])}`, { signal: controller.signal })
|
|
48
|
+
.then(function (response) { return response.json(); })
|
|
49
|
+
.then(function (data) {
|
|
50
|
+
clearTimeout(timeoutId);
|
|
51
|
+
// Only clear the shared controller if it is still ours; a newer
|
|
52
|
+
// request may have replaced it, and nulling it would break the
|
|
53
|
+
// abort chain and let this stale response overwrite the field.
|
|
54
|
+
if (state.pendingController === controller) { state.pendingController = null; }
|
|
55
|
+
if (controller.signal.aborted) { return; }
|
|
56
|
+
setField(nearestGridRefFrom(data) || rawGridRef);
|
|
57
|
+
})
|
|
58
|
+
.catch(function () {
|
|
59
|
+
clearTimeout(timeoutId);
|
|
60
|
+
if (state.pendingController === controller) { state.pendingController = null; }
|
|
61
|
+
if (!controller.signal.aborted) { setField(rawGridRef); }
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// gridRefZoom: zoom level for flying to a typed grid reference (the caller,
|
|
66
|
+
// map_init.js, owns the constant).
|
|
67
|
+
function wireGridRefSync(interactiveMap, field, proxyUrl, gridRefZoom) {
|
|
68
|
+
let mapInstance = null;
|
|
69
|
+
const state = { pendingController: null };
|
|
70
|
+
|
|
71
|
+
const liveRegion = createLiveRegion();
|
|
72
|
+
(field.parentNode || document.body).appendChild(liveRegion);
|
|
73
|
+
|
|
74
|
+
// Writes a map-derived value into the field, tells host JS (validation,
|
|
75
|
+
// character count) via a "change" event, and announces it to assistive tech.
|
|
76
|
+
// A "change" event — not "input" — avoids re-triggering the field→map handler.
|
|
77
|
+
function setField(value) {
|
|
78
|
+
field.value = value;
|
|
79
|
+
field.dispatchEvent(new Event("change", { bubbles: true }));
|
|
80
|
+
liveRegion.textContent = "Grid reference updated to " + value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Map → field: nearest address lookup via proxy
|
|
84
|
+
interactiveMap.on("interact:markerchange", function (event) {
|
|
85
|
+
if (!event?.coords) { return; }
|
|
86
|
+
interactiveMap.removeMarker(GRIDREF_MARKER_ID);
|
|
87
|
+
interactiveMap.removeMarker(SEARCH_MARKER_ID);
|
|
88
|
+
|
|
89
|
+
const en = window.DefraGridRef.coordsToEastingNorthing(event.coords[0], event.coords[1]);
|
|
90
|
+
if (!en) { return; }
|
|
91
|
+
const rawGridRef = window.DefraGridRef.eastingNorthingToGridRef(en[0], en[1]);
|
|
92
|
+
if (!rawGridRef) { return; }
|
|
93
|
+
|
|
94
|
+
if (proxyUrl) {
|
|
95
|
+
lookupNearestGridRef(proxyUrl, en, rawGridRef, state, setField);
|
|
96
|
+
} else {
|
|
97
|
+
setField(rawGridRef);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Search result selected: the plugin places its own "search" marker, so
|
|
102
|
+
// clear the map-click / typed markers and set the field to its location.
|
|
103
|
+
interactiveMap.on("search:match", function (event) {
|
|
104
|
+
if (!event?.point) { return; }
|
|
105
|
+
interactiveMap.removeMarker(GRIDREF_MARKER_ID);
|
|
106
|
+
interactiveMap.removeMarker(INTERACT_MARKER_ID);
|
|
107
|
+
const gridRef = window.DefraGridRef.coordsToGridRef(event.point[0], event.point[1]);
|
|
108
|
+
if (gridRef) { setField(gridRef); }
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Capture underlying MapLibre instance for flyTo
|
|
112
|
+
interactiveMap.on("map:ready", function (event) {
|
|
113
|
+
if (event?.map) { mapInstance = event.map; }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Pin the field's pre-filled grid reference. "map:firstidle" is the
|
|
117
|
+
// earliest event at which markers project correctly — any sooner and the
|
|
118
|
+
// pin lands at pixel (0,0).
|
|
119
|
+
interactiveMap.on("map:firstidle", function () {
|
|
120
|
+
const value = field.value;
|
|
121
|
+
if (!window.DefraGridRef.isValidGridRef(value)) { return; }
|
|
122
|
+
const coords = window.DefraGridRef.gridRefToCoords(value);
|
|
123
|
+
if (coords) { interactiveMap.addMarker(GRIDREF_MARKER_ID, coords); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
field.addEventListener("input", function () {
|
|
127
|
+
if (!mapInstance) { return; }
|
|
128
|
+
const value = field.value;
|
|
129
|
+
if (!window.DefraGridRef.isValidGridRef(value)) { return; }
|
|
130
|
+
const coords = window.DefraGridRef.gridRefToCoords(value);
|
|
131
|
+
if (!coords) { return; }
|
|
132
|
+
interactiveMap.removeMarker(GRIDREF_MARKER_ID);
|
|
133
|
+
interactiveMap.removeMarker(SEARCH_MARKER_ID);
|
|
134
|
+
interactiveMap.removeMarker(INTERACT_MARKER_ID);
|
|
135
|
+
interactiveMap.addMarker(GRIDREF_MARKER_ID, coords);
|
|
136
|
+
mapInstance.flyTo({ center: coords, zoom: gridRefZoom });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
window.DefraGridRefSync = {
|
|
141
|
+
wire: wireGridRefSync
|
|
142
|
+
};
|
|
143
|
+
})();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Grid Reference Converter
|
|
2
|
+
// Converts between WGS84 (lng/lat), OSGB36 (easting/northing), and OS grid references.
|
|
3
|
+
// Ported from the os_map_ref Ruby gem (https://github.com/DEFRA/os-map-ref).
|
|
4
|
+
//
|
|
5
|
+
// Dependencies: proj4 (loaded globally as window.proj4)
|
|
6
|
+
|
|
7
|
+
(function () {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
// OSGB36 / British National Grid projection (EPSG:27700)
|
|
11
|
+
const OSGB36 = "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 " +
|
|
12
|
+
"+x_0=400000 +y_0=-100000 +ellps=airy " +
|
|
13
|
+
"+towgs84=446.448,-125.157,542.06,0.15,0.247,0.842,-20.489 +units=m +no_defs +type=crs";
|
|
14
|
+
|
|
15
|
+
// Grid of 100km squares as arranged over the UK.
|
|
16
|
+
// Origin (0,0) is bottom-left corner (SV). Rows go south-to-north.
|
|
17
|
+
// Matches the os_map_ref gem grid (reversed).
|
|
18
|
+
const GRID = [
|
|
19
|
+
["SV", "SW", "SX", "SY", "SZ", "TV", "TW", "TX", "TY", "TZ"],
|
|
20
|
+
["SQ", "SR", "SS", "ST", "SU", "TQ", "TR", "TS", "TT", "TU"],
|
|
21
|
+
["SL", "SM", "SN", "SO", "SP", "TL", "TM", "TN", "TO", "TP"],
|
|
22
|
+
["SF", "SG", "SH", "SJ", "SK", "TF", "TG", "TH", "TJ", "TK"],
|
|
23
|
+
["SA", "SB", "SC", "SD", "SE", "TA", "TB", "TC", "TD", "TE"],
|
|
24
|
+
["NV", "NW", "NX", "NY", "NZ", "OV", "OW", "OX", "OY", "OZ"],
|
|
25
|
+
["NQ", "NR", "NS", "NT", "NU", "OQ", "OR", "OS", "OT", "OU"],
|
|
26
|
+
["NL", "NM", "NN", "NO", "NP", "OL", "OM", "ON", "OO", "OP"],
|
|
27
|
+
["NF", "NG", "NH", "NJ", "NK", "OF", "OG", "OH", "OJ", "OK"],
|
|
28
|
+
["NA", "NB", "NC", "ND", "NE", "OA", "OB", "OC", "OD", "OE"],
|
|
29
|
+
["HV", "HW", "HX", "HY", "HZ", "JV", "JW", "JX", "JY", "JZ"],
|
|
30
|
+
["HQ", "HR", "HS", "HT", "HU", "JQ", "JR", "JS", "JT", "JU"],
|
|
31
|
+
["HL", "HM", "HN", "HO", "HP", "JL", "JM", "JN", "JO", "JP"]
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// The caps mirror GRID's extent: 10 x 13 hundred-km squares.
|
|
35
|
+
const MAX_EASTING = 1000000;
|
|
36
|
+
const MAX_NORTHING = 1300000;
|
|
37
|
+
|
|
38
|
+
// A grid reference is a two-letter square prefix plus five digits per axis.
|
|
39
|
+
const PREFIX_LENGTH = 2;
|
|
40
|
+
const COORD_DIGITS = 5;
|
|
41
|
+
const EASTING_END = PREFIX_LENGTH + COORD_DIGITS;
|
|
42
|
+
const NORTHING_END = EASTING_END + COORD_DIGITS;
|
|
43
|
+
|
|
44
|
+
// Build reverse lookup: prefix -> [rowIndex, colIndex]
|
|
45
|
+
const PREFIX_LOOKUP = {};
|
|
46
|
+
for (let row = 0; row < GRID.length; row++) {
|
|
47
|
+
for (let col = 0; col < GRID[row].length; col++) {
|
|
48
|
+
PREFIX_LOOKUP[GRID[row][col]] = [row, col];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function padCoord(value) {
|
|
53
|
+
return String(value % 100000).padStart(COORD_DIGITS, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function eastingNorthingToGridRef(easting, northing) {
|
|
57
|
+
easting = Math.round(easting);
|
|
58
|
+
northing = Math.round(northing);
|
|
59
|
+
|
|
60
|
+
if (!Number.isFinite(easting) || !Number.isFinite(northing)) { return null; }
|
|
61
|
+
if (easting < 0 || northing < 0 || easting >= MAX_EASTING || northing >= MAX_NORTHING) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const gridEasting = Math.floor(easting / 100000);
|
|
66
|
+
const gridNorthing = Math.floor(northing / 100000);
|
|
67
|
+
|
|
68
|
+
const prefix = GRID[gridNorthing][gridEasting];
|
|
69
|
+
|
|
70
|
+
return `${prefix} ${padCoord(easting)} ${padCoord(northing)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gridRefToEastingNorthing(gridRef) {
|
|
74
|
+
if (!gridRef || typeof gridRef !== "string") { return null; }
|
|
75
|
+
|
|
76
|
+
// Normalise to two letters + ten digits, accepting exactly what
|
|
77
|
+
// isValidGridRef accepts regardless of whitespace grouping. Refs with
|
|
78
|
+
// fewer digits are rejected rather than mis-scaled to a wrong location.
|
|
79
|
+
const cleaned = gridRef.replace(/\s+/g, "").toUpperCase();
|
|
80
|
+
if (!/^[A-Z]{2}\d{10}$/.test(cleaned)) { return null; }
|
|
81
|
+
|
|
82
|
+
const coords = PREFIX_LOOKUP[cleaned.substring(0, PREFIX_LENGTH)];
|
|
83
|
+
if (!coords) { return null; }
|
|
84
|
+
|
|
85
|
+
const easting = coords[1] * 100000 + Number.parseInt(cleaned.substring(PREFIX_LENGTH, EASTING_END), 10);
|
|
86
|
+
const northing = coords[0] * 100000 + Number.parseInt(cleaned.substring(EASTING_END, NORTHING_END), 10);
|
|
87
|
+
|
|
88
|
+
return [easting, northing];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function coordsToEastingNorthing(lng, lat) {
|
|
92
|
+
if (typeof proj4 === "undefined") { return null; }
|
|
93
|
+
return proj4("EPSG:4326", OSGB36, [lng, lat]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function coordsToGridRef(lng, lat) {
|
|
97
|
+
const en = coordsToEastingNorthing(lng, lat);
|
|
98
|
+
if (!en) { return null; }
|
|
99
|
+
return eastingNorthingToGridRef(en[0], en[1]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function gridRefToCoords(gridRef) {
|
|
103
|
+
if (typeof proj4 === "undefined") { return null; }
|
|
104
|
+
const en = gridRefToEastingNorthing(gridRef);
|
|
105
|
+
if (!en) { return null; }
|
|
106
|
+
return proj4(OSGB36, "EPSG:4326", en);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isValidGridRef(str) {
|
|
110
|
+
if (!str || typeof str !== "string") { return false; }
|
|
111
|
+
const cleaned = str.replace(/\s+/g, "").toUpperCase();
|
|
112
|
+
return /^[A-Z]{2}\d{10}$/.test(cleaned) && !!PREFIX_LOOKUP[cleaned.substring(0, PREFIX_LENGTH)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
window.DefraGridRef = {
|
|
116
|
+
coordsToGridRef: coordsToGridRef,
|
|
117
|
+
gridRefToCoords: gridRefToCoords,
|
|
118
|
+
isValidGridRef: isValidGridRef,
|
|
119
|
+
eastingNorthingToGridRef: eastingNorthingToGridRef,
|
|
120
|
+
gridRefToEastingNorthing: gridRefToEastingNorthing,
|
|
121
|
+
coordsToEastingNorthing: coordsToEastingNorthing
|
|
122
|
+
};
|
|
123
|
+
})();
|