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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +8 -0
  3. data/README.md +204 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/defra-ruby-map/grid_ref_sync.js +143 -0
  6. data/app/assets/javascripts/defra-ruby-map/grid_reference_converter.js +123 -0
  7. data/app/assets/javascripts/defra-ruby-map/map_init.js +275 -0
  8. data/app/controllers/defra_ruby_map/application_controller.rb +6 -0
  9. data/app/controllers/defra_ruby_map/proxy_controller.rb +102 -0
  10. data/config/routes.rb +7 -0
  11. data/lib/defra_ruby_map/asset_server.rb +61 -0
  12. data/lib/defra_ruby_map/configuration.rb +7 -0
  13. data/lib/defra_ruby_map/engine.rb +40 -0
  14. data/lib/defra_ruby_map/map_helper.rb +9 -0
  15. data/lib/defra_ruby_map/version.rb +5 -0
  16. data/lib/defra_ruby_map.rb +19 -0
  17. data/vendor/assets/images/defra-ruby-map/black-and-white-map-thumb.jpg +0 -0
  18. data/vendor/assets/images/defra-ruby-map/dark-map-thumb.jpg +0 -0
  19. data/vendor/assets/images/defra-ruby-map/os-logo-white.svg +1 -0
  20. data/vendor/assets/images/defra-ruby-map/os-logo.svg +1 -0
  21. data/vendor/assets/images/defra-ruby-map/outdoor-map-thumb.jpg +0 -0
  22. data/vendor/assets/javascripts/defra-interactive-map/im-core.js +2 -0
  23. data/vendor/assets/javascripts/defra-interactive-map/im-core.js.LICENSE.txt +31 -0
  24. data/vendor/assets/javascripts/defra-interactive-map/index.js +2 -0
  25. data/vendor/assets/javascripts/defra-interactive-map/index.js.LICENSE.txt +21 -0
  26. data/vendor/assets/javascripts/interact-plugin/im-interact-plugin.js +1 -0
  27. data/vendor/assets/javascripts/interact-plugin/index.js +2 -0
  28. data/vendor/assets/javascripts/interact-plugin/index.js.LICENSE.txt +1 -0
  29. data/vendor/assets/javascripts/map-styles-plugin/im-map-styles-plugin.js +1 -0
  30. data/vendor/assets/javascripts/map-styles-plugin/index.js +2 -0
  31. data/vendor/assets/javascripts/map-styles-plugin/index.js.LICENSE.txt +1 -0
  32. data/vendor/assets/javascripts/maplibre-provider/im-maplibre-framework.js +2 -0
  33. data/vendor/assets/javascripts/maplibre-provider/im-maplibre-framework.js.LICENSE.txt +4 -0
  34. data/vendor/assets/javascripts/maplibre-provider/im-maplibre-provider.js +2 -0
  35. data/vendor/assets/javascripts/maplibre-provider/im-maplibre-provider.js.LICENSE.txt +1 -0
  36. data/vendor/assets/javascripts/maplibre-provider/index.js +2 -0
  37. data/vendor/assets/javascripts/maplibre-provider/index.js.LICENSE.txt +1 -0
  38. data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Black_and_White.json +11566 -0
  39. data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Dark.json +11309 -0
  40. data/vendor/assets/javascripts/os-styles/OS_VTS_3857_Outdoor.json +11293 -0
  41. data/vendor/assets/javascripts/os-styles/sprites/dark.json +1 -0
  42. data/vendor/assets/javascripts/os-styles/sprites/dark.png +0 -0
  43. data/vendor/assets/javascripts/os-styles/sprites/dark@2x.json +1 -0
  44. data/vendor/assets/javascripts/os-styles/sprites/dark@2x.png +0 -0
  45. data/vendor/assets/javascripts/os-styles/sprites/greyscale.json +1 -0
  46. data/vendor/assets/javascripts/os-styles/sprites/greyscale.png +0 -0
  47. data/vendor/assets/javascripts/os-styles/sprites/greyscale@2x.json +1 -0
  48. data/vendor/assets/javascripts/os-styles/sprites/greyscale@2x.png +0 -0
  49. data/vendor/assets/javascripts/os-styles/sprites/sprite.json +1 -0
  50. data/vendor/assets/javascripts/os-styles/sprites/sprite.png +0 -0
  51. data/vendor/assets/javascripts/os-styles/sprites/sprite@2x.json +1 -0
  52. data/vendor/assets/javascripts/os-styles/sprites/sprite@2x.png +0 -0
  53. data/vendor/assets/javascripts/proj4js/LICENSE.md +29 -0
  54. data/vendor/assets/javascripts/proj4js/proj4.js +1 -0
  55. data/vendor/assets/javascripts/scale-bar-plugin/im-scale-bar-plugin.js +1 -0
  56. data/vendor/assets/javascripts/scale-bar-plugin/index.js +2 -0
  57. data/vendor/assets/javascripts/scale-bar-plugin/index.js.LICENSE.txt +1 -0
  58. data/vendor/assets/javascripts/search-plugin/im-search-plugin.js +2 -0
  59. data/vendor/assets/javascripts/search-plugin/im-search-plugin.js.LICENSE.txt +1 -0
  60. data/vendor/assets/javascripts/search-plugin/index.js +2 -0
  61. data/vendor/assets/javascripts/search-plugin/index.js.LICENSE.txt +1 -0
  62. data/vendor/assets/stylesheets/defra-interactive-map/interactive-map.css +1 -0
  63. data/vendor/assets/stylesheets/defra-interactive-map/map-styles-plugin.css +1 -0
  64. data/vendor/assets/stylesheets/defra-interactive-map/scale-bar-plugin.css +31 -0
  65. data/vendor/assets/stylesheets/defra-interactive-map/search-plugin.css +1 -0
  66. 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
+ [![Quality gate status](https://sonarcloud.io/api/project_badges/measure?project=DEFRA_defra-ruby-map&metric=alert_status)](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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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
+ })();