├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── pnpm-test.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .nvmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── how-to-use-a-proxy.md ├── index.html ├── package.json ├── pitsby.config.js ├── pnpm-lock.yaml ├── src ├── components │ ├── address-autocomplete │ │ ├── address-autocomplete.doc.js │ │ ├── index.ts │ │ ├── main.test.ts │ │ └── styles.scss │ ├── my-map │ │ ├── controls.ts │ │ ├── docs │ │ │ ├── my-map-basic.doc.js │ │ │ ├── my-map-draw.doc.js │ │ │ ├── my-map-features.doc.js │ │ │ ├── my-map-geojson.doc.js │ │ │ └── my-map-proxy.doc.js │ │ ├── drawing.ts │ │ ├── icons │ │ │ ├── README.md │ │ │ ├── north-arrow-n.svg │ │ │ ├── poi-alt.svg │ │ │ ├── printer.svg │ │ │ └── trash-can.svg │ │ ├── index.ts │ │ ├── layers.test.ts │ │ ├── layers.ts │ │ ├── main.test.ts │ │ ├── os-features.ts │ │ ├── pin.svg │ │ ├── projections.ts │ │ ├── snapping.ts │ │ ├── styles.scss │ │ └── utils.ts │ └── postcode-search │ │ ├── index.ts │ │ ├── main.test.ts │ │ ├── postcode-search.doc.js │ │ └── styles.scss ├── index.ts ├── lib │ ├── ordnanceSurvey.test.ts │ └── ordnanceSurvey.ts ├── test-utils.ts └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_APP_OS_API_KEY=👻 2 | VITE_APP_MAPBOX_ACCESS_TOKEN=👻 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" # use this yaml value when package manager is 'pnpm' 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | reviewers: 8 | - "theopensystemslab/planx" 9 | ignore: 10 | - dependency-name: "ol" 11 | update-types: ["version-update:semver-major"] 12 | - dependency-name: "happy-dom" 13 | update-types: ["version-update:semver-major"] 14 | 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "monthly" 19 | reviewers: 20 | - "theopensystemslab/planx" 21 | -------------------------------------------------------------------------------- /.github/workflows/pnpm-test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | pull_request: 4 | types: [opened, synchronize] 5 | branches: 6 | - main 7 | 8 | env: 9 | PNPM_VERSION: 8.6.6 10 | NODE_VERSION: 18.16.1 11 | 12 | jobs: 13 | vitest: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v4.1.0 18 | with: 19 | version: ${{ env.PNPM_VERSION }} 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ env.NODE_VERSION }} 23 | cache: "pnpm" 24 | cache-dependency-path: "**/pnpm-lock.yaml" 25 | - run: pnpm install --no-frozen-lockfile 26 | - run: pnpm test 27 | env: 28 | VITE_APP_OS_API_KEY: ${{ secrets.OS_API_KEY }} 29 | VITE_APP_MAPBOX_ACCESS_TOKEN: ${{ secrets.MAPBOX_ACCESS_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | pitsby 5 | types 6 | *.local 7 | *.log 8 | /.vscode -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.16.1 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | **Note:** Version 0 of Semantic Versioning is handled differently from version 1 and above. 9 | The minor version will be incremented upon a breaking change and the patch version will be 10 | incremented for features. 11 | 12 | ### [1.0.0-alpha.5] - 2025-03-11 13 | 14 | ### Fixed 15 | - chore: ensured Vite environment variables are not bundled in build files ([#533](https://github.com/theopensystemslab/map/pull/533)) 16 | 17 | ### Changed 18 | - deps: now on OpenLayers v10 ! ([#535](https://github.com/theopensystemslab/map/pull/535)) 19 | - deps: a number of other package updates via Dependabot 20 | - docs: deployment instructions now added for releases & pre-releases ([#511](https://github.com/theopensystemslab/map/pull/511)) 21 | 22 | ### [1.0.0-alpha.4] - 2024-10-23 23 | 24 | ### Added 25 | - feat: new boolean prop `hideDrawLabels` allows default labels to be hidden when `drawMany` is enabled ([#508](https://github.com/theopensystemslab/map/pull/508)) 26 | 27 | ### Changed 28 | - style: increased visual constrast of drawing points by applying colored stroke rather fill and bumping overall size ([#507](https://github.com/theopensystemslab/map/pull/507), [#509](https://github.com/theopensystemslab/map/pull/509)) 29 | 30 | ### [1.0.0-alpha.3] - 2024-09-06 31 | 32 | ### Fixed 33 | - fix: ensure labels are incremental when drawing many features ([#495](https://github.com/theopensystemslab/map/pull/495)) 34 | 35 | ### Changed 36 | - style: switch direction of reset icon ([#498](https://github.com/theopensystemslab/map/pull/498)) 37 | 38 | ### Added 39 | - feat: add boolean prop `resetViewOnly` to prevent reset control from clearing drawing data and only reset viewport ([#496](https://github.com/theopensystemslab/map/pull/496)) 40 | 41 | ### [1.0.0-alpha.2] - 2024-09-02 42 | 43 | ### Fixed 44 | - fix: maintain existing drawing labels when modifying features ([#493](https://github.com/theopensystemslab/map/pull/493)) 45 | - fix: `drawGeojsonData` accepts individual "Feature" or "FeatureCollection" to work correctly with `drawMany` ([#491](https://github.com/theopensystemslab/map/pull/491)) 46 | 47 | ### Added 48 | - feat: `drawGeojsonData` will read from GeoJSON property "color" if set, or else fallback to the `drawColor` prop which allows individual features to use different styles when `drawMany` ([#492](https://github.com/theopensystemslab/map/pull/492)) 49 | 50 | ### [1.0.0-alpha.1] - 2024-08-29 51 | 52 | ### Changed 53 | - fix: ensure `showCentreMarker` and `geojsonData` layers are correctly ordered on top of basemap when using "MapboxSatellite" ([#481](https://github.com/theopensystemslab/map/pull/481)) 54 | - fix: ensure point features displayed via `geojsonData` have an associated style ([#482](https://github.com/theopensystemslab/map/pull/482)) 55 | - fix: increased `drawMany` label font size and default point size for `drawType="Point"` ([#483](https://github.com/theopensystemslab/map/pull/483)) 56 | 57 | ### Added 58 | - feat: adds prop `dataTestId` to set a `data-testid` on the map's shadow root ([#484](https://github.com/theopensystemslab/map/pull/484)) 59 | 60 | ### [1.0.0-alpha.0] - 2024-08-24 61 | 62 | We're starting to work towards a v1.0.0 stable release! 63 | 64 | ### Breaking 65 | A number of props and dispatched events have been deprecated and condensed: 66 | - `osVectorTilesApiKey`, `osFeaturesApiKey` and `osPlacesApiKey` are deprecated in favor of a single `osApiKey` prop ([#476](https://github.com/theopensystemslab/map/pull/476)) 67 | - `disableVectorTiles` is deprecated in favor of a _new_ `basemap` prop with enum values `"OSVectorTile" | "OSRaster" | "MapboxSatellite" | "OSM"`. The default is still `"OSVectorTile"` and we'll still fallback to OpenStreetMap if any of the API-dependent basemaps can't be initialised ([#476](https://github.com/theopensystemslab/map/pull/476)) 68 | - `drawPointColor`, `drawFillColor` and `featureBorderNone` props are deprecated and rolled into existing style props ([#473](https://github.com/theopensystemslab/map/pull/473)) 69 | - The `areaChange` event dispatched in `drawMode` has been deprecated and rolled into existing `geojsonChange` event. If your `drawType="Polygon"`, you'll now simply find an `area` property on the dispatched geojson feature ([#466-discussion](https://github.com/theopensystemslab/map/pull/466#discussion_r1703872391)) 70 | - Similarly, the `featuresAreaChange` event dispatched by `clickFeatures` has been deprecated and rolled into `featuresGeojsonChange` event ([#479](https://github.com/theopensystemslab/map/pull/479)) 71 | - `areaUnit` prop has been deprecated and you'll find the calculated area in _both_ `squareMetres` and `hectares` by default now in `geojsonChange` event data above ([#479](https://github.com/theopensystemslab/map/pull/479)) 72 | 73 | We think the above deprecations will mean simpler, _more_ flexible configurations and subscriptions for end-users, but if you were relying on any of the deprecated props, cannot achieve feature parity with the alternatives, or find a regression we've overlooked, please open an [Issue](https://github.com/theopensystemslab/map/issues)! 74 | 75 | ### Changed 76 | - fix: `osCopyright` prop no longer has a default license number, please add your own! ([#476](https://github.com/theopensystemslab/map/pull/476)) 77 | - deps: various dependency updates via Dependabot 78 | 79 | ### Added 80 | - feat: new `basemap` option `"MapboxSatellite"` displays aerial imagery; see README "Bring your own API keys" for configuring a Mapbox access token ([#475](https://github.com/theopensystemslab/map/pull/475)) 81 | - feat: `drawMany` prop allows more than one feature to be drawn and will display labels (simple incremental index for now, _not_ customisable). The label and area (if `drawType="Polygon"`) will be included in the `properties` of each feature dispatched via the `geojsonChange` event ([#466](https://github.com/theopensystemslab/map/pull/466)) 82 | - feat: `drawType` prop adds supported value `"Circle"` for drawing & modifying circles; please note this type is still quite experimental and does _not_ yet dispatch a `geojsonChange` event ("circles" are not a natively supported type in geojson and we'll need to transform to polygons first) ([#465](https://github.com/theopensystemslab/map/pull/465)) 83 | 84 | ### [0.8.3] - 2024-06-28 85 | 86 | ### Fixed 87 | - fix(a11y): adds a `role` to the map container div of either `application` if interactive or `presentation` if implemented in static mode ([#454](https://github.com/theopensystemslab/map/pull/454)) 88 | 89 | ### Changed 90 | - deps: various dependency updates via Dependabot 91 | 92 | ### [0.8.2] - 2024-05-09 93 | 94 | ### Added 95 | - feat(a11y): adds optional prop `ariaLabelOlFixedOverlay` which sets an `aria-label` on the outermost `canvas` element rendered in the shadow root ([#445](https://github.com/theopensystemslab/map/pull/445)) 96 | - fix(a11y): sets `aria-controls` on the OL Attribution control button rendered when `collapseAttributions` is true, and a corresponding `id` on the attribution list ([#446](https://github.com/theopensystemslab/map/pull/446)) 97 | 98 | ### [0.8.1] - 2024-04-05 99 | 100 | ### Changed 101 | - fix: usability improvements such as stronger focus color contrast and improved keyboard navigation based on recent accessibility audit ([#442](https://github.com/theopensystemslab/map/pull/442)) 102 | - deps: upgraded to Vite v5, in addition to a number of other Dependabot updates ([#441](https://github.com/theopensystemslab/map/pull/441)) 103 | 104 | ### [0.8.0] - 2024-01-25 105 | 106 | ### Breaking 107 | - feat: adds new boolean prop `showGeojsonDataMarkers` to display point features passed via the `geojsonData` prop; renames existing `showMarker` boolean prop to `showCentreMarker` for clarity ([#429](https://github.com/theopensystemslab/map/pull/429)) 108 | 109 | ### [0.7.9] - 2024-01-02 110 | 111 | ### Added 112 | - feat: new props `drawGeojsonDataCopyright`, `geojsonDataCopyright`, and `collapseAttributes` allow multiple attributions to be set and styled on the map ([#424](https://github.com/theopensystemslab/map/pull/424)) 113 | 114 | ### Changed 115 | - deps: various dependency updates via Dependabot 116 | 117 | ### [0.7.8] - 2023-12-13 118 | 119 | ### Changed 120 | - fix: now displays vertices for polygons as well as multipolygons that are passed into `drawGeojsonData` prop ([#417](https://github.com/theopensystemslab/map/pull/417)) 121 | 122 | ### [0.7.7] - 2023-09-01 123 | 124 | ### Added 125 | - feat: ability to set a custom border color _per_ feature when passing a `FeatureCollection` into `geojsonData` by reading from the feature's `properties.color` attribute. If `properties.color` is not defined, `geojsonColor` will be used to style each feature. ([#381](https://github.com/theopensystemslab/map/pull/381)) 126 | 127 | ### [0.7.6] - 2023-08-30 128 | 129 | ### Added 130 | - feat: add `drawColor` & `drawFillColor` props to customise the drawing color. It still defaults to red for the canonical example of location plans. ([#379](https://github.com/theopensystemslab/map/pull/379)) 131 | 132 | ### [0.7.5] - 2023-08-14 133 | 134 | ### Added 135 | - feat: add `clipGeojsonData` prop to disable panning/zooming/navigating the map's viewport beyond a given geojson extent. ([#363](https://github.com/theopensystemslab/map/pull/363)) 136 | 137 | ### Changed 138 | - deps: various dependency updates via Dependabot 139 | 140 | ### [0.7.4] - 2023-03-17 141 | 142 | ### Changed 143 | - fix: ensure autocomplete selected address formatting always matches option, completing #275 below. ([#277](https://github.com/theopensystemslab/map/pull/277)) 144 | 145 | ### [0.7.3] - 2023-03-17 146 | 147 | ### Changed 148 | - fix: split single line addresses on last occurance of council name, not first, in address-autocomplete dropdown options. Our previous string formatting method failed on postcode ME7 1NH ([#275](https://github.com/theopensystemslab/map/pull/275)) 149 | 150 | ### [0.7.2] - 2023-02-24 151 | 152 | ### Added 153 | - feat: Printing ([#263](https://github.com/theopensystemslab/map/pull/263)) 154 | 155 | ### Changed 156 | - chore: Update `@testing-library/dom` and `vitest` dependencies to latests ([#265](https://github.com/theopensystemslab/map/pull/265)) 157 | 158 | ### [0.7.1] - 2023-02-07 159 | 160 | ### Changed 161 | - fix: correctly project coordinates to EPSG:27700 on GeoJSON change events (coordinates in EPSG:3857 are and were ok!) ([#261](https://github.com/theopensystemslab/map/pull/261)) 162 | 163 | ### [0.7.0] - 2023-01-20 164 | 165 | ### Changed 166 | - **BREAKING**: GeoJSON change events are now dispatched in _two_ projections: EPSG:3857 (prior default) and EPSG:27700. If you are subscribed to these events, please update your code to reflect the new data format ([#255](https://github.com/theopensystemslab/map/pull/255)) 167 | - fix: display scale bar correctly ([#252](https://github.com/theopensystemslab/map/pull/252)) 168 | - fix: debug `drawGeojsonData` examples in Pitsy Component Docs ([#249](https://github.com/theopensystemslab/map/pull/249)) 169 | 170 | ### [0.6.3] - 2022-12-21 171 | 172 | ### Added 173 | - feat: add `osProxyEndpoint` prop to support optionally calling the Ordnance Survey APIs via a proxy in public applications to avoid exposing your API keys ([#241](https://github.com/theopensystemslab/map/pull/241)) 174 | 175 | ### Changed 176 | - build: update vitest dependencies 177 | 178 | ### [0.6.2] - 2022-12-09 179 | 180 | ### Added 181 | - feat: add `drawingType` prop to specify "Polygon" (default) or "Point" to enable drawing a single point ([#232](https://github.com/theopensystemslab/map/pull/232)) 182 | 183 | ### Changed 184 | - chore: update styling of default scale line ([#230](https://github.com/theopensystemslab/map/pull/230)) 185 | - chore: swap out north arrow icon and remove unused `resetControlImage` icons ([#233](https://github.com/theopensystemslab/map/pull/233)) 186 | - build: update vite and vitest-related dependencies 187 | 188 | ### [0.6.1] - 2022-10-17 189 | 190 | ### Added 191 | - feat: `resetControlImage` prop can be used to specify a custom icon for the reset control button. This is likely a temporary prop while user research testing is conducted, then we will refactor to use a single standard icon ([#209](https://github.com/theopensystemslab/map/pull/209)) 192 | 193 | ### [0.6.0] - 2022-10-10 194 | 195 | ### Added 196 | - feat: `showNorthArrow` boolean prop will show a static North arrow icon in the upper right of the map for official reference ([#198]https://github.com/theopensystemslab/map/pull/198) 197 | 198 | ### [0.5.9] - 2022-08-26 199 | 200 | ### Changed 201 | - fix: Ensure snap points load on the map's `loadend` event ([#193](https://github.com/theopensystemslab/map/pull/193)) 202 | - test: Added basic suite of OL tests for snap loading, exposing an `olMap` instance on the global window for testing 203 | 204 | ### [0.5.8] - 2022-08-19 205 | 206 | ### Added 207 | - feat: Added map property `projection` to specify which system you are supplying coordinates in. Supported values are `EPSG:4326` (default), `EPSG:27700`, and `EPSG:3857` ([#168](https://github.com/theopensystemslab/map/pull/168)) 208 | - feat: Added Vitest framework for unit testing our web components and a Github Action workflow to run tests on all pull requests ([#139](https://github.com/theopensystemslab/map/pull/139), [#191](https://github.com/theopensystemslab/map/pull/191)) 209 | - feat: Added Pitsby interactive documentation for our web components, available at [oslmap.netlify.app](https://oslmap.netlify.app/) ([#61](https://github.com/theopensystemslab/map/pull/61)) 210 | 211 | ### Changed 212 | - docs: Updated README to reflect scope of all components and new local dev instructions ([#181](https://github.com/theopensystemslab/map/pull/181)) 213 | - build: Upgraded multiple project dependencies 214 | 215 | ### [0.5.7] - 2022-07-28 216 | 217 | ### Added 218 | - feat: `markerImage` property added to specify a circle (default) or pin icon ([#165](https://github.com/theopensystemslab/map/pull/165)) 219 | 220 | ### Changed 221 | - build: Upgrade development dependency Vite to v3 ([#167](https://github.com/theopensystemslab/map/pull/167)) 222 | 223 | ### [0.5.6] - 2022-07-05 224 | 225 | ### Added 226 | - feat: `showMarker` property added to display a point on the map (defaults to latitude & longitude used to center the map, custom coordinates can be provided using `markerLatitude`, `markerLongitude`) ([#159](https://github.com/theopensystemslab/map/pull/159)) 227 | 228 | ### Changed 229 | - fix: Ability to remove border style using boolean property `featureBorderNone` when in `showFeaturesAtPoint` mode ([#159](https://github.com/theopensystemslab/map/pull/159)) 230 | 231 | ### [0.5.5] - 2022-05-09 232 | 233 | ### Changed 234 | - fix: Update map focus to Gov.UK yellow, adding a black border on map element for sufficient contrast ([#147](https://github.com/theopensystemslab/map/pull/147)) 235 | 236 | ## [0.5.4] - 2022-03-29 237 | 238 | ### Changed 239 | - fix: Ensure error container is always in DOM (autocomplete) ([#136](https://github.com/theopensystemslab/map/pull/136)) 240 | 241 | ## [0.5.3] - 2022-03-28 242 | 243 | ### Changed 244 | - fix: Re-enable `labelStyle` property ([#133](https://github.com/theopensystemslab/map/pull/133)) 245 | 246 | ## [0.5.2] - 2022-03-28 247 | 248 | ### Added 249 | - feat: `labelStyle` property added to autocomplete ([#130](https://github.com/theopensystemslab/map/pull/130)) 250 | 251 | ### Changed 252 | - fix: Accessibility fixes flagged by auditors ([#131](https://github.com/theopensystemslab/map/pull/131)) 253 | 254 | ## [0.5.1] - 2022-03-24 255 | 256 | ### Added 257 | - feat: `arrowStyle` property added to autocomplete ([#128](https://github.com/theopensystemslab/map/pull/128)) 258 | 259 | ### Changed 260 | - fix: Improve style of autocomplete ([#128](https://github.com/theopensystemslab/map/pull/128)) 261 | 262 | ## [0.5.0] - 2022-03-23 263 | 264 | ### Changed 265 | - fix: autocomplete shouldn't have a tabindex on its' container, only the input ([#126](https://github.com/theopensystemslab/map/pull/126)) 266 | 267 | ## [0.4.9] - 2022-03-23 268 | 269 | ### Added 270 | - feat: allow autocomplete to be styled from the parent ([#124](https://github.com/theopensystemslab/map/pull/124)) 271 | 272 | ### Changed 273 | - fix: autocomplete & search should set `tabindex="0"` to ensure they're keyboard accessible ([#122](https://github.com/theopensystemslab/map/pull/122)) 274 | 275 | ## [0.4.8] - 2022-03-22 276 | 277 | ### Added 278 | - feat: address-autocomplete supports a default value using the `initialAddress` property ([#120](https://github.com/theopensystemslab/map/pull/120)) 279 | 280 | ## [0.4.7] - 2022-03-22 281 | 282 | ### Added 283 | - feat: two new components ([#93](https://github.com/theopensystemslab/map/pull/93))! file structure & build config are adjusted to reflect a library of components, but no breaking changes to the original map. New components: 284 | 1. `` is a GOV.UK-styled input that validates UK postcodes using [these utility methods](https://www.npmjs.com/package/postcode). When a postcode is validated, an event is dispatched containing the sanitized string. 285 | 2. `` fetches addresses in a given UK postcode using the [OS Places API](https://developer.ordnancesurvey.co.uk/os-places-api) and displays them using GOV.UK's [accessible-autocomplete](https://github.com/alphagov/accessible-autocomplete) component. When you select an address, an event is dispatched with the full OS record for that address. Set the `osPlacesApiKey` property to start using this component. 286 | 287 | ## [0.4.6] - 2022-02-04 288 | 289 | ### Changed 290 | - fix: make snap points visible on the first render before any interactions if other conditions are met (`drawMode` is enabled, `zoom` is greater than or equal to 20). Previosly, we'd only render snaps after a map move ([#112](https://github.com/theopensystemslab/map/pull/112)) 291 | 292 | ## [0.4.5] - 2022-01-14 293 | 294 | ### Added 295 | - feat: string property `id` now allows users to set a custom id on the custom element ``. it still defaults to `id="map"` as before ([#110](https://github.com/theopensystemslab/map/pull/110)) 296 | 297 | ### Changed 298 | - fix: `featureSource` and `drawingSource` are now cleared upfront when their respective interaction modes (eg `showFeaturesAtPoint`, `drawMode`) are enabled. This doesn't change anything on the first map render, but should help clear up scenarios where the map has been redrawn with new props but the layer still holds prior data features ([#110](https://github.com/theopensystemslab/map/pull/110)) 299 | 300 | ## [0.4.4] - 2022-01-11 301 | 302 | ### Changed 303 | - fix: when in `drawMode`, "reset" control button now dispatches two events to reset area to 0 and empty geojson. Previously, the area and geojson continued to reflect the last drawn polygon ([#102](https://github.com/theopensystemslab/map/pull/102)) 304 | - bump rambda and @types/node dependencies ([#107](https://github.com/theopensystemslab/map/pull/107) & [#108](https://github.com/theopensystemslab/map/pull/108)) 305 | 306 | ## [0.4.3] - 2021-12-14 307 | 308 | ### Changed 309 | - fix: control buttons are an accessible size ([#95](https://github.com/theopensystemslab/map/pull/95)) 310 | - fix: add Lit lifecycle method to unmount map ([#97](https://github.com/theopensystemslab/map/pull/97)) 311 | 312 | ## [0.4.2] - 2021-11-26 313 | 314 | ### Changed 315 | - upgrade openlayers ([#89](https://github.com/theopensystemslab/map/pull/89)) 316 | 317 | ## [0.4.1] - 2021-11-25 318 | 319 | ### Changed 320 | - feat: string property `osCopyright` now allows users to set the map attribution for OS layers based on their own API keys. The default copyright text is updated to reflect our new data agreement with DHLUC ([#88](https://github.com/theopensystemslab/map/pull/88)) 321 | 322 | ## [0.4.0] - 2021-11-24 323 | 324 | ### Changed 325 | - **BREAKING**: removed `ariaLabel` property based on accessibility audit recommendation, as aria-label attributes shouldn't be used on div elements ([#86](https://github.com/theopensystemslab/map/pull/86)) 326 | 327 | ## [0.3.7] - 2021-11-19 328 | 329 | ### Added 330 | - feat: string property `drawPointer` to set the drawing cursor style, defaults to "crosshair" or can be set to "dot" ([#84](https://github.com/theopensystemslab/map/pull/84)) 331 | 332 | ### Changed 333 | - fix: keep snapping behavior while modifying drawn polygon ([#83](https://github.com/theopensystemslab/map/pull/83)) 334 | 335 | ## [0.3.6] - 2021-11-12 336 | 337 | ### Added 338 | - feat: `drawMode` now derives snap-able points from the OS Vector Tiles basemap and displays them by default when the zoom level > 20. The drawing pointer also changed from a red dot to a simple crosshair. ([#75](https://github.com/theopensystemslab/map/pull/75)) 339 | 340 | ### Changed 341 | - fix: updated control button color for more accessible level of contrast ([#77](https://github.com/theopensystemslab/map/pull/77)) 342 | - fix: ensure prettier is run on precommit hook ([#78](https://github.com/theopensystemslab/map/pull/78)) 343 | - fix: typo in Readme ([#73](https://github.com/theopensystemslab/map/pull/73)) 344 | 345 | ## [0.3.5] - 2021-10-27 346 | 347 | ### Added 348 | - feat: ability to display a geojson polygon in the initial drawing layer when in `drawMode`, using new object property `drawGeojsonData` and number property `drawGeojsonBuffer` ([#70](https://github.com/theopensystemslab/map/pull/70)) 349 | - feat: dispatch events `featuresAreaChange`, `featuresGeojsonChange` and `geojsonDataArea`, so that show/click features mode and loading static data has parity with existing event dispatching used in draw mode ([#69](https://github.com/theopensystemslab/map/pull/69)) 350 | 351 | ## [0.3.4] - 2021-10-01 352 | 353 | ### Added 354 | - feat: boolean property `featureFill` to style the fill color of OS Features polygon as the specified stroke color with 20% opacity, disabled/false by default. Same idea as below, my oversight for not combining them into the same release! ([#66](https://github.com/theopensystemslab/map/pull/66)) 355 | 356 | ## [0.3.3] - 2021-10-01 357 | 358 | ### Added 359 | - feat: boolean property `geojsonFill` to style the fill color of a static geojson polygon as the specified stroke color with 20% opacity, disabled/false by default ([#64](https://github.com/theopensystemslab/map/pull/64)) 360 | 361 | ## [0.3.2] - 2021-09-22 362 | 363 | ### Added 364 | - feat: show vertices of the drawn polygon, similar in design to MapInfo Professional which will hopefully help guide users in modifying existing vertices or adding new ones when drawing a site boundary ([#57](https://github.com/theopensystemslab/map/pull/57)) 365 | - feat: accessibilty improvements, including string property `ariaLabel` to add custom text to describe the component and the ability to access the main map div and control buttons by tabbing ([#58](https://github.com/theopensystemslab/map/pull/58)) 366 | - feat: boolean properties `showScale` and `useScaleBarStyle` to display a scale bar on the map ([#60](https://github.com/theopensystemslab/map/pull/60)) 367 | 368 | ## [0.3.1] - 2021-08-27 369 | 370 | ### Changed 371 | - fix: any prior drawings are cleared upon enabling `drawMode`, resolving an edge case that could occur in PlanX 'back' button behavior ([#50](https://github.com/theopensystemslab/map/pull/50)) 372 | 373 | ### Added 374 | - feat: string property `areaUnit` to specify "m2" for metres squared (default) or "ha" for hectares when returning the total area of a feature ([#51](https://github.com/theopensystemslab/map/pull/51)) 375 | - feat: boolean property `clickFeatures` to extend the `showFeaturesAtPoint` mode, by allowing a user to click to select or de-select features ([#48](https://github.com/theopensystemslab/map/pull/48)) 376 | 377 | ## [0.3.0] - 2021-08-17 378 | 379 | ### Changed 380 | - **BREAKING**: `renderVectorTiles` is renamed to `disableVectorTiles` and disabled by default, a convention we'll follow for all boolean property types going forward ([#40](https://github.com/theopensystemslab/map/pull/40)) 381 | - fix: reset control erases drawing when `geojsonData` is also displayed ([#42](https://github.com/theopensystemslab/map/pull/42)) 382 | - fix: total area doesn't return html tags if units are configured to square metres ([#43](https://github.com/theopensystemslab/map/pull/43)) 383 | 384 | ### Added 385 | - feat: boolean properties `hideResetControl` and `staticMode` to configure visibility of control buttons and allowed user interactions like zooming/dragging ([#41](https://github.com/theopensystemslab/map/pull/41)) 386 | 387 | ## [0.2.0] - 2021-08-12 388 | 389 | ### Changed 390 | - **BREAKING**: Ordnance Survey API keys are now provided client-side as optional properties `osVectorTilesApiKey`, `osFeaturesApiKey` ([#29](https://github.com/theopensystemslab/map/pull/29)) 391 | - fix: `geojsonData` now handles `{ "type": "Feature" }` in addition to "FeatureCollection" ([#34](https://github.com/theopensystemslab/map/pull/34)) 392 | 393 | ### Added 394 | - docs: basic examples + gif ([#33](https://github.com/theopensystemslab/map/pull/33), [#35](https://github.com/theopensystemslab/map/pull/35)) 395 | 396 | ## [0.1.0] - 2021-08-10 397 | 398 | ### Changed 399 | 400 | - **BREAKING**: [`drawMode` is now disabled by default](https://github.com/theopensystemslab/map/pull/24#discussion_r685808355) 401 | - upgrade from lit-element > lit ([#27](https://github.com/theopensystemslab/map/pull/27)) 402 | 403 | ### Added 404 | 405 | - feat: query & display features that intersect with lon,lat ([#24](https://github.com/theopensystemslab/map/pull/24)) 406 | - feat: display a static polygon if geojson provided ([#19](https://github.com/theopensystemslab/map/pull/19)) 407 | - docs: ([update npm badge link](https://github.com/theopensystemslab/map/commit/5e95993869bc6bd04761fdfb02a7e208e82aade6)) 408 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Mozilla Public License (MPL) Version 2 2 | 3 | Copyright (c) 2023 4 | 5 | This source code is licensed under the Mozilla Public License v2.0. To view this license, visit https://mozilla.org/MPL/2.0/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Place components 2 | 3 | [![npm @opensystemslab/map](https://img.shields.io/npm/v/@opensystemslab/map?style=flat-square)](http://npm.im/@opensystemslab/map) 4 | 5 | A library of [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components) for tasks related to addresses and planning permission in the UK built with [Lit](https://lit.dev/), [Vite](https://vitejs.dev/), and [Ordnance Survey APIs](https://developer.ordnancesurvey.co.uk/). 6 | 7 | ***Web map*** 8 | 9 | `` is an [OpenLayers](https://openlayers.org/)-powered map to support drawing and modifying red-line boundaries. Other supported modes include: highlighting an OS Feature that intersects with a given address point; clicking to select and merge multiple OS Features into a single boundary; and displaying static point or polygon data. Events are dispatched with the calculated area and geojson representation when you change your drawing. 10 | 11 | ![chrome-capture-2022-7-16-map](https://user-images.githubusercontent.com/5132349/184860750-bf7514db-7cab-4f9c-aa32-791099ecd6cc.gif) 12 | 13 | ***Postcode search*** 14 | 15 | `` is a [GOV.UK-styled](https://frontend.design-system.service.gov.uk/) input that validates UK postcodes using these [utility methods](https://www.npmjs.com/package/postcode). When a postcode is validated, an event is dispatched containing the sanitized string. 16 | 17 | ***Address autocomplete*** 18 | 19 | `` fetches addresses in a given UK postcode using the [OS Places API](https://developer.ordnancesurvey.co.uk/os-places-api) and displays them using GOV.UK's [accessible-autocomplete](https://github.com/alphagov/accessible-autocomplete) component. An event is dispatched with the OS record when you select an address. 20 | 21 | These web components can be used independently or together following GOV.UK's [Address lookup](https://design-system.service.gov.uk/patterns/addresses/) design pattern. 22 | 23 | ![chrome-capture-2022-7-16 (1)](https://user-images.githubusercontent.com/5132349/184858819-133bc7fa-7f48-4a2a-a416-b612febcce58.gif) 24 | 25 | ## Documentation & examples 26 | 27 | - Interactive web component docs [oslmap.netlify.app](https://oslmap.netlify.app) 28 | - [CodeSandbox](https://codesandbox.io/s/confident-benz-rr0s9?file=/index.html) (note: update the CDN script with a version number for new features) 29 | 30 | Find these components in the wild, including what we're learning through public beta user-testing, at [https://www.ripa.digital/](https://www.ripa.digital/). 31 | 32 | ## Bring your own API keys 33 | 34 | Different features rely on different APIs - namely from Ordnance Survey and Mapbox. 35 | 36 | You can set keys directly as props (eg `osApiKey`) on the applicable web components or [use a proxy](https://github.com/theopensystemslab/map/blob/main/docs/how-to-use-a-proxy.md) to mask these secrets. 37 | 38 | Address autocomplete utilises the OS Places API. 39 | 40 | For the map: 41 | - The `basemap` prop defaults to `"OSVectorTile"` which requires the OS Vector Tiles API 42 | - Basemap `"OSRaster"` uses the OS Maps API 43 | - Basemap `"MapboxSatellite"` requires a Mapbox Access Token with with scope `style:read` 44 | - The `"OSM"` (OpenStreetMap) basemap is available for users without any keys, and as a fallback if any of the above basemaps fail to build 45 | - `clickFeatures` requires the OS Features API 46 | 47 | When using Ordnance Survey APIs: 48 | - Update the `osCopyright` attribution prop with your license number 49 | - Configure an optional `osProxyEndpoint` to avoid exposing your keys (set this instead of `osApiKey`) 50 | - ** We are not currently supporting a similar proxy for Mapbox because access tokens can be restricted to specific URLs via your account 51 | 52 | ## Running locally 53 | 54 | - Rename `.env.example` to `.env.local` and replace the values - or simply provide your API keys as props 55 | - Install [pnpm](https://pnpm.io) globally if you don't have it already `npm i pnpm -g` 56 | - Install dependencies `pnpm i` 57 | - Start development server `pnpm dev` 58 | 59 | ### Tests 60 | 61 | Unit tests are written with [Vitest](https://vitest.dev/), [Happy Dom](https://www.npmjs.com/package/happy-dom), and [@testing-library/user-event](https://testing-library.com/docs/user-event/intro/). Each component has a `main.test.ts` file. 62 | 63 | - `pnpm test` starts `vitest` in watch mode 64 | - `pnpm test:ui` opens Vitest's UI in the browser to interactively explore logs https://vitest.dev/guide/ui.html 65 | 66 | ### Docs 67 | 68 | We use [Pitsby](https://pitsby.com/) for documenting our web components. It's simple to configure (`pitsby.config.js` plus a `*.doc.js` per component), has good support for vanilla web components, and an interactive playground. 69 | 70 | - `pnpm run docs` starts Pitsby in watch mode for local development 71 | - `pnpm run docsPublish` builds the site so Netlify can serve it from `pitsby/` 72 | 73 | ### Deployments 74 | 75 | We publish this package via [NPM](https://www.npmjs.com/package/@opensystemslab/map). 76 | 77 | To create a new release: 78 | 1. Open a new PR against `main` which bumps the package.json "version" & creates a CHANGELOG.md entry, request code review & merge on approval 79 | 1. Run `npm publish` or `npm publish --tag next` if making a pre-release (requires permissions to OSL team in NPM & access to 2-factor auth method) 80 | 1. [Draft a new release](https://github.com/theopensystemslab/map/releases) via GitHub web: tag should match version, automatically generate changenotes and link above PR, then "Publish" and set as latest version (or set as pre-release if you used `--tag next` in above command) 81 | 82 | ## License 83 | 84 | This repository is licensed under the [Open Government License v3](http://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). 85 | -------------------------------------------------------------------------------- /docs/how-to-use-a-proxy.md: -------------------------------------------------------------------------------- 1 | # How to: Use a MyMap & AddressAutocomplete with a proxy 2 | 3 | ## Context 4 | Both `MyMap` and `AddressAutocomplete` can call the Ordnance Survey API directly, or via a proxy. 5 | 6 | Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. 7 | 8 | A proxy endpoint can be supplied via the `osProxyEndpoint` property on these components. 9 | 10 | Proxies are required to complete the following actions in order to work successfully - 11 | 12 | - Append a valid OS API key as a search parameter to incoming requests 13 | - Modify outgoing response with suitable CORS / CORP headers to allow the originating site access to the returned assets 14 | 15 | ## Diagram 16 | ```mermaid 17 | sequenceDiagram 18 | autonumber 19 | participant WC as Web Component 20 | participant P as Proxy 21 | participant OS as Ordnance Survey API 22 | 23 | WC ->>+ P: Request 24 | P -->> P: Validate Request 25 | P ->>+ OS: Request + API key 26 | OS ->>- P: Response 27 | P ->>- WC: Response + CORP/CORS headers 28 | ``` 29 | 30 | ## Examples 31 | Please see the sample code below for how a proxy could be implemented - 32 | 33 | ### Express 34 | Below is an annotated example of a simple proxy using [Express](https://github.com/expressjs/express) & [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware). 35 | 36 | **index.js** 37 | ```js 38 | import express from "express"; 39 | import { useOrdnanceSurveyProxy } from "proxy"; 40 | 41 | const app = express() 42 | const port = 3000 43 | 44 | app.use('/proxy/ordnance-survey', useOrdnanceSurveyProxy) 45 | 46 | app.listen(port) 47 | ``` 48 | 49 | **proxy.js** 50 | ```js 51 | import { createProxyMiddleware } from "http-proxy-middleware"; 52 | 53 | const OS_DOMAIN = "https://api.os.uk"; 54 | 55 | export const useOrdnanceSurveyProxy = async (req, res, next) => { 56 | if (!isValid(req)) return next({ 57 | status: 401, 58 | message: "Unauthorised" 59 | }) 60 | 61 | return createProxyMiddleware({ 62 | target: OS_DOMAIN, 63 | changeOrigin: true, 64 | onProxyRes: (proxyRes) => setCORPHeaders(proxyRes), 65 | pathRewrite: (fullPath, req) => appendAPIKey(fullPath, req) 66 | onError: (_err, _req, res) => { 67 | res.json({ 68 | status: 500, 69 | message: "Something went wrong", 70 | }); 71 | }, 72 | })(req, res, next); 73 | }; 74 | 75 | const isValid = (req) => { 76 | // Your validation logic here, for example checking req.header.referer against an allowlist of domains 77 | } 78 | 79 | // Ensure that returned tiles can be embedded cross-site 80 | // May not be required if "same-site" policy works for your setup 81 | const setCORPHeaders = (proxyRes: IncomingMessage): void => { 82 | proxyRes.headers["Cross-Origin-Resource-Policy"] = "cross-origin" 83 | } 84 | 85 | export const appendAPIKey = (fullPath, req) => { 86 | const [path, params] = fullPath.split("?"); 87 | // Append API key 88 | const updatedParams = new URLSearchParams(params); 89 | updatedParams.set("key", process.env.ORDNANCE_SURVEY_API_KEY); 90 | // Remove our API baseUrl (/proxy/ordnance-survey) 91 | const updatedPath = path.replace(req.baseUrl, ""); 92 | // Construct and return rewritten path 93 | const resultPath = [updatedPath, updatedParams.toString()].join("?"); 94 | return resultPath; 95 | }; 96 | ``` 97 | > A working and more fleshed out example (in TypeScript) can be seen [here in the PlanX API](https://github.com/theopensystemslab/planx-new/blob/production/api.planx.uk/proxy/ordnanceSurvey.ts). -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | OS Web Components Sandbox 8 | 9 | 10 | 11 | 12 | 14 | 15 | 40 | 41 | 42 | 43 |
44 |

45 | *** This is a testing sandbox - these components are unaware of each other!*** 46 |

47 |
48 | 61 |
62 |
63 | 64 |
65 |
66 | 76 | 77 |
78 |
79 | 156 | 157 | 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@opensystemslab/map", 3 | "version": "1.0.0-alpha.5", 4 | "license": "MPL-2.0", 5 | "private": false, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/theopensystemslab/map.git" 9 | }, 10 | "browser": "./dist/component-lib.umd.js", 11 | "module": "./dist/component-lib.es.js", 12 | "exports": "./dist/component-lib.es.js", 13 | "types": "./dist/types/index.d.ts", 14 | "files": [ 15 | "dist", 16 | "types" 17 | ], 18 | "scripts": { 19 | "dev": "vite", 20 | "build": "rm -rf dist types && tsc && vite build && sed 's/src=\".*\"/src=\"component-lib.es.js\"/' index.html > dist/index.html", 21 | "prepublishOnly": "npm run build", 22 | "prepare": "husky install", 23 | "test": "vitest", 24 | "test:ui": "vitest --ui", 25 | "docs": "NODE_ENV=development pitsby build --watch --port=7007", 26 | "docsPublish": "pitsby build" 27 | }, 28 | "dependencies": { 29 | "@turf/helpers": "^7.2.0", 30 | "@turf/union": "^7.1.0", 31 | "@types/geojson": "^7946.0.14", 32 | "accessible-autocomplete": "^2.0.4", 33 | "file-saver": "^2.0.5", 34 | "govuk-frontend": "^5.9.0", 35 | "jspdf": "^3.0.1", 36 | "lit": "^3.0.1", 37 | "ol": "^10.4.0", 38 | "ol-ext": "^4.0.24", 39 | "ol-mapbox-style": "^12.6.0", 40 | "postcode": "^5.1.0", 41 | "proj4": "^2.17.0", 42 | "rambda": "^9.4.2" 43 | }, 44 | "devDependencies": { 45 | "@glorious/pitsby": "^1.37.2", 46 | "@testing-library/dom": "^10.4.0", 47 | "@testing-library/user-event": "^14.6.1", 48 | "@types/file-saver": "^2.0.7", 49 | "@types/node": "22.0.3", 50 | "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.6.1", 51 | "@types/proj4": "^2.5.6", 52 | "@vitest/ui": "^3.0.7", 53 | "happy-dom": "^9.1.9", 54 | "husky": "^8.0.3", 55 | "lint-staged": "^15.2.10", 56 | "prettier": "^3.5.3", 57 | "rollup-plugin-postcss-lit": "^2.1.0", 58 | "sass": "^1.87.0", 59 | "typescript": "^5.8.3", 60 | "vite": "^6.3.4", 61 | "vitest": "3.0.7", 62 | "wait-for-expect": "^3.0.2" 63 | }, 64 | "lint-staged": { 65 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md,html}": "prettier --write" 66 | }, 67 | "stackblitz": { 68 | "startCommand": "npm run test:ui" 69 | }, 70 | "packageManager": "pnpm@8.6.6", 71 | "pnpm": { 72 | "overrides": { 73 | "braces@<3.0.3": ">=3.0.3" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pitsby.config.js: -------------------------------------------------------------------------------- 1 | // https://pitsby.com/documentation#config 2 | module.exports = { 3 | projects: [ 4 | { 5 | engine: "vanilla", 6 | collectDocsFrom: "./src/components/", 7 | }, 8 | ], 9 | styles: ["./dist/map.css"], 10 | scripts: ["./dist/component-lib.es.js", "./dist/component-lib.umd.js"], 11 | custom: { 12 | windowTitle: 'Docs - Place Components', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/address-autocomplete/address-autocomplete.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "AddressAutocomplete", 3 | description: 4 | "AddressAutocomplete is a Lit wrapper for the Gov.UK accessible-autocomplete component that fetches & displays addresses in a given postcode using the Ordnance Survey Places API. The Ordnance Survey API can be called directly, or via a proxy. Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. Any proxy supplied via the osProxyEndpoint property must append a valid Ordnance Survey API key to all requests. For full implementation details, please see https://github.com/theopensystemslab/map/blob/main/docs/how-to-use-a-proxy.md", 5 | properties: [ 6 | { 7 | name: "postcode", 8 | type: "String", 9 | values: "SE5 0HU (default), any UK postcode", 10 | required: true, 11 | }, 12 | { 13 | name: "initialAddress", 14 | type: "String", 15 | values: `"" (default)`, 16 | }, 17 | { 18 | name: "label", 19 | type: "String", 20 | values: "Select an address (default)", 21 | }, 22 | { 23 | name: "arrowStyle", 24 | type: "String", 25 | values: `default (default), light`, 26 | }, 27 | { 28 | name: "labelStyle", 29 | type: "String", 30 | values: `responsive (default), static`, 31 | }, 32 | { 33 | name: "id", 34 | type: "String", 35 | values: "autocomplete (default)", 36 | }, 37 | { 38 | name: "osPlacesApiKey", 39 | type: "String", 40 | values: "https://osdatahub.os.uk/plans", 41 | required: true, 42 | }, 43 | { 44 | name: "osProxyEndpoint", 45 | type: "String", 46 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 47 | }, 48 | ], 49 | methods: [ 50 | { 51 | name: "ready", 52 | params: [ 53 | { 54 | name: "ready", 55 | type: "Event Listener", 56 | values: "detail", 57 | description: 58 | "Dispatches the number of addresses fetched from the OS Places API for a given `postcode` when the component is connected to the DOM, before initial render", 59 | }, 60 | ], 61 | }, 62 | { 63 | name: "addressSelection", 64 | params: [ 65 | { 66 | name: "addressSelection", 67 | type: "Event Listener", 68 | values: "detail", 69 | description: 70 | "Dispatches the OS Places API record for an individual address when it's selected from the dropdown", 71 | }, 72 | ], 73 | }, 74 | ], 75 | examples: [ 76 | { 77 | title: "Select an address in postcode SE19 1NT", 78 | description: "Standard case", 79 | template: ``, 80 | controller: function (document) { 81 | const autocomplete = document.querySelector("address-autocomplete"); 82 | 83 | autocomplete.addEventListener("ready", ({ detail: data }) => { 84 | console.debug("autocomplete ready", { data }); 85 | }); 86 | 87 | autocomplete.addEventListener( 88 | "addressSelection", 89 | ({ detail: address }) => { 90 | console.debug({ detail: address }); 91 | }, 92 | ); 93 | }, 94 | }, 95 | { 96 | title: "Select an address in postcode SE19 1NT", 97 | description: "Standard case (via proxy)", 98 | template: ``, 99 | controller: function (document) { 100 | const autocomplete = document.querySelector("address-autocomplete"); 101 | 102 | autocomplete.addEventListener("ready", ({ detail: data }) => { 103 | console.debug("autocomplete ready", { data }); 104 | }); 105 | 106 | autocomplete.addEventListener( 107 | "addressSelection", 108 | ({ detail: address }) => { 109 | console.debug({ detail: address }); 110 | }, 111 | ); 112 | }, 113 | }, 114 | ], 115 | }; 116 | -------------------------------------------------------------------------------- /src/components/address-autocomplete/index.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, TemplateResult, unsafeCSS } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | import accessibleAutocomplete from "accessible-autocomplete"; 4 | 5 | import styles from "./styles.scss?inline"; 6 | import { getServiceURL } from "../../lib/ordnanceSurvey"; 7 | 8 | // https://apidocs.os.uk/docs/os-places-lpi-output 9 | type Address = { 10 | LPI: any; 11 | }; 12 | 13 | type ArrowStyleEnum = "default" | "light"; 14 | type LabelStyleEnum = "responsive" | "static"; 15 | 16 | @customElement("address-autocomplete") 17 | export class AddressAutocomplete extends LitElement { 18 | // ref https://github.com/e111077/vite-lit-element-ts-sass/issues/3 19 | static styles = unsafeCSS(styles); 20 | 21 | // configurable component properties 22 | @property({ type: String }) 23 | id = "autocomplete"; 24 | 25 | @property({ type: String }) 26 | postcode = "SE5 0HU"; 27 | 28 | @property({ type: String }) 29 | label = "Select an address"; 30 | 31 | @property({ type: String }) 32 | initialAddress = ""; 33 | 34 | @property({ type: String }) 35 | osApiKey = ""; 36 | 37 | /** 38 | * @deprecated - please set singular `osApiKey` 39 | */ 40 | @property({ type: String }) 41 | osPlacesApiKey = ""; 42 | 43 | @property({ type: String }) 44 | osProxyEndpoint = ""; 45 | 46 | @property({ type: String }) 47 | arrowStyle: ArrowStyleEnum = "default"; 48 | 49 | @property({ type: String }) 50 | labelStyle: LabelStyleEnum = "responsive"; 51 | 52 | // internal reactive state 53 | @state() 54 | private _totalAddresses: number | undefined = undefined; 55 | 56 | @state() 57 | private _addressesInPostcode: Address[] = []; 58 | 59 | @state() 60 | private _options: string[] = []; 61 | 62 | @state() 63 | private _selectedAddress: Address | null = null; 64 | 65 | @state() 66 | private _osError: string | undefined = undefined; 67 | 68 | // called when DOM node is connected to the document, before render 69 | connectedCallback() { 70 | super.connectedCallback(); 71 | this._fetchData(); 72 | } 73 | 74 | // called when the component is removed from the document's DOM 75 | disconnectedCallback() { 76 | super.disconnectedCallback(); 77 | } 78 | 79 | _getLightDropdownArrow() { 80 | return ''; 81 | } 82 | 83 | // called after the initial render 84 | firstUpdated() { 85 | // https://github.com/alphagov/accessible-autocomplete 86 | accessibleAutocomplete({ 87 | element: this.renderRoot.querySelector(`#${this.id}-container`), 88 | id: this.id, 89 | required: true, 90 | source: this._options, 91 | defaultValue: this.initialAddress, 92 | showAllValues: true, 93 | displayMenu: "overlay", 94 | dropdownArrow: 95 | this.arrowStyle === "light" ? this._getLightDropdownArrow : undefined, 96 | tNoResults: () => "No addresses found", 97 | onConfirm: (option: string) => { 98 | this._selectedAddress = this._addressesInPostcode.filter( 99 | (address) => 100 | address.LPI.ADDRESS.slice( 101 | 0, 102 | address.LPI.ADDRESS.lastIndexOf( 103 | `, ${address.LPI.ADMINISTRATIVE_AREA}`, 104 | ), 105 | ) === option, 106 | )[0]; 107 | if (this._selectedAddress) 108 | this.dispatch("addressSelection", { address: this._selectedAddress }); 109 | }, 110 | }); 111 | } 112 | 113 | async _fetchData(offset: number = 0, prevResults: Address[] = []) { 114 | const isUsingOS = Boolean(this.osApiKey || this.osProxyEndpoint); 115 | if (!isUsingOS) 116 | throw Error("OS Places API key or OS proxy endpoint not found"); 117 | 118 | // https://apidocs.os.uk/docs/os-places-service-metadata 119 | const params: Record = { 120 | postcode: this.postcode, 121 | dataset: "LPI", // or "DPA" for only mailable addresses 122 | maxResults: "100", 123 | output_srs: "EPSG:4326", 124 | lr: "EN", 125 | offset: offset.toString(), 126 | }; 127 | const url = getServiceURL({ 128 | service: "places", 129 | apiKey: this.osApiKey, 130 | proxyEndpoint: this.osProxyEndpoint, 131 | params, 132 | }); 133 | 134 | await fetch(url) 135 | .then((resp) => resp.json()) 136 | .then((data) => { 137 | // handle error formats returned by OS 138 | if (data.error || data.fault) { 139 | this._osError = 140 | data.error?.message || 141 | data.fault?.faultstring || 142 | "Something went wrong"; 143 | } 144 | 145 | this._totalAddresses = data.header?.totalresults; 146 | 147 | // concatenate full results 148 | const concatenated = prevResults.concat(data.results || []); 149 | this._addressesInPostcode = concatenated; 150 | 151 | this.dispatch("ready", { 152 | postcode: this.postcode, 153 | status: `fetched ${this._addressesInPostcode.length}/${this._totalAddresses} addresses`, 154 | }); 155 | 156 | // format & sort list of address "titles" that will be visible in dropdown 157 | if (data.results) { 158 | data.results 159 | .filter( 160 | (address: Address) => 161 | // filter out "ALTERNATIVE", "HISTORIC", and "PROVISIONAL" records 162 | address.LPI.LPI_LOGICAL_STATUS_CODE_DESCRIPTION === "APPROVED", 163 | ) 164 | .map((address: Address) => { 165 | // omit the council name and postcode from the display name 166 | this._options.push( 167 | address.LPI.ADDRESS.slice( 168 | 0, 169 | address.LPI.ADDRESS.lastIndexOf( 170 | `, ${address.LPI.ADMINISTRATIVE_AREA}`, 171 | ), 172 | ), 173 | ); 174 | }); 175 | 176 | const collator = new Intl.Collator([], { numeric: true }); 177 | this._options.sort((a, b) => collator.compare(a, b)); 178 | } 179 | 180 | // fetch next page of results if they exist 181 | if ( 182 | this._totalAddresses && 183 | this._totalAddresses > this._addressesInPostcode.length 184 | ) { 185 | this._fetchData( 186 | this._addressesInPostcode.length, 187 | this._addressesInPostcode, 188 | ); 189 | } 190 | }) 191 | .catch((error) => console.log(error)); 192 | } 193 | 194 | _getLabelClasses() { 195 | let styles = "govuk-label"; 196 | if (this.labelStyle === "static") { 197 | styles += " govuk-label--static"; 198 | } 199 | return styles; 200 | } 201 | 202 | /** 203 | * Render an errorMessage container 204 | * Must always be visible to ensure that role="status" works for screenreaders 205 | * @param errorMessage 206 | * @returns TemplateResult 207 | */ 208 | _getErrorMessageContainer(errorMessage: string | undefined): TemplateResult { 209 | const className = errorMessage ? "govuk-warning-text" : ""; 210 | const content = errorMessage 211 | ? html` 212 | 213 | Warning 214 | ${errorMessage} 215 | ` 216 | : null; 217 | 218 | return html`
223 | ${content} 224 |
`; 225 | } 226 | 227 | /** 228 | * If not in state of error, return the autocomplete 229 | * @param errorMessage 230 | * @returns TemplateResult | null 231 | */ 232 | _getAutocomplete(errorMessage: string | undefined): TemplateResult | null { 233 | return errorMessage 234 | ? null 235 | : html` 236 | 240 | 243 |
244 | `; 245 | } 246 | 247 | render() { 248 | // handle various error states 249 | let errorMessage; 250 | if (!this.osApiKey && !this.osProxyEndpoint) 251 | errorMessage = "Missing OS Places API key or proxy endpoint"; 252 | else if (this._osError) errorMessage = this._osError; 253 | else if (this._totalAddresses === 0) 254 | errorMessage = `No addresses found in postcode ${this.postcode}`; 255 | 256 | return html` 257 | ${this._getErrorMessageContainer(errorMessage)} 258 | ${this._getAutocomplete(errorMessage)} 259 | `; 260 | } 261 | 262 | /** 263 | * dispatches an event for clients to subscribe to 264 | * @param eventName 265 | * @param payload 266 | */ 267 | private dispatch = (eventName: string, payload?: any) => 268 | this.dispatchEvent( 269 | new CustomEvent(eventName, { 270 | detail: payload, 271 | }), 272 | ); 273 | } 274 | 275 | declare global { 276 | interface HTMLElementTagNameMap { 277 | "address-autocomplete": AddressAutocomplete; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/components/address-autocomplete/main.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from "happy-dom"; 2 | import { beforeEach, describe, it, expect } from "vitest"; 3 | 4 | import { getShadowRoot, getShadowRootEl } from "../../test-utils"; 5 | 6 | import "./index"; 7 | 8 | declare global { 9 | interface Window extends IWindow {} 10 | } 11 | 12 | test.todo( 13 | "Replace environment variable prop dependency with mock response. Ref https://vitest.dev/guide/mocking.html", 14 | ); 15 | 16 | describe("AddressAutocomplete on initial render with valid postcode", async () => { 17 | beforeEach(async () => { 18 | document.body.innerHTML = ``; 19 | await window.happyDOM.whenAsyncComplete(); 20 | }, 2500); 21 | 22 | it("should render a custom element with a shadow root", () => { 23 | const autocomplete = document.body.querySelector("address-autocomplete"); 24 | expect(autocomplete).toBeTruthy; 25 | 26 | const autocompleteShadowRoot = getShadowRoot("address-autocomplete"); 27 | expect(autocompleteShadowRoot).toBeTruthy; 28 | }); 29 | 30 | it("should have an input with autocomplete attributes", () => { 31 | const input = getShadowRootEl("address-autocomplete", "input"); 32 | expect(input).toBeTruthy; 33 | expect(input?.getAttribute("role")).toEqual("combobox"); 34 | expect(input?.getAttribute("type")).toEqual("text"); 35 | expect(input?.getAttribute("aria-autocomplete")).toEqual("list"); 36 | expect(input?.getAttribute("aria-expanded")).toEqual("false"); 37 | expect(input?.getAttribute("autocomplete")).toEqual("off"); 38 | }); 39 | 40 | it("should have a label with the default text", () => { 41 | const label = getShadowRootEl("address-autocomplete", "label"); 42 | expect(label).toBeTruthy; 43 | expect(label?.className).toContain("govuk-label"); 44 | expect(label?.innerHTML).toContain("Select an address"); 45 | }); 46 | 47 | it("should associate the label with the input", () => { 48 | const label = getShadowRootEl("address-autocomplete", "label"); 49 | expect(label?.getAttribute("for")).toEqual("autocomplete-vitest"); 50 | 51 | const input = getShadowRootEl("address-autocomplete", "input"); 52 | expect(input?.id).toEqual("autocomplete-vitest"); 53 | }); 54 | 55 | it("should always render the warning message container for screenreaders", () => { 56 | const error = getShadowRoot("address-autocomplete")?.getElementById( 57 | "error-message-container", 58 | ); 59 | expect(error).toBeTruthy; 60 | }); 61 | }); 62 | 63 | describe("AddressAutocomplete on initial render with empty postcode", async () => { 64 | beforeEach(async () => { 65 | document.body.innerHTML = ``; 66 | await window.happyDOM.whenAsyncComplete(); 67 | }, 500); 68 | 69 | it.todo("renders a 'no addresses in this postcode' warning", () => { 70 | const autocomplete = getShadowRoot("address-autocomplete"); 71 | console.log(autocomplete?.innerHTML); // pnpm test:ui 72 | expect(autocomplete?.innerHTML).toContain( 73 | "No addresses found in postcode HP11 1BR", 74 | ); 75 | }); 76 | }); 77 | 78 | describe("External API calls", async () => { 79 | const fetchSpy = vi.spyOn(window, "fetch"); 80 | 81 | afterEach(() => { 82 | vi.clearAllMocks(); 83 | }); 84 | 85 | // Component makes a request for styles and tiles in a non-determintic manner 86 | const lastTwoCalls = () => fetchSpy.mock.calls?.slice(-2).join(", "); 87 | 88 | it("calls proxy when 'osProxyEndpoint' provided", async () => { 89 | document.body.innerHTML = ``; 90 | await window.happyDOM.whenAsyncComplete(); 91 | 92 | expect(fetchSpy).toHaveBeenCalled(); 93 | expect(lastTwoCalls()).toContain( 94 | "https://www.my-site.com/api/v1/os/search/places/v1/postcode?postcode=SE5+0HU", 95 | ); 96 | expect(fetchSpy.mock.calls?.[0]).not.toContain("&key="); 97 | }); 98 | 99 | it("calls OS API when 'osApiKey' provided", async () => { 100 | const mockAPIKey = "test-test-test"; 101 | document.body.innerHTML = ``; 102 | await window.happyDOM.whenAsyncComplete(); 103 | 104 | expect(fetchSpy).toHaveBeenCalled(); 105 | expect(lastTwoCalls()).toContain( 106 | "https://api.os.uk/search/places/v1/postcode?postcode=SE5+0HU", 107 | ); 108 | expect(lastTwoCalls()).toContain(`&key=${mockAPIKey}`); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/components/address-autocomplete/styles.scss: -------------------------------------------------------------------------------- 1 | @import "govuk-frontend/dist/govuk/index"; 2 | // @import "accessible-autocomplete"; 3 | 4 | :host { 5 | $font-family: var( 6 | --autocomplete__font-family, 7 | "GDS Transport", 8 | arial, 9 | sans-serif 10 | ); 11 | $font-size: var(--autocomplete__input__font-size, 19px); 12 | $input-height: var(--autocomplete__input__height, 35px); 13 | $arrow-down-z-index: var(--autocomplete__dropdown-arrow-down__z-index, 1); 14 | 15 | .govuk-label { 16 | font-family: $font-family; 17 | } 18 | 19 | .govuk-label--static { 20 | font-size: var(--autocomplete__label__font-size, 19px); 21 | } 22 | 23 | .autocomplete__input { 24 | font-family: $font-family; 25 | font-size: $font-size; 26 | height: $input-height; 27 | padding: var(--autocomplete__input__padding, 5px 34px 5px 5px); 28 | // Ensure arrow is visible on white background, but behind the input so the click interaction works 29 | // https://github.com/alphagov/accessible-autocomplete/issues/351 30 | z-index: calc($arrow-down-z-index + 1); 31 | } 32 | 33 | .autocomplete__dropdown-arrow-down { 34 | z-index: $arrow-down-z-index; 35 | // Ensure the down arrow is vertically centred 36 | $arrow-down-height: 17px; 37 | top: calc(($input-height - $arrow-down-height) / 2); 38 | } 39 | 40 | .autocomplete__option { 41 | font-family: $font-family; 42 | font-size: $font-size; 43 | padding: var(--autocomplete__option__padding, 5px); 44 | border-bottom: var( 45 | --autocomplete__option__border-bottom, 46 | solid 1px #b1b4b6 47 | ); 48 | } 49 | 50 | .autocomplete__option--focused, 51 | .autocomplete__option:hover, 52 | .autocomplete__option--focused & .autocomplete__option:hover { 53 | border-color: var(--autocomplete__option__hover-border-color, #1d70b8); 54 | background-color: var( 55 | --autocomplete__option__hover-background-color, 56 | #1d70b8 57 | ); 58 | } 59 | 60 | .autocomplete__menu { 61 | max-height: var(--autocomplete__menu__max-height, 342px); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/my-map/controls.ts: -------------------------------------------------------------------------------- 1 | import Map from "ol/Map"; 2 | import { Control, ScaleLine } from "ol/control"; 3 | import "ol/ol.css"; 4 | import "ol-ext/dist/ol-ext.css"; 5 | import northArrowIcon from "./icons/north-arrow-n.svg"; 6 | import trashCanIcon from "./icons/trash-can.svg"; 7 | import printIcon from "./icons/printer.svg"; 8 | import PrintDialog from "ol-ext/control/PrintDialog"; 9 | import { Options } from "ol-ext/control/PrintDialog"; 10 | import CanvasScaleLine from "ol-ext/control/CanvasScaleLine"; 11 | import jsPDF from "jspdf"; 12 | import { saveAs } from "file-saver"; 13 | 14 | export function scaleControl(useScaleBarStyle: boolean) { 15 | return new ScaleLine({ 16 | units: "metric", 17 | bar: useScaleBarStyle, 18 | steps: 4, 19 | text: useScaleBarStyle, 20 | minWidth: 140, 21 | }); 22 | } 23 | 24 | export function northArrowControl() { 25 | const image = document.createElement("img"); 26 | image.src = northArrowIcon; 27 | image.title = "North"; 28 | 29 | const element = document.createElement("div"); 30 | element.className = "north-arrow-control ol-unselectable ol-control"; 31 | element.appendChild(image); 32 | 33 | return new Control({ element: element }); 34 | } 35 | 36 | export function resetControl(listener: any, icon: string) { 37 | const button = document.createElement("button"); 38 | button.title = "Reset map view"; 39 | 40 | if (icon === "unicode") { 41 | button.innerHTML = "↺"; 42 | } else { 43 | const image = document.createElement("img"); 44 | image.className = "reset-icon"; 45 | image.src = trashCanIcon; 46 | button.appendChild(image); 47 | } 48 | 49 | // this is an internal event listener, so doesn't need to be removed later 50 | // ref https://lit.dev/docs/components/lifecycle/#disconnectedcallback 51 | button.addEventListener("click", listener, false); 52 | 53 | const element = document.createElement("div"); 54 | element.className = "reset-control ol-unselectable ol-control"; 55 | element.appendChild(button); 56 | 57 | return new Control({ element: element }); 58 | } 59 | 60 | PrintDialog.prototype.scales = { 61 | // @ts-ignore 62 | 100: "1/100", 63 | 200: "1/200", 64 | 500: "1/500", 65 | 1000: "1/1000", 66 | 1250: "1/1250", 67 | 2500: "1/2500", 68 | 5000: "1/5000", 69 | }; 70 | 71 | // @ts-ignore 72 | PrintDialog.prototype.paperSize = { 73 | A1: [569, 816], 74 | A2: [395, 569], 75 | A3: [272, 395], 76 | A4: [185, 272], 77 | }; 78 | 79 | PrintDialog.prototype._labels = { 80 | en: { 81 | ...PrintDialog.prototype._labels.en, 82 | // @ts-ignore 83 | none: "None", 84 | small: "Small", 85 | large: "Large", 86 | jpegFormat: "Save as JPG", 87 | pngFormat: "Save as PNG", 88 | pdfFormat: "Save as PDF", 89 | }, 90 | }; 91 | 92 | PrintDialog.prototype.formats = [ 93 | { 94 | title: "pdfFormat", 95 | imageType: "pdf", 96 | pdf: true, 97 | }, 98 | { 99 | title: "pngFormat", 100 | imageType: "png", 101 | quality: 1, 102 | }, 103 | { 104 | title: "jpegFormat", 105 | imageType: "jpg", 106 | quality: 1, 107 | }, 108 | ]; 109 | 110 | export interface PrintControlOptions extends Options { 111 | map: Map; 112 | } 113 | 114 | export class PrintControl extends PrintDialog { 115 | mainMap: Map; 116 | 117 | constructor({ map }: PrintControlOptions) { 118 | super({ 119 | // @ts-expect-error: Types don't allow an SVG override, but library does 120 | northImage: northArrowIcon, 121 | saveAs: saveAs, 122 | // @ts-expect-error: Types don't match library 123 | jsPDF: jsPDF, 124 | copy: false, 125 | }); 126 | this.mainMap = map; 127 | this.setSize("A4"); 128 | this.setMargin(10); 129 | this.setOrientation("portrait"); 130 | this.element.className = "ol-print ol-unselectable ol-control"; 131 | 132 | this.setupCanvasScaleLine(); 133 | this.setupPrintButton(); 134 | } 135 | 136 | /** 137 | * Toggle scaleControl when printControl is open 138 | * Instead, display CanvasScaleLine which can be printed 139 | */ 140 | private setupCanvasScaleLine() { 141 | const scaleLineControl = this.mainMap 142 | ?.getControls() 143 | .getArray() 144 | .filter( 145 | (control: Control) => control instanceof ScaleLine, 146 | )[0] as ScaleLine; 147 | if (!scaleLineControl) return; 148 | // @ts-ignore 149 | this.on("show", () => this.getMap().removeControl(scaleLineControl)); 150 | // @ts-ignore 151 | this.on("hide", () => this.getMap().addControl(scaleLineControl)); 152 | this.mainMap.addControl(new CanvasScaleLine({ dpi: 96 })); 153 | } 154 | 155 | /** 156 | * Setup custom styling and event listeners of print button displayed on map 157 | */ 158 | private setupPrintButton() { 159 | const image = document.createElement("img"); 160 | image.className = "print-icon"; 161 | image.src = printIcon; 162 | const button = this.element.firstChild as HTMLButtonElement; 163 | button?.appendChild(image); 164 | 165 | button?.addEventListener("click", () => { 166 | this.customiseContent(); 167 | this.getMap()?.on("postrender", () => this.syncScaleText()); 168 | }); 169 | } 170 | 171 | /** 172 | * Display scale (1:XXX) on CanvasScaleLine control 173 | * This can not natively be done with this element, but it has all the inherited 174 | * functions from OL ScaleLine to allow us to set this 175 | */ 176 | private syncScaleText() { 177 | const scaleLineControl = this.getMap() 178 | ?.getControls() 179 | .getArray() 180 | .filter( 181 | (control) => control instanceof CanvasScaleLine, 182 | )[0] as CanvasScaleLine; 183 | const canvasScaleLine = document.querySelector( 184 | ".ol-scale-line-inner", 185 | ) as HTMLDivElement; 186 | const scale = Math.round(scaleLineControl.getScaleForResolution()); 187 | if (canvasScaleLine) { 188 | canvasScaleLine.innerHTML = `1 : ${scale}`; 189 | } 190 | } 191 | 192 | /** 193 | * Hide browser dialog 194 | */ 195 | private customiseContent() { 196 | const content = this.getContentElement() as HTMLElement; 197 | content.querySelector(".ol-ext-buttons")?.remove(); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/components/my-map/docs/my-map-basic.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "MyMap - Basic", 3 | description: 4 | "MyMap is an OpenLayers-powered Lit web component map for tasks related to planning permission in the UK. These examples cover the foundational properties used to render and style the map.", 5 | properties: [ 6 | { 7 | name: "latitude", 8 | type: "Number", 9 | values: "51.507351 (default)", 10 | required: true, 11 | }, 12 | { 13 | name: "longitude", 14 | type: "Number", 15 | values: "-0.127758 (default)", 16 | required: true, 17 | }, 18 | { 19 | name: "projection", 20 | type: "String", 21 | values: "EPSG:4326 (default), EPSG:27700, EPSG:3857", 22 | }, 23 | { 24 | name: "zoom", 25 | type: "Number", 26 | values: "10 (default)", 27 | required: true, 28 | }, 29 | { 30 | name: "minZoom", 31 | type: "Number", 32 | values: "7 (default)", 33 | }, 34 | { 35 | name: "maxZoom", 36 | type: "Number", 37 | values: "22 (default)", 38 | }, 39 | { 40 | name: "hideResetControl", 41 | type: "Boolean", 42 | values: "false (default)", 43 | }, 44 | { 45 | name: "staticMode", 46 | type: "Boolean", 47 | values: "false (default)", 48 | }, 49 | { 50 | name: "showScale", 51 | type: "Boolean", 52 | values: "false (default)", 53 | }, 54 | { 55 | name: "useScaleBarStyle", 56 | type: "Boolean", 57 | values: "false (default)", 58 | }, 59 | { 60 | name: "id", 61 | type: "String", 62 | values: "map (default)", 63 | }, 64 | { 65 | name: "ariaLabelOlFixedOverlay", 66 | type: "String", 67 | values: "An interactive map", 68 | }, 69 | { 70 | name: "disableVectorTiles", 71 | type: "Boolean", 72 | values: "false (default)", 73 | }, 74 | { 75 | name: "osCopyright", 76 | type: "String", 77 | values: `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857 (default)`, 78 | }, 79 | { 80 | name: "osVectorTileApiKey", 81 | type: "String", 82 | values: "https://osdatahub.os.uk/plans", 83 | }, 84 | { 85 | name: "osProxyEndpoint", 86 | type: "String", 87 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 88 | }, 89 | ], 90 | examples: [ 91 | { 92 | title: "Basemap: Ordnance Survey vector tiles", 93 | description: 94 | "Requires access to the Ordnance Survey Vector Tiles API, fallsback to OpenStreetMap basemap if no key is provided.", 95 | template: ``, 96 | }, 97 | { 98 | title: "Basemap: Ordnance Survey raster tiles", 99 | description: 100 | "Requires access to the Ordnance Survey Maps API, fallsback to OpenStreetMap basemap if no key provided.", 101 | template: ``, 102 | }, 103 | { 104 | title: "Display a static map", 105 | description: 106 | "Disable zooming, panning, and other map interactions. Hide the reset control button.", 107 | template: ` 108 | `, 113 | }, 114 | { 115 | title: "Display a scale bar on the map", 116 | description: 117 | 'Display a scale bar on the map for orientation, choose between the default or "bar" styles offered by OpenLayers', 118 | template: ` 119 | `, 124 | }, 125 | ], 126 | }; 127 | -------------------------------------------------------------------------------- /src/components/my-map/docs/my-map-draw.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "MyMap - Drawing", 3 | description: 4 | "Drawing mode enables drawing and modifying a shape on the map. Snapping points display for guidance at zoom level 20+ when the vector tile basemap is enabled. One polygon can be drawn at a time. The reset control button will erase your drawing and re-center the map view.", 5 | properties: [ 6 | { 7 | name: "drawMode", 8 | type: "Boolean", 9 | values: "false (default)", 10 | }, 11 | { 12 | name: "drawPointer", 13 | type: "String", 14 | values: "crosshair (default), dot", 15 | }, 16 | { 17 | name: "drawGeojsonData", 18 | type: "GeoJSON Feature", 19 | values: ` 20 | { 21 | type: "Feature", 22 | geometry: {}, 23 | } 24 | (default)`, 25 | }, 26 | { 27 | name: "drawGeojsonBuffer", 28 | type: "Number", 29 | values: "100 (default)", 30 | }, 31 | { 32 | name: "drawColor", 33 | type: "String", 34 | values: "#ff0000 (default)", 35 | }, 36 | { 37 | name: "drawFillColor", 38 | type: "String", 39 | values: "rgba(255, 0, 0, 0.1) (default)", 40 | }, 41 | { 42 | name: "areaUnits", 43 | type: "String", 44 | values: "m2 (default), ha", 45 | }, 46 | { 47 | name: "osVectorTileApiKey", 48 | type: "String", 49 | values: "https://osdatahub.os.uk/plans", 50 | }, 51 | { 52 | name: "osProxyEndpoint", 53 | type: "String", 54 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 55 | }, 56 | ], 57 | examples: [ 58 | { 59 | title: "Draw mode", 60 | description: 61 | "Draw and modify a site plan boundary with a red line. Start at zoom 20 so snaps are visible on initial load.", 62 | template: ` 63 | `, 70 | }, 71 | { 72 | title: "Load an initial shape onto the drawing canvas", 73 | description: 74 | "Load a polygon with the ability to continue modifying it. Click 'reset' to erase and draw fresh.", 75 | template: ` 76 | `, 100 | }, 101 | { 102 | title: "Calculate the area of the drawn polygon", 103 | description: 104 | "Listen for an event when the drawn polygon is closed or modified. Specify if you want to calculate area in square metres or hectares.", 105 | controller: function (element) { 106 | const map = element.querySelector("my-map"); 107 | map.addEventListener("areaChange", ({ detail: area }) => { 108 | console.debug({ area }); 109 | }); 110 | }, 111 | template: ` 112 | `, 119 | }, 120 | { 121 | title: "Get the GeoJSON representation of the drawn polygon", 122 | description: 123 | "Listen for an event when the drawn polygon is closed or modified.", 124 | controller: function (element) { 125 | const map = element.querySelector("my-map"); 126 | map.addEventListener("geojsonChange", ({ detail: geojson }) => { 127 | console.debug({ geojson }); 128 | }); 129 | }, 130 | template: ` 131 | `, 137 | }, 138 | ], 139 | }; 140 | -------------------------------------------------------------------------------- /src/components/my-map/docs/my-map-features.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "MyMap - Features", 3 | description: 4 | "Features mode queries the Ordnance Survey Features API for any features that intersect the center point of the map. Should be used with the vector tiles basemap.", 5 | properties: [ 6 | { 7 | name: "showFeaturesAtPoint", 8 | type: "Boolean", 9 | values: "false (default)", 10 | }, 11 | { 12 | name: "clickFeatures", 13 | type: "Boolean", 14 | values: "false (default)", 15 | }, 16 | { 17 | name: "featureColor", 18 | type: "String", 19 | values: "", 20 | }, 21 | { 22 | name: "featureFill", 23 | type: "Boolean", 24 | values: "false (default)", 25 | }, 26 | { 27 | name: "featureBuffer", 28 | type: "Number", 29 | values: "40", 30 | }, 31 | { 32 | name: "osFeaturesApiKey", 33 | type: "String", 34 | values: "https://osdatahub.os.uk/plans", 35 | }, 36 | { 37 | name: "osVectorTileApiKey", 38 | type: "String", 39 | values: "https://osdatahub.os.uk/plans", 40 | }, 41 | { 42 | name: "osProxyEndpoint", 43 | type: "String", 44 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 45 | }, 46 | ], 47 | examples: [ 48 | { 49 | title: "Show features at point", 50 | description: 51 | "Show the Ordnance Survey Feature(s) that intersects with a given point.", 52 | template: ` 53 | `, 60 | }, 61 | { 62 | title: "Click to select and merge features", 63 | description: 64 | "Show features at point plus ability to click to select or deselect additional features to create a more accurate full site boundary.", 65 | template: ` 66 | `, 74 | }, 75 | ], 76 | }; 77 | -------------------------------------------------------------------------------- /src/components/my-map/docs/my-map-geojson.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "MyMap - GeoJSON", 3 | description: 4 | "GeoJSON mode displays a static polygon on the map. The map view will center on the shape, overriding the latitude, longitude, and zoom properties. Use the geojsonBuffer property to control the padding between the shape and edge of the map view.", 5 | properties: [ 6 | { 7 | name: "geojsonData", 8 | type: "GeoJSON FeatureCollection or Feature", 9 | values: ` 10 | { 11 | type: "FeatureCollection", 12 | features: [], 13 | } 14 | (default) 15 | `, 16 | }, 17 | { 18 | name: "geojsonColor", 19 | type: "String", 20 | values: "#ff0000 (default)", 21 | }, 22 | { 23 | name: "geojsonFill", 24 | type: "Boolean", 25 | values: "false (default)", 26 | }, 27 | { 28 | name: "geojsonBuffer", 29 | type: "Number", 30 | values: "12", 31 | }, 32 | { 33 | name: "osProxyEndpoint", 34 | type: "String", 35 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 36 | }, 37 | ], 38 | examples: [ 39 | { 40 | title: "Show a GeoJSON polygon on a static map", 41 | description: 42 | "Show a custom GeoJSON polygon on an OS basemap. Hide the zoom & reset control buttons.", 43 | template: ` 44 | `, 69 | }, 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/my-map/docs/my-map-proxy.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "MyMap - Proxy", 3 | description: 4 | "The MyMap component can either call the Ordnance Survey API directly, or via a proxy. Calling the API directly may be suitable for internal use, where exposure of API keys is not a concern, whilst calling a proxy may be more suitable for public use. Any proxy supplied via the osProxyEndpoint property must append a valid Ordnance Survey API key to all requests. For full implementation details, please see https://github.com/theopensystemslab/map/blob/main/docs/how-to-use-a-proxy.md", 5 | properties: [ 6 | { 7 | name: "latitude", 8 | type: "Number", 9 | values: "51.507351 (default)", 10 | required: true, 11 | }, 12 | { 13 | name: "longitude", 14 | type: "Number", 15 | values: "-0.127758 (default)", 16 | required: true, 17 | }, 18 | { 19 | name: "projection", 20 | type: "String", 21 | values: "EPSG:4326 (default), EPSG:27700, EPSG:3857", 22 | }, 23 | { 24 | name: "zoom", 25 | type: "Number", 26 | values: "10 (default)", 27 | required: true, 28 | }, 29 | { 30 | name: "minZoom", 31 | type: "Number", 32 | values: "7 (default)", 33 | }, 34 | { 35 | name: "maxZoom", 36 | type: "Number", 37 | values: "22 (default)", 38 | }, 39 | { 40 | name: "hideResetControl", 41 | type: "Boolean", 42 | values: "false (default)", 43 | }, 44 | { 45 | name: "staticMode", 46 | type: "Boolean", 47 | values: "false (default)", 48 | }, 49 | { 50 | name: "showScale", 51 | type: "Boolean", 52 | values: "false (default)", 53 | }, 54 | { 55 | name: "useScaleBarStyle", 56 | type: "Boolean", 57 | values: "false (default)", 58 | }, 59 | { 60 | name: "id", 61 | type: "String", 62 | values: "map (default)", 63 | }, 64 | { 65 | name: "ariaLabelOlFixedOverlay", 66 | type: "String", 67 | values: "An interactive map", 68 | }, 69 | { 70 | name: "disableVectorTiles", 71 | type: "Boolean", 72 | values: "false (default)", 73 | }, 74 | { 75 | name: "osCopyright", 76 | type: "String", 77 | values: `© Crown copyright and database rights ${new Date().getFullYear()} OS (0)100024857 (default)`, 78 | }, 79 | { 80 | name: "osVectorTileApiKey", 81 | type: "String", 82 | values: "https://osdatahub.os.uk/plans", 83 | }, 84 | { 85 | name: "osProxyEndpoint", 86 | type: "String", 87 | values: "https://api.editor.planx.dev/proxy/ordnance-survey", 88 | }, 89 | ], 90 | examples: [ 91 | { 92 | title: "Basemap: Ordnance Survey vector tiles (proxied)", 93 | description: 94 | "Calls the Ordnance Survey Vector Tiles API via the supplied proxy endpoint. The proxy must append a valid Ordnance Survey API key to each request.", 95 | template: ``, 96 | }, 97 | { 98 | title: "Basemap: Ordnance Survey raster tiles (proxied)", 99 | description: 100 | "Calls the Ordnance Survey Maps API via the supplied proxy endpoint. The proxy must append a valid Ordnance Survey API key to each request.", 101 | template: ``, 102 | }, 103 | ], 104 | }; 105 | -------------------------------------------------------------------------------- /src/components/my-map/drawing.ts: -------------------------------------------------------------------------------- 1 | import { FeatureLike } from "ol/Feature"; 2 | import { MultiPoint, MultiPolygon, Polygon } from "ol/geom"; 3 | import { Type } from "ol/geom/Geometry"; 4 | import { Draw, Modify, Snap } from "ol/interaction"; 5 | import { Vector as VectorLayer } from "ol/layer"; 6 | import { Vector as VectorSource } from "ol/source"; 7 | import { Circle, Fill, RegularShape, Stroke, Style, Text } from "ol/style"; 8 | import CircleStyle from "ol/style/Circle"; 9 | import { pointsSource } from "./snapping"; 10 | import { hexToRgba } from "./utils"; 11 | 12 | export type DrawTypeEnum = Extract; 13 | export type DrawPointerEnum = "crosshair" | "dot"; 14 | 15 | function configureDrawPointerImage( 16 | drawPointer: DrawPointerEnum, 17 | drawColor: string, 18 | ) { 19 | switch (drawPointer) { 20 | case "crosshair": 21 | return new RegularShape({ 22 | stroke: new Stroke({ 23 | color: drawColor, 24 | width: 2, 25 | }), 26 | points: 4, // crosshair aka star 27 | radius: 15, // outer radius 28 | radius2: 1, // inner radius 29 | }); 30 | case "dot": 31 | return new CircleStyle({ 32 | radius: 10, 33 | fill: new Fill({ 34 | color: drawColor, 35 | }), 36 | }); 37 | } 38 | } 39 | 40 | function getVertices(drawColor: string) { 41 | return new Style({ 42 | image: new RegularShape({ 43 | fill: new Fill({ 44 | color: "#fff", 45 | }), 46 | stroke: new Stroke({ 47 | color: drawColor, 48 | width: 2, 49 | }), 50 | points: 4, // squares 51 | radius: 5, 52 | angle: Math.PI / 4, 53 | }), 54 | geometry: function (feature) { 55 | const geom = feature.getGeometry(); 56 | if (geom instanceof Polygon) { 57 | const coordinates = geom.getCoordinates()[0]; 58 | return new MultiPoint(coordinates); 59 | } else if (geom instanceof MultiPolygon) { 60 | const coordinates = geom.getCoordinates().flat(2); 61 | return new MultiPoint(coordinates); 62 | } else { 63 | return; 64 | } 65 | }, 66 | }); 67 | } 68 | 69 | function styleFeatureLabels(drawType: DrawTypeEnum, feature: FeatureLike) { 70 | return new Text({ 71 | text: feature.get("label"), 72 | font: "bold 19px Source Sans Pro,sans-serif", 73 | placement: drawType === "Point" ? "line" : "point", // "point" placement is center point of polygon 74 | fill: new Fill({ 75 | color: "#000", 76 | }), 77 | }); 78 | } 79 | 80 | function configureDrawingLayerStyle( 81 | drawType: DrawTypeEnum, 82 | drawColor: string, 83 | drawMany: boolean, 84 | hideDrawLabels: boolean, 85 | feature: FeatureLike, 86 | ) { 87 | drawColor = feature.get("color") || drawColor; 88 | switch (drawType) { 89 | case "Point": 90 | return new Style({ 91 | image: new Circle({ 92 | radius: drawMany && !hideDrawLabels ? 12 : 10, 93 | fill: new Fill({ color: "#fff" }), 94 | stroke: new Stroke({ 95 | color: drawColor, 96 | width: drawMany && !hideDrawLabels ? 2 : 6, 97 | }), 98 | }), 99 | text: 100 | drawMany && !hideDrawLabels 101 | ? styleFeatureLabels(drawType, feature) 102 | : undefined, 103 | }); 104 | default: 105 | return [ 106 | new Style({ 107 | fill: new Fill({ 108 | color: hexToRgba(drawColor, 0.2), 109 | }), 110 | stroke: new Stroke({ 111 | color: drawColor, 112 | width: 3, 113 | }), 114 | text: 115 | drawMany && !hideDrawLabels 116 | ? styleFeatureLabels(drawType, feature) 117 | : undefined, 118 | }), 119 | getVertices(drawColor), 120 | ]; 121 | } 122 | } 123 | 124 | export const drawingSource = new VectorSource({ wrapX: false }); 125 | 126 | export function configureDrawingLayer( 127 | drawType: DrawTypeEnum, 128 | drawColor: string, 129 | drawMany: boolean, 130 | hideDrawLabels: boolean, 131 | ) { 132 | return new VectorLayer({ 133 | source: drawingSource, 134 | style: function (feature) { 135 | return configureDrawingLayerStyle( 136 | drawType, 137 | drawColor, 138 | drawMany, 139 | hideDrawLabels, 140 | feature, 141 | ); 142 | }, 143 | }); 144 | } 145 | 146 | function configureDrawInteractionStyle( 147 | drawType: DrawTypeEnum, 148 | drawPointer: DrawPointerEnum, 149 | drawColor: string, 150 | ) { 151 | switch (drawType) { 152 | case "Point": 153 | return new Style({ 154 | fill: new Fill({ color: drawColor }), 155 | }); 156 | default: 157 | return new Style({ 158 | stroke: new Stroke({ 159 | color: drawColor, 160 | width: 3, 161 | lineDash: [2, 8], 162 | }), 163 | fill: new Fill({ 164 | color: hexToRgba(drawColor, 0.2), 165 | }), 166 | image: configureDrawPointerImage(drawPointer, drawColor), 167 | }); 168 | } 169 | } 170 | 171 | export function configureDraw( 172 | drawType: DrawTypeEnum, 173 | drawPointer: DrawPointerEnum, 174 | drawColor: string, 175 | ) { 176 | return new Draw({ 177 | source: drawingSource, 178 | type: drawType, 179 | style: configureDrawInteractionStyle(drawType, drawPointer, drawColor), 180 | }); 181 | } 182 | 183 | export const snap = new Snap({ 184 | source: pointsSource, // empty if OS VectorTile basemap is disabled & zoom > 20 185 | pixelTolerance: 15, 186 | }); 187 | 188 | export function configureModify( 189 | drawPointer: DrawPointerEnum, 190 | drawColor: string, 191 | ) { 192 | return new Modify({ 193 | source: drawingSource, 194 | style: new Style({ 195 | image: configureDrawPointerImage(drawPointer, drawColor), 196 | }), 197 | }); 198 | } 199 | -------------------------------------------------------------------------------- /src/components/my-map/icons/README.md: -------------------------------------------------------------------------------- 1 | Icons are sourced from [Font-GIS](https://viglino.github.io/font-gis/?fg=arrow-o) and [Carbon Design System](https://carbondesignsystem.com/guidelines/icons/library) 2 | -------------------------------------------------------------------------------- /src/components/my-map/icons/north-arrow-n.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/my-map/icons/poi-alt.svg: -------------------------------------------------------------------------------- 1 |  2 | 16 | 37 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 57 | -------------------------------------------------------------------------------- /src/components/my-map/icons/printer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | printer 6 | 9 | 11 | -------------------------------------------------------------------------------- /src/components/my-map/icons/trash-can.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | trash-can 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/my-map/index.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, unsafeCSS } from "lit"; 2 | import { customElement, property } from "lit/decorators.js"; 3 | import apply from "ol-mapbox-style"; 4 | import { defaults as defaultControls, ScaleLine } from "ol/control"; 5 | import { FeatureLike } from "ol/Feature"; 6 | import { GeoJSON } from "ol/format"; 7 | import { Geometry, Point } from "ol/geom"; 8 | import { Feature } from "ol/index"; 9 | import { defaults as defaultInteractions } from "ol/interaction"; 10 | import { Vector as VectorLayer } from "ol/layer"; 11 | import BaseLayer from "ol/layer/Base"; 12 | import TileLayer from "ol/layer/Tile"; 13 | import VectorTileLayer from "ol/layer/VectorTile"; 14 | import Map from "ol/Map"; 15 | import { ProjectionLike, transform, transformExtent } from "ol/proj"; 16 | import { Vector as VectorSource, XYZ } from "ol/source"; 17 | import { Circle, Fill, Icon, Stroke, Style } from "ol/style"; 18 | import View from "ol/View"; 19 | import { 20 | northArrowControl, 21 | PrintControl, 22 | resetControl, 23 | scaleControl, 24 | } from "./controls"; 25 | import { 26 | configureDraw, 27 | configureDrawingLayer, 28 | configureModify, 29 | drawingSource, 30 | DrawPointerEnum, 31 | DrawTypeEnum, 32 | snap, 33 | } from "./drawing"; 34 | import pinIcon from "./icons/poi-alt.svg"; 35 | import { 36 | BasemapEnum, 37 | makeDefaultTileLayer, 38 | makeMapboxSatelliteBasemap, 39 | makeOSRasterBasemap, 40 | makeOsVectorTileBasemap, 41 | } from "./layers"; 42 | import { 43 | getFeaturesAtPoint, 44 | makeFeatureLayer, 45 | outlineSource, 46 | } from "./os-features"; 47 | import { proj27700, ProjectionEnum } from "./projections"; 48 | import { 49 | getSnapPointsFromVectorTiles, 50 | pointsLayer, 51 | pointsSource, 52 | } from "./snapping"; 53 | import styles from "./styles.scss?inline"; 54 | import { 55 | AreaUnitEnum, 56 | calculateArea, 57 | fitToData, 58 | hexToRgba, 59 | makeGeoJSON, 60 | } from "./utils"; 61 | import { GeoJSONFeatureCollection } from "ol/format/GeoJSON"; 62 | 63 | type MarkerImageEnum = "circle" | "pin"; 64 | type ResetControlImageEnum = "unicode" | "trash"; 65 | 66 | @customElement("my-map") 67 | export class MyMap extends LitElement { 68 | // ref https://github.com/e111077/vite-lit-element-ts-sass/issues/3 69 | static styles = unsafeCSS(styles); 70 | 71 | // configurable component properties 72 | @property({ type: String }) 73 | id = "map"; 74 | 75 | @property({ type: String }) 76 | dataTestId = "map-test-id"; 77 | 78 | @property({ type: Number }) 79 | latitude = 51.507351; 80 | 81 | @property({ type: Number }) 82 | longitude = -0.127758; 83 | 84 | @property({ type: String }) 85 | basemap: BasemapEnum = "OSVectorTile"; 86 | 87 | @property({ type: String }) 88 | projection: ProjectionEnum = "EPSG:4326"; 89 | 90 | @property({ type: Number }) 91 | zoom = 10; 92 | 93 | @property({ type: Number }) 94 | minZoom = 7; 95 | 96 | @property({ type: Number }) 97 | maxZoom = 22; 98 | 99 | @property({ type: Boolean }) 100 | drawMode = false; 101 | 102 | @property({ type: Boolean }) 103 | drawMany = false; 104 | 105 | @property({ type: String }) 106 | drawType: DrawTypeEnum = "Polygon"; 107 | 108 | /** 109 | * @deprecated - please set `drawColor` regardless of `drawType` 110 | */ 111 | @property({ type: String }) 112 | drawPointColor = "#2c2c2c"; 113 | 114 | @property({ type: Object }) 115 | drawGeojsonData = { 116 | type: "FeatureCollection", 117 | features: [], 118 | }; 119 | 120 | @property({ type: String }) 121 | drawGeojsonDataCopyright = ""; 122 | 123 | @property({ type: Number }) 124 | drawGeojsonDataBuffer = 100; 125 | 126 | @property({ type: String }) 127 | drawPointer: DrawPointerEnum = "crosshair"; 128 | 129 | @property({ type: Boolean }) 130 | clickFeatures = false; 131 | 132 | @property({ type: String }) 133 | drawColor = "#ff0000"; 134 | 135 | /** 136 | * @deprecated - please set `drawColor` and fill will be automatically inferred using 20% opacity 137 | */ 138 | @property({ type: String }) 139 | drawFillColor = "rgba(255, 0, 0, 0.1)"; 140 | 141 | @property({ type: Boolean }) 142 | showFeaturesAtPoint = false; 143 | 144 | @property({ type: String }) 145 | featureColor = "#0000ff"; 146 | 147 | @property({ type: Boolean }) 148 | featureFill = false; 149 | 150 | /** 151 | * @deprecated 152 | */ 153 | @property({ type: Boolean }) 154 | featureBorderNone = false; 155 | 156 | @property({ type: Number }) 157 | featureBuffer = 40; 158 | 159 | @property({ type: Boolean }) 160 | showCentreMarker = false; 161 | 162 | @property({ type: String }) 163 | markerImage: MarkerImageEnum = "circle"; 164 | 165 | @property({ type: Number }) 166 | markerLatitude = this.latitude; 167 | 168 | @property({ type: Number }) 169 | markerLongitude = this.longitude; 170 | 171 | @property({ type: String }) 172 | markerColor = "#2c2c2c"; 173 | 174 | @property({ type: Object }) 175 | geojsonData: GeoJSONFeatureCollection = { 176 | type: "FeatureCollection", 177 | features: [], 178 | }; 179 | 180 | @property({ type: Boolean }) 181 | showGeojsonDataMarkers = false; 182 | 183 | @property({ type: String }) 184 | geojsonDataCopyright = ""; 185 | 186 | @property({ type: String }) 187 | geojsonColor = "#ff0000"; 188 | 189 | @property({ type: Boolean }) 190 | geojsonFill = false; 191 | 192 | @property({ type: Number }) 193 | geojsonBuffer = 12; 194 | 195 | /** 196 | * @deprecated - please specify `basemap="OSRaster"` directly instead 197 | */ 198 | @property({ type: Boolean }) 199 | disableVectorTiles = false; 200 | 201 | @property({ type: String }) 202 | osApiKey = ""; 203 | 204 | /** 205 | * @deprecated - please set singular `osApiKey` 206 | */ 207 | @property({ type: String }) 208 | osVectorTilesApiKey = ""; 209 | 210 | /** 211 | * @deprecated - please set singular `osApiKey` 212 | */ 213 | @property({ type: String }) 214 | osFeaturesApiKey = ""; 215 | 216 | @property({ type: String }) 217 | osCopyright = 218 | `© Crown copyright and database rights ${new Date().getFullYear()} OS `; 219 | 220 | @property({ type: String }) 221 | osProxyEndpoint = ""; 222 | 223 | /** 224 | * @deprecated - please specify `basemap="MapboxSatellite"` + `mapboxAccessToken` instead 225 | */ 226 | @property({ type: Boolean }) 227 | applySatelliteStyle = false; 228 | 229 | @property({ type: String }) 230 | mapboxAccessToken = ""; 231 | 232 | @property({ type: Boolean }) 233 | hideResetControl = false; 234 | 235 | @property({ type: Boolean }) 236 | resetViewOnly = false; 237 | 238 | @property({ type: String }) 239 | resetControlImage: ResetControlImageEnum = "unicode"; 240 | 241 | @property({ type: Boolean }) 242 | staticMode = false; 243 | 244 | /** 245 | * @deprecated - both `area.squareMetres` & `area.hectares` are calculated by default now in applicable `geojsonChange` events 246 | */ 247 | @property({ type: String }) 248 | areaUnit: AreaUnitEnum = "m2"; 249 | 250 | @property({ type: Boolean }) 251 | hideDrawLabels = false; 252 | 253 | @property({ type: Boolean }) 254 | showScale = false; 255 | 256 | @property({ type: Boolean }) 257 | useScaleBarStyle = false; 258 | 259 | @property({ type: Boolean }) 260 | showNorthArrow = false; 261 | 262 | @property({ type: Boolean }) 263 | showPrint = false; 264 | 265 | @property({ type: Boolean }) 266 | collapseAttributions = false; 267 | 268 | @property({ type: Object }) 269 | clipGeojsonData = { 270 | type: "Feature", 271 | geometry: { 272 | coordinates: [], 273 | }, 274 | }; 275 | 276 | @property({ type: String }) 277 | ariaLabelOlFixedOverlay = ""; 278 | 279 | // set class property (map doesn't require any reactivity using @state) 280 | map?: Map; 281 | 282 | // called when element is created 283 | constructor() { 284 | super(); 285 | } 286 | 287 | // runs after the initial render 288 | firstUpdated() { 289 | const target = this.renderRoot.querySelector(`#${this.id}`) as HTMLElement; 290 | 291 | const isUsingOS = Boolean(this.osApiKey || this.osProxyEndpoint); 292 | 293 | const basemapLayers: BaseLayer[] = []; 294 | let osVectorTileBasemap: VectorTileLayer | undefined, 295 | osRasterBasemap: TileLayer | undefined, 296 | mapboxSatelliteBasemap: 297 | | VectorLayer>, Feature> 298 | | undefined; 299 | 300 | if (this.basemap === "OSVectorTile" && isUsingOS) { 301 | osVectorTileBasemap = makeOsVectorTileBasemap( 302 | this.osApiKey, 303 | this.osProxyEndpoint, 304 | this.osCopyright, 305 | ); 306 | basemapLayers.push(osVectorTileBasemap); 307 | } else if (this.basemap === "OSRaster" && isUsingOS) { 308 | osRasterBasemap = makeOSRasterBasemap( 309 | this.osApiKey, 310 | this.osProxyEndpoint, 311 | this.osCopyright, 312 | ); 313 | basemapLayers.push(osRasterBasemap); 314 | } else if ( 315 | this.basemap === "MapboxSatellite" && 316 | Boolean(this.mapboxAccessToken) 317 | ) { 318 | mapboxSatelliteBasemap = makeMapboxSatelliteBasemap(); 319 | basemapLayers.push(mapboxSatelliteBasemap); 320 | } else if (this.basemap === "OSM" || basemapLayers.length === 0) { 321 | // Fallback to OpenStreetMap if we've failed to make any of the above layers, or if it's set directly 322 | const osmBasemap = makeDefaultTileLayer(); 323 | basemapLayers.push(osmBasemap); 324 | } 325 | 326 | // @ts-ignore 327 | const projection: ProjectionLike = 328 | this.projection === "EPSG:27700" && Boolean(proj27700) 329 | ? proj27700 330 | : this.projection; 331 | const centerCoordinate = transform( 332 | [this.longitude, this.latitude], 333 | projection, 334 | "EPSG:3857", 335 | ); 336 | 337 | const clipFeature = 338 | this.clipGeojsonData.geometry?.coordinates?.length > 0 && 339 | new GeoJSON().readFeature(this.clipGeojsonData, { 340 | featureProjection: "EPSG:3857", 341 | }); 342 | const clipExtent = 343 | clipFeature && 344 | !Array.isArray(clipFeature) && 345 | clipFeature.getGeometry()?.getExtent(); 346 | 347 | const map = new Map({ 348 | target, 349 | layers: basemapLayers, 350 | view: new View({ 351 | projection: "EPSG:3857", 352 | extent: clipExtent 353 | ? clipExtent 354 | : transformExtent( 355 | // UK Boundary 356 | [-10.76418, 49.528423, 1.9134116, 61.331151], 357 | "EPSG:4326", 358 | "EPSG:3857", 359 | ), 360 | minZoom: this.minZoom, 361 | maxZoom: this.maxZoom, 362 | center: centerCoordinate, 363 | zoom: this.zoom, 364 | enableRotation: false, 365 | }), 366 | controls: defaultControls({ 367 | attribution: true, 368 | attributionOptions: { 369 | collapsed: this.collapseAttributions, 370 | }, 371 | zoom: !this.staticMode, 372 | rotate: false, // alternatively uses custom prop `showNorthArrow` 373 | }), 374 | interactions: defaultInteractions({ 375 | doubleClickZoom: !this.staticMode, 376 | dragPan: !this.staticMode, 377 | mouseWheelZoom: !this.staticMode, 378 | }), 379 | }); 380 | 381 | this.map = map; 382 | 383 | if (this.basemap === "MapboxSatellite" && mapboxSatelliteBasemap) { 384 | apply( 385 | map, 386 | `https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=${this.mapboxAccessToken}`, 387 | ); 388 | } 389 | 390 | // Append to global window for reference in tests 391 | window.olMap = import.meta.env.VITEST ? this.map : undefined; 392 | 393 | // make configurable interactions available 394 | const draw = configureDraw(this.drawType, this.drawPointer, this.drawColor); 395 | const modify = configureModify(this.drawPointer, this.drawColor); 396 | 397 | // Add a custom 'reset' control to the map 398 | const handleReset = () => { 399 | // Reset the view port of the map based on available data or center/zoom by default 400 | if (this.showFeaturesAtPoint) { 401 | fitToData(map, outlineSource, this.featureBuffer); 402 | } else if (geojsonSource.getFeatures().length > 0) { 403 | fitToData(map, geojsonSource, this.geojsonBuffer); 404 | } else if (this.resetViewOnly && drawingSource.getFeatures().length > 0) { 405 | fitToData(map, drawingSource, this.drawGeojsonDataBuffer); 406 | } else { 407 | map.getView().setCenter(centerCoordinate); 408 | map.getView().setZoom(this.zoom); 409 | } 410 | 411 | // If in drawMode, also clear features from the drawingSource by default 412 | if (this.drawMode && !this.resetViewOnly) { 413 | drawingSource.clear(); 414 | this.dispatch("geojsonChange", {}); 415 | map.addInteraction(draw); 416 | map.addInteraction(snap); 417 | } 418 | }; 419 | 420 | if (!this.hideResetControl) { 421 | map.addControl(resetControl(handleReset, this.resetControlImage)); 422 | } 423 | 424 | // add custom scale line and north arrow controls to the map 425 | let scale: ScaleLine; 426 | if (this.showNorthArrow) { 427 | map.addControl(northArrowControl()); 428 | } 429 | 430 | if (this.showScale) { 431 | scale = scaleControl(this.useScaleBarStyle); 432 | map.addControl(scale); 433 | } 434 | 435 | if (this.showPrint) { 436 | const printControl = new PrintControl({ map }); 437 | map.addControl(printControl); 438 | } 439 | 440 | // Apply aria-labels to OL Controls for accessibility 441 | const olControls: NodeListOf | undefined = 442 | this.renderRoot?.querySelectorAll(".ol-control button"); 443 | olControls?.forEach((node) => 444 | node.setAttribute("aria-label", node.getAttribute("title") || ""), 445 | ); 446 | 447 | // Apply aria-controls to attribution button for accessibility 448 | const olAttributionButton: NodeListOf | undefined = 449 | this.renderRoot?.querySelectorAll(".ol-attribution button"); 450 | olAttributionButton?.forEach((node) => 451 | node.setAttribute("aria-controls", "ol-attribution-list"), 452 | ); 453 | 454 | // Apply ID to attribution list for accessibility 455 | const olList: NodeListOf | undefined = 456 | this.renderRoot?.querySelectorAll(".ol-attribution ul"); 457 | olList?.forEach((node) => node.setAttribute("id", "ol-attribution-list")); 458 | 459 | // Re-order overlay elements so that OL Attribution is final element 460 | // making OL Controls first in natural tab order for accessibility 461 | const olAttribution = this.renderRoot?.querySelector( 462 | ".ol-attribution", 463 | ) as Node; 464 | const olOverlay = this.renderRoot?.querySelector( 465 | ".ol-overlaycontainer-stopevent", 466 | ); 467 | olOverlay?.append(olAttribution); 468 | 469 | // define cursors for dragging/panning and moving 470 | map.on("pointerdrag", () => { 471 | map.getViewport().style.cursor = "grabbing"; 472 | }); 473 | 474 | map.on("pointermove", () => { 475 | map.getViewport().style.cursor = "grab"; 476 | }); 477 | 478 | // Display static GeoJSON if features are provided 479 | const geojsonSource = new VectorSource(); 480 | 481 | if (this.geojsonData.type === "FeatureCollection") { 482 | let features = new GeoJSON().readFeatures(this.geojsonData, { 483 | featureProjection: "EPSG:3857", 484 | }); 485 | geojsonSource.addFeatures(features); 486 | } else if (this.geojsonData.type === "Feature") { 487 | let feature = new GeoJSON().readFeature(this.geojsonData, { 488 | featureProjection: "EPSG:3857", 489 | }); 490 | 491 | if (Array.isArray(feature)) return; 492 | 493 | geojsonSource.addFeature(feature); 494 | } 495 | 496 | geojsonSource.setAttributions(this.geojsonDataCopyright); 497 | 498 | const geojsonLayer = new VectorLayer({ 499 | source: geojsonSource, 500 | style: function (this: MyMap, feature: FeatureLike) { 501 | // Read color from geojson feature `properties` if set or fallback to prop 502 | let featureColor = feature.get("color") || this.geojsonColor; 503 | return new Style({ 504 | stroke: new Stroke({ 505 | color: featureColor, 506 | width: 3, 507 | }), 508 | fill: new Fill({ 509 | color: this.geojsonFill 510 | ? hexToRgba(featureColor, 0.2) 511 | : hexToRgba(featureColor, 0), 512 | }), 513 | image: new Circle({ 514 | radius: 10, 515 | fill: new Fill({ color: featureColor }), 516 | }), 517 | }); 518 | }.bind(this), 519 | }); 520 | 521 | map.addLayer(geojsonLayer); 522 | geojsonLayer.setZIndex(1001); 523 | 524 | if (geojsonSource.getFeatures().length > 0) { 525 | // fit map to extent of geojson features, overriding default zoom & center 526 | fitToData(map, geojsonSource, this.geojsonBuffer); 527 | 528 | // log total area of static geojson data (assumes single polygon for now) 529 | const data = geojsonSource.getFeatures()[0].getGeometry(); 530 | if (data) { 531 | this.dispatch("geojsonDataArea", calculateArea(data, this.areaUnit)); 532 | } 533 | } 534 | 535 | // draw interactions 536 | const drawingLayer = configureDrawingLayer( 537 | this.drawType, 538 | this.drawColor, 539 | this.drawMany, 540 | this.hideDrawLabels, 541 | ); 542 | if (this.drawMode) { 543 | // Clear drawingSource to begin, even if drawGeojsonData is provided 544 | drawingSource.clear(); 545 | 546 | // Load initial drawing (or drawings if drawMany) into the drawing source 547 | if (this.drawGeojsonData.type === "FeatureCollection") { 548 | let features = new GeoJSON().readFeatures(this.drawGeojsonData, { 549 | featureProjection: "EPSG:3857", 550 | }); 551 | drawingSource.addFeatures(features); 552 | } else if (this.drawGeojsonData.type === "Feature") { 553 | let feature = new GeoJSON().readFeature(this.drawGeojsonData, { 554 | featureProjection: "EPSG:3857", 555 | }); 556 | 557 | if (Array.isArray(feature)) return; 558 | 559 | drawingSource.addFeature(feature); 560 | } 561 | 562 | drawingSource.setAttributions(this.drawGeojsonDataCopyright); 563 | if (drawingSource.getFeatures().length > 0) { 564 | fitToData(map, drawingSource, this.drawGeojsonDataBuffer); 565 | } 566 | 567 | map.addLayer(drawingLayer); 568 | drawingLayer.setZIndex(1001); // Ensure drawing layer is on top of Mapbox Satellite style 569 | 570 | // If exactly one drawGeojsonData feature was initially provided, and we are NOT supporting drawMany, only add modify interaction to map, else add draw & modify 571 | const modifyOnly = 572 | drawingSource.getFeatures().length === 1 && !this.drawMany; 573 | if (!modifyOnly) { 574 | map.addInteraction(draw); 575 | } 576 | map.addInteraction(modify); 577 | 578 | // Snap must be added after draw and modify 579 | map.addInteraction(snap); 580 | 581 | // 'change' listens for 'drawend' and modifications 582 | drawingSource.on("change", () => { 583 | const sketches = drawingSource.getFeatures(); 584 | 585 | // Assign a label to each feature based on its' index 586 | sketches.forEach((sketch) => { 587 | // If this feature already exists and is only being modified, use its' current label, else use feature length (needs to be type string in order to be parsed by Style "Text") 588 | const label = sketch.get("label") || `${sketches.length}`; 589 | sketch.set("label", label); 590 | }); 591 | 592 | if (sketches.length > 0) { 593 | if (this.drawType === "Polygon") { 594 | // Calculate the "area" and set on geojson `properties` 595 | sketches.forEach((sketch) => { 596 | const sketchGeom = sketch.getGeometry(); 597 | if (sketchGeom) { 598 | sketch.set( 599 | "area.squareMetres", 600 | calculateArea(sketchGeom, "m2"), 601 | ); 602 | sketch.set("area.hectares", calculateArea(sketchGeom, "ha")); 603 | } 604 | }); 605 | } 606 | 607 | this.dispatch("geojsonChange", { 608 | "EPSG:3857": makeGeoJSON(sketches, "EPSG:3857"), 609 | "EPSG:27700": makeGeoJSON(sketches, "EPSG:27700"), 610 | }); 611 | 612 | // Unless specified, limit to drawing a single feature, still allowing modifications 613 | if (!this.drawMany) { 614 | map.removeInteraction(draw); 615 | } 616 | } 617 | }); 618 | } 619 | 620 | // show snapping points when in boundary drawMode, with vector tile basemap enabled, and at qualifying zoom 621 | if ( 622 | this.drawMode && 623 | this.drawType === "Polygon" && 624 | this.basemap === "OSVectorTile" && 625 | osVectorTileBasemap 626 | ) { 627 | // define zoom threshold for showing snaps (not @property yet because computationally expensive!) 628 | const snapsZoom: number = 20; 629 | 630 | // display draw vertices on top of snap points 631 | map.addLayer(pointsLayer); 632 | drawingLayer.setZIndex(1001); 633 | 634 | // extract snap-able points from the basemap, and display them as points on the map if initial render within zoom 635 | const addSnapPoints = (): void => { 636 | pointsSource.clear(); 637 | const currentZoom: number | undefined = map.getView().getZoom(); 638 | if (currentZoom && currentZoom >= snapsZoom) { 639 | const extent = map.getView().calculateExtent(map.getSize()); 640 | getSnapPointsFromVectorTiles(osVectorTileBasemap, extent); 641 | } 642 | }; 643 | 644 | // Wait for all vector tiles to finish loading before extracting points from them 645 | map.on("loadend", addSnapPoints); 646 | 647 | // Update snap points when appropriate 648 | // Timeout minimizes updates mid-pan/drag 649 | map.on("moveend", () => { 650 | const isSourceLoaded = 651 | osVectorTileBasemap.getSource()?.getState() === "ready"; 652 | if (isSourceLoaded) setTimeout(addSnapPoints, 200); 653 | }); 654 | } 655 | 656 | // OS Features API & click-to-select interactions 657 | const isUsingOSFeatures = isUsingOS && this.showFeaturesAtPoint; 658 | if (isUsingOSFeatures) { 659 | getFeaturesAtPoint( 660 | centerCoordinate, 661 | this.osApiKey, 662 | this.osProxyEndpoint, 663 | false, 664 | ); 665 | 666 | if (this.clickFeatures) { 667 | map.on("singleclick", (e) => { 668 | getFeaturesAtPoint( 669 | e.coordinate, 670 | this.osApiKey, 671 | this.osProxyEndpoint, 672 | true, 673 | ); 674 | }); 675 | } 676 | 677 | const outlineLayer = makeFeatureLayer( 678 | this.featureColor, 679 | this.featureFill, 680 | ); 681 | map.addLayer(outlineLayer); 682 | 683 | // Ensure getFeaturesAtPoint has fetched successfully 684 | outlineSource.on("change", () => { 685 | if ( 686 | outlineSource.getState() === "ready" && 687 | outlineSource.getFeatures().length > 0 688 | ) { 689 | // Fit map to extent of features 690 | fitToData(map, outlineSource, this.featureBuffer); 691 | 692 | // Calculate the total area of the feature or merged features and set on geojson `properties` 693 | const osFeatures = outlineSource.getFeatures(); 694 | if (osFeatures.length > 0) { 695 | osFeatures.forEach((osFeature) => { 696 | const osFeatureGeom = osFeature.getGeometry(); 697 | if (osFeatureGeom) { 698 | osFeature.set( 699 | "area.squareMetres", 700 | calculateArea(osFeatureGeom, "m2"), 701 | ); 702 | osFeature.set( 703 | "area.hectares", 704 | calculateArea(osFeatureGeom, "ha"), 705 | ); 706 | } 707 | }); 708 | } 709 | 710 | // Dispatch the geojson of the feature or merged features 711 | this.dispatch("featuresGeojsonChange", { 712 | "EPSG:3857": makeGeoJSON(outlineSource, "EPSG:3857"), 713 | "EPSG:27700": makeGeoJSON(outlineSource, "EPSG:27700"), 714 | }); 715 | } 716 | }); 717 | } 718 | 719 | const markerCircle = new Circle({ 720 | radius: 10, 721 | fill: new Fill({ color: this.markerColor }), 722 | }); 723 | 724 | const markerPin = new Icon({ src: pinIcon, scale: 0.5 }); 725 | 726 | const markerImage = () => { 727 | switch (this.markerImage) { 728 | case "circle": 729 | return markerCircle; 730 | case "pin": 731 | return markerPin; 732 | } 733 | }; 734 | 735 | const showNewMarker = (lon: number, lat: number) => { 736 | const markerPoint = new Point( 737 | transform([lon, lat], projection, "EPSG:3857"), 738 | ); 739 | const markerLayer = new VectorLayer({ 740 | source: new VectorSource({ 741 | features: [new Feature(markerPoint)], 742 | }), 743 | style: new Style({ image: markerImage() }), 744 | }); 745 | 746 | map.addLayer(markerLayer); 747 | markerLayer.setZIndex(1001); 748 | }; 749 | 750 | // show a marker at a point 751 | if (this.showCentreMarker) { 752 | showNewMarker(this.markerLongitude, this.markerLatitude); 753 | } 754 | 755 | if (this.showGeojsonDataMarkers) { 756 | this.geojsonData.features.forEach((feature) => { 757 | if (feature.geometry.type !== "Point") return; 758 | 759 | showNewMarker( 760 | feature.geometry.coordinates[0], 761 | feature.geometry.coordinates[1], 762 | ); 763 | }); 764 | } 765 | 766 | // Add an aria-label to the overlay canvas for accessibility 767 | const olCanvas = this.renderRoot?.querySelector("canvas.ol-fixedoverlay"); 768 | olCanvas?.setAttribute("aria-label", this.ariaLabelOlFixedOverlay); 769 | 770 | // XXX: force re-render for safari due to it thinking map is 0 height on load 771 | setTimeout(() => { 772 | window.dispatchEvent(new Event("resize")); 773 | target.style.opacity = "1"; 774 | this.dispatch("ready"); 775 | }, 500); 776 | } 777 | 778 | // render the map 779 | render() { 780 | return html` 781 |
`; 790 | } 791 | 792 | // unmount the map 793 | disconnectedCallback() { 794 | super.disconnectedCallback(); 795 | this.map?.dispose(); 796 | } 797 | 798 | /** 799 | * dispatches an event for clients to subscribe to 800 | * @param eventName 801 | * @param payload 802 | */ 803 | private dispatch = (eventName: string, payload?: any) => 804 | this.dispatchEvent( 805 | new CustomEvent(eventName, { 806 | detail: payload, 807 | }), 808 | ); 809 | } 810 | 811 | declare global { 812 | interface Window { 813 | olMap: Map | undefined; 814 | } 815 | interface HTMLElementTagNameMap { 816 | "my-map": MyMap; 817 | } 818 | } 819 | -------------------------------------------------------------------------------- /src/components/my-map/layers.test.ts: -------------------------------------------------------------------------------- 1 | import { setupMap } from "../../test-utils"; 2 | import "./index"; 3 | 4 | import type { IWindow } from "happy-dom"; 5 | import { OSM, XYZ } from "ol/source"; 6 | import VectorTileSource from "ol/source/VectorTile"; 7 | 8 | declare global { 9 | interface Window extends IWindow {} 10 | } 11 | 12 | declare global { 13 | interface Window extends IWindow {} 14 | } 15 | 16 | describe("Basemap layer loading", () => { 17 | afterEach(() => { 18 | vi.resetAllMocks(); 19 | }); 20 | 21 | it("loads OSVectorTile basemap by default and requests layers directly from OS when an API key is provided", async () => { 22 | const apiKey = process.env.VITE_APP_OS_API_KEY; 23 | await setupMap(` 24 | `); 28 | const vectorBasemap = window.olMap 29 | ?.getAllLayers() 30 | .find((layer) => layer.get("name") === "osVectorTileBasemap"); 31 | expect(vectorBasemap).toBeDefined(); 32 | const source = vectorBasemap?.getSource() as VectorTileSource; 33 | expect(source.getUrls()).toHaveLength(1); 34 | expect(source.getUrls()?.[0]).toEqual( 35 | expect.stringMatching(/^https:\/\/api.os.uk/), 36 | ); 37 | expect(source.getUrls()?.[0]).toEqual( 38 | expect.stringContaining(`key=${apiKey}`), 39 | ); 40 | }); 41 | 42 | it("loads OSVectorTile basemap by default and requests layers via proxy when an API key is not provided", async () => { 43 | const fetchSpy = vi.spyOn(window, "fetch"); 44 | 45 | const osProxyEndpoint = "https://www.my-site.com/api/v1/os"; 46 | await setupMap(` 47 | `); 51 | const vectorBasemap = window.olMap 52 | ?.getAllLayers() 53 | .find((layer) => layer.get("name") === "osVectorTileBasemap"); 54 | expect(vectorBasemap).toBeDefined(); 55 | const source = vectorBasemap?.getSource() as VectorTileSource; 56 | 57 | // Tiles are being requested via proxy 58 | expect(source.getUrls()).toHaveLength(1); 59 | expect(source.getUrls()?.[0]).toEqual( 60 | expect.stringContaining(osProxyEndpoint), 61 | ); 62 | // Style is being fetched via proxy 63 | expect(fetchSpy).toHaveBeenCalledWith( 64 | "https://www.my-site.com/api/v1/os/maps/vector/v1/vts/resources/styles?srs=3857", 65 | ); 66 | }); 67 | 68 | it("loads OSRaster basemap when an OS API key is provided", async () => { 69 | const apiKey = process.env.VITE_APP_OS_API_KEY; 70 | await setupMap(` 71 | `); 76 | const rasterBasemap = window.olMap 77 | ?.getAllLayers() 78 | .find((layer) => layer.get("name") === "osRasterBasemap"); 79 | expect(rasterBasemap).toBeDefined(); 80 | const source = rasterBasemap?.getSource() as XYZ; 81 | expect(source.getUrls()?.length).toBeGreaterThan(0); 82 | source.getUrls()?.forEach((url) => expect(url).toMatch(/api.os.uk/)); 83 | }); 84 | 85 | it.skip("loads MapboxSatellite basemap when a Mapbox access token is provided", async () => { 86 | const accessToken = process.env.VITE_APP_MAPBOX_ACCESS_TOKEN; 87 | await setupMap( 88 | ` layer.get("name") === "mapboxSatelliteBasemap"); // 'name' not getting set? 93 | expect(satelliteBasemap).toBeDefined(); 94 | }); 95 | 96 | it("loads OSM basemap when specified", async () => { 97 | await setupMap(``); 98 | const osmBasemap = window.olMap 99 | ?.getAllLayers() 100 | .find((layer) => layer.get("name") === "osmBasemap"); 101 | expect(osmBasemap).toBeDefined(); 102 | const source = osmBasemap?.getSource() as OSM; 103 | expect(source.getUrls()?.length).toBeGreaterThan(0); 104 | source 105 | .getUrls() 106 | ?.forEach((url) => expect(url).toMatch(/openstreetmap\.org/)); 107 | }); 108 | 109 | it("fallsback to an OSM basemap when an OS basemap is specified without an OS API key or proxy endpoint", async () => { 110 | await setupMap(` 111 | `); 112 | const osmBasemap = window.olMap 113 | ?.getAllLayers() 114 | .find((layer) => layer.get("name") === "osmBasemap"); 115 | expect(osmBasemap).toBeDefined(); 116 | const source = osmBasemap?.getSource() as OSM; 117 | expect(source.getUrls()?.length).toBeGreaterThan(0); 118 | source 119 | .getUrls() 120 | ?.forEach((url) => expect(url).toMatch(/openstreetmap\.org/)); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/components/my-map/layers.ts: -------------------------------------------------------------------------------- 1 | import { stylefunction } from "ol-mapbox-style"; 2 | import { MVT } from "ol/format"; 3 | import { Tile as TileLayer } from "ol/layer"; 4 | import VectorTileLayer from "ol/layer/VectorTile"; 5 | import { OSM, XYZ } from "ol/source"; 6 | import { ATTRIBUTION } from "ol/source/OSM"; 7 | import VectorTileSource from "ol/source/VectorTile"; 8 | import { getServiceURL } from "../../lib/ordnanceSurvey"; 9 | import VectorLayer from "ol/layer/Vector"; 10 | import VectorSource from "ol/source/Vector"; 11 | import { Feature } from "ol"; 12 | import { Geometry } from "ol/geom"; 13 | 14 | export type BasemapEnum = 15 | | "OSM" 16 | | "MapboxSatellite" 17 | | "OSRaster" 18 | | "OSVectorTile"; 19 | 20 | export function makeDefaultTileLayer(): TileLayer { 21 | const layer = new TileLayer({ 22 | source: new OSM({ 23 | attributions: [ATTRIBUTION], 24 | crossOrigin: "anonymous", 25 | }), 26 | }); 27 | layer.set("name", "osmBasemap"); 28 | return layer; 29 | } 30 | 31 | export function makeMapboxSatelliteBasemap(): VectorLayer< 32 | VectorSource>, 33 | Feature 34 | > { 35 | // Layer is empty besides attribution, style is "applied" after instantiating map in index.ts 36 | const layer = new VectorLayer({ 37 | source: new VectorSource({ 38 | attributions: 39 | '© Mapbox © OpenStreetMap Improve this map', 40 | }), 41 | }); 42 | layer.set("name", "mapboxSatelliteBasemap"); // @todo debug why not actually set?? 43 | return layer; 44 | } 45 | 46 | export function makeOSRasterBasemap( 47 | apiKey: string, 48 | proxyEndpoint: string, 49 | copyright: string, 50 | ): TileLayer { 51 | const tileServiceURL = getServiceURL({ 52 | service: "xyz", 53 | apiKey, 54 | proxyEndpoint, 55 | }); 56 | const layer = new TileLayer({ 57 | source: new XYZ({ 58 | url: tileServiceURL, 59 | attributions: [copyright], 60 | crossOrigin: "anonymous", 61 | maxZoom: 20, 62 | }), 63 | }); 64 | layer.set("name", "osRasterBasemap"); 65 | return layer; 66 | } 67 | 68 | export function makeOsVectorTileBasemap( 69 | apiKey: string, 70 | proxyEndpoint: string, 71 | copyright: string, 72 | ): VectorTileLayer { 73 | const vectorTileServiceUrl = getServiceURL({ 74 | service: "vectorTile", 75 | apiKey, 76 | proxyEndpoint, 77 | params: { srs: "3857" }, 78 | }); 79 | const osVectorTileLayer = new VectorTileLayer({ 80 | declutter: true, 81 | source: new VectorTileSource({ 82 | format: new MVT(), 83 | url: vectorTileServiceUrl, 84 | attributions: [copyright], 85 | }), 86 | }); 87 | 88 | const vectorTileStyleUrl = getServiceURL({ 89 | service: "vectorTileStyle", 90 | apiKey, 91 | proxyEndpoint, 92 | params: { srs: "3857" }, 93 | }); 94 | // ref https://github.com/openlayers/ol-mapbox-style#usage-example 95 | fetch(vectorTileStyleUrl) 96 | .then((response) => response.json()) 97 | .then((glStyle) => stylefunction(osVectorTileLayer, glStyle, "esri")) 98 | .catch((error) => console.log(error)); 99 | 100 | osVectorTileLayer.set("name", "osVectorTileBasemap"); 101 | return osVectorTileLayer; 102 | } 103 | -------------------------------------------------------------------------------- /src/components/my-map/main.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from "happy-dom"; 2 | import { beforeEach, describe, it, expect, MockInstance } from "vitest"; 3 | import Map from "ol/Map"; 4 | import { Feature } from "ol"; 5 | import Point from "ol/geom/Point"; 6 | import VectorSource from "ol/source/Vector"; 7 | import waitForExpect from "wait-for-expect"; 8 | 9 | import { getShadowRoot, setupMap } from "../../test-utils"; 10 | import * as snapping from "./snapping"; 11 | import "./index"; 12 | 13 | declare global { 14 | interface Window extends IWindow {} 15 | } 16 | 17 | test("olMap is added to the global window for tests", async () => { 18 | await setupMap(``); 19 | expect(window.olMap).toBeTruthy(); 20 | expect(window.olMap).toBeInstanceOf(Map); 21 | }); 22 | 23 | describe("MyMap on initial render with OSM basemap", async () => { 24 | beforeEach(() => setupMap(''), 2500); 25 | 26 | it("should render a custom element with a shadow root", () => { 27 | const map = document.body.querySelector("my-map"); 28 | expect(map).toBeTruthy; 29 | 30 | const mapShadowRoot = getShadowRoot("my-map"); 31 | expect(mapShadowRoot).toBeTruthy; 32 | }); 33 | }); 34 | 35 | describe("Keyboard navigation of map container, controls and attribution links", () => { 36 | it("map container should be keyboard navigable by default", async () => { 37 | await setupMap(``); 38 | const map = getShadowRoot("my-map")?.getElementById("map-vitest"); 39 | expect(map).toBeTruthy; 40 | expect(map?.getAttribute("tabindex")).toEqual("0"); 41 | }); 42 | 43 | it("should omit map container from tab order if not interactive", async () => { 44 | await setupMap(``); 45 | const map = getShadowRoot("my-map")?.getElementById("map-vitest"); 46 | expect(map).toBeTruthy; 47 | expect(map?.getAttribute("tabindex")).toEqual("-1"); 48 | }); 49 | 50 | it("should keep map container in tab order if attributions are collapsed", async () => { 51 | await setupMap( 52 | ``, 53 | ); 54 | const map = getShadowRoot("my-map")?.getElementById("map-vitest"); 55 | expect(map).toBeTruthy; 56 | expect(map?.getAttribute("tabindex")).toEqual("0"); 57 | }); 58 | }); 59 | 60 | describe("Snap points loading behaviour", () => { 61 | const ZOOM_WITHIN_RANGE = 25; 62 | const ZOOM_OUTWITH_RANGE = 15; 63 | 64 | const getSnapSpy: MockInstance = vi.spyOn( 65 | snapping, 66 | "getSnapPointsFromVectorTiles", 67 | ); 68 | afterEach(() => { 69 | vi.resetAllMocks(); 70 | }); 71 | 72 | it("should not load snap points if the initial zoom is not within range", async () => { 73 | await setupMap(``); 79 | const pointsLayer = window.olMap 80 | ?.getAllLayers() 81 | .find((layer) => layer.get("name") === "pointsLayer"); 82 | expect(pointsLayer).toBeDefined(); 83 | const source = pointsLayer?.getSource() as VectorSource; 84 | expect(source.getFeatures()?.length).toEqual(0); 85 | expect(getSnapSpy).not.toHaveBeenCalled(); 86 | }); 87 | 88 | it("should load snap points if the initial zoom is within range", async () => { 89 | await setupMap(``); 95 | expect(getSnapSpy).toHaveBeenCalledOnce(); 96 | }); 97 | 98 | it("should load snap points on zoom into correct range", async () => { 99 | await setupMap(``); 105 | window.olMap?.getView().setZoom(ZOOM_WITHIN_RANGE); 106 | window.olMap?.dispatchEvent("loadend"); 107 | expect(getSnapSpy).toHaveBeenCalledOnce(); 108 | }); 109 | 110 | it("should clear snap points on zoom out of range", async () => { 111 | // Setup map and add a mock snap point 112 | await setupMap(``); 118 | const pointsLayer = window.olMap 119 | ?.getAllLayers() 120 | .find((layer) => layer.get("name") === "pointsLayer"); 121 | const source = pointsLayer?.getSource() as VectorSource; 122 | const mockSnapPoint = new Feature(new Point([1, 1])); 123 | source?.addFeature(mockSnapPoint); 124 | expect(source.getFeatures()?.length).toEqual(1); 125 | 126 | // Zoom out to clear the point layer 127 | window.olMap?.getView().setZoom(ZOOM_OUTWITH_RANGE); 128 | window.olMap?.dispatchEvent("loadend"); 129 | expect(getSnapSpy).toHaveBeenCalledOnce(); 130 | expect(source.getFeatures()?.length).toEqual(0); 131 | }); 132 | 133 | it("refetches snap points on a pan", async () => { 134 | await setupMap(``); 140 | expect(getSnapSpy).toHaveBeenCalledTimes(1); 141 | window.olMap?.dispatchEvent("moveend"); 142 | await waitForExpect(() => expect(getSnapSpy).toHaveBeenCalledTimes(2)); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/components/my-map/os-features.ts: -------------------------------------------------------------------------------- 1 | import union from "@turf/union"; 2 | import { GeoJSON } from "ol/format"; 3 | import { Vector as VectorLayer } from "ol/layer"; 4 | import { toLonLat } from "ol/proj"; 5 | import { Vector as VectorSource } from "ol/source"; 6 | import { Fill, Stroke, Style } from "ol/style"; 7 | import { getServiceURL } from "../../lib/ordnanceSurvey"; 8 | 9 | import { hexToRgba } from "./utils"; 10 | import { featureCollection } from "@turf/helpers"; 11 | import { MultiPolygon, Polygon, Feature } from "geojson"; 12 | 13 | const featureSource = new VectorSource(); 14 | 15 | export const outlineSource = new VectorSource(); 16 | 17 | export function makeFeatureLayer(color: string, featureFill: boolean) { 18 | return new VectorLayer({ 19 | source: outlineSource, 20 | style: new Style({ 21 | stroke: new Stroke({ 22 | width: 3, 23 | color: color, 24 | }), 25 | fill: new Fill({ 26 | color: featureFill ? hexToRgba(color, 0.2) : hexToRgba(color, 0), 27 | }), 28 | }), 29 | }); 30 | } 31 | 32 | /** 33 | * Create an OGC XML filter parameter value which will select the TopographicArea 34 | * features containing the coordinates of the provided point 35 | * @param coord - xy coordinate 36 | * @param apiKey - Ordnance Survey Features API key, sign up here: https://osdatahub.os.uk/plans 37 | * @param proxyEndpoint - Endpoint to proxy all requests to Ordnance Survey 38 | * @param supportClickFeatures - whether the featureSource should support `clickFeatures` mode or be cleared upfront 39 | */ 40 | export function getFeaturesAtPoint( 41 | coord: Array, 42 | apiKey: any, 43 | proxyEndpoint: string, 44 | supportClickFeatures: boolean, 45 | ) { 46 | const xml = encodeURIComponent(` 47 | 48 | 49 | SHAPE 50 | 51 | ${toLonLat(coord) 52 | .reverse() 53 | .join(",")} 54 | 55 | 56 | 57 | `); 58 | 59 | // Define (WFS) parameters object 60 | const params = { 61 | service: "WFS", 62 | request: "GetFeature", 63 | version: "2.0.0", 64 | typeNames: "Topography_TopographicArea", 65 | propertyName: "TOID,DescriptiveGroup,SHAPE", 66 | outputFormat: "GEOJSON", 67 | srsName: "urn:ogc:def:crs:EPSG::4326", 68 | filter: xml, 69 | count: "1", 70 | }; 71 | 72 | const url = getServiceURL({ 73 | service: "features", 74 | apiKey, 75 | proxyEndpoint, 76 | params, 77 | }); 78 | 79 | // Use fetch() method to request GeoJSON data from the OS Features API 80 | // If successful, replace everything in the vector layer with the GeoJSON response 81 | fetch(url) 82 | .then((response) => response.json()) 83 | .then((data) => { 84 | if (!data.features.length) return; 85 | 86 | const properties = data.features[0].properties, 87 | validKeys = ["TOID", "DescriptiveGroup"]; 88 | 89 | Object.keys(properties).forEach( 90 | (key) => validKeys.includes(key) || delete properties[key], 91 | ); 92 | 93 | const geojson = new GeoJSON(); 94 | 95 | const features = geojson.readFeatures(data, { 96 | dataProjection: "EPSG:4326", // match srsName in wfsParams 97 | featureProjection: "EPSG:3857", 98 | }); 99 | 100 | if (supportClickFeatures) { 101 | // Allows for many features to be selected/deselected when `showFeaturesAtPoint` && `clickFeatures` are enabled 102 | features.forEach((feature) => { 103 | const id = feature.getProperties().TOID; 104 | const existingFeature = featureSource.getFeatureById(id); 105 | 106 | if (existingFeature) { 107 | featureSource.removeFeature(existingFeature); 108 | } else { 109 | feature.setId(id); 110 | featureSource.addFeature(feature); 111 | } 112 | }); 113 | } else { 114 | // Clears the source upfront to prevent previously fetched results from persisting when only `showFeaturesAtPoint` is enabled 115 | featureSource.clear(); 116 | features.forEach((feature) => { 117 | const id = feature.getProperties().TOID; 118 | feature.setId(id); 119 | featureSource.addFeature(feature); 120 | }); 121 | } 122 | 123 | outlineSource.clear(); 124 | 125 | // Convert OL features to GeoJSON 126 | const allFeatures = featureSource 127 | .getFeatures() 128 | .map((feature) => geojson.writeFeatureObject(feature)) 129 | .filter((feature): feature is Feature => 130 | ["Polygon", "MultiPolygon"].includes(feature.geometry.type), 131 | ); 132 | 133 | // Merge all GeoJSON features 134 | const collection = featureCollection(allFeatures); 135 | const mergedGeoJSON = union(collection); 136 | 137 | // Convert back to OL feature and add to source 138 | const mergedFeature = geojson.readFeature(mergedGeoJSON); 139 | if (!Array.isArray(mergedFeature)) { 140 | outlineSource.addFeature(mergedFeature); 141 | } 142 | }) 143 | .catch((error) => console.log(error)); 144 | } 145 | -------------------------------------------------------------------------------- /src/components/my-map/pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/my-map/projections.ts: -------------------------------------------------------------------------------- 1 | import { get as getProjection, Projection } from "ol/proj"; 2 | import { register } from "ol/proj/proj4"; 3 | import proj4 from "proj4"; 4 | 5 | export type ProjectionEnum = "EPSG:4326" | "EPSG:3857" | "EPSG:27700"; 6 | 7 | // https://openlayers.org/en/latest/examples/reprojection.html 8 | // https://spatialreference.org/ref/epsg/27700/proj4/ 9 | proj4.defs( 10 | "EPSG:27700", 11 | "+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 " + 12 | "+x_0=400000 +y_0=-100000 +ellps=airy " + 13 | "+datum=OSGB36 +units=m +no_defs" 14 | ); 15 | register(proj4); 16 | 17 | export const proj27700: Projection | null = getProjection("EPSG:27700"); 18 | -------------------------------------------------------------------------------- /src/components/my-map/snapping.ts: -------------------------------------------------------------------------------- 1 | import { Feature } from "ol"; 2 | import { Geometry } from "ol/geom"; 3 | import Point from "ol/geom/Point"; 4 | import { Vector as VectorLayer } from "ol/layer"; 5 | import VectorTileLayer from "ol/layer/VectorTile"; 6 | import VectorSource from "ol/source/Vector"; 7 | import { Fill, Style } from "ol/style"; 8 | import CircleStyle from "ol/style/Circle"; 9 | import { splitEvery } from "rambda"; 10 | 11 | export const pointsSource = new VectorSource>({ 12 | features: [], 13 | wrapX: false, 14 | }); 15 | 16 | export const pointsLayer = new VectorLayer({ 17 | source: pointsSource, 18 | properties: { 19 | name: "pointsLayer", 20 | }, 21 | style: new Style({ 22 | image: new CircleStyle({ 23 | radius: 3, 24 | fill: new Fill({ 25 | color: "grey", 26 | }), 27 | }), 28 | }), 29 | }); 30 | 31 | /** 32 | * Extract points that are available to snap to when a VectorTileLayer basemap is displayed 33 | * @param basemap - a VectorTileLayer 34 | * @param extent - an array of 4 points 35 | * @returns - a VectorSource populated with points within the extent 36 | */ 37 | export function getSnapPointsFromVectorTiles( 38 | basemap: VectorTileLayer, 39 | extent: number[], 40 | ) { 41 | const points = 42 | basemap && 43 | basemap 44 | ?.getFeaturesInExtent(extent) 45 | ?.filter((feature) => feature.getGeometry()?.getType() !== "Point") 46 | ?.flatMap((feature: any) => feature.flatCoordinates_); 47 | 48 | if (points) { 49 | return (splitEvery(2, points) as [number, number][]).forEach((pair, i) => { 50 | pointsSource.addFeature( 51 | new Feature({ 52 | geometry: new Point(pair), 53 | i, 54 | }) as never, 55 | ); 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/my-map/styles.scss: -------------------------------------------------------------------------------- 1 | $gov-uk-yellow: #ffdd00; 2 | $planx-blue: #0010a4; 3 | $planx-dark-grey: #2c2c2c; 4 | 5 | // default map size, can be overwritten with CSS 6 | :host { 7 | display: block; 8 | width: 650px; 9 | height: 650px; 10 | position: relative; 11 | } 12 | 13 | .map { 14 | height: 100%; 15 | opacity: 0; 16 | transition: opacity 0.25s; 17 | overflow: hidden; 18 | border: #000000 solid 0.15em; 19 | } 20 | 21 | .map:focus { 22 | outline: $gov-uk-yellow solid 0.25em; 23 | } 24 | 25 | .ol-control button { 26 | border-radius: 0 !important; 27 | background-color: $planx-dark-grey !important; 28 | cursor: pointer; 29 | min-width: 44px; 30 | min-height: 44px; 31 | font-size: 1.75rem; 32 | } 33 | 34 | .ol-control button:hover { 35 | background-color: rgba(44, 44, 44, 0.85) !important; 36 | } 37 | 38 | .ol-control button:focus { 39 | outline: $gov-uk-yellow solid 0.15em; 40 | } 41 | 42 | .ol-scale-line { 43 | background-color: transparent; 44 | } 45 | 46 | .ol-scale-line-inner { 47 | border: 0.2em solid $planx-dark-grey; 48 | border-top: none; 49 | color: $planx-dark-grey; 50 | font-size: 1em; 51 | font-family: inherit; 52 | } 53 | 54 | .ol-scale-bar-inner { 55 | display: flex; 56 | } 57 | 58 | .reset-control { 59 | top: 114px; 60 | left: 0.5em; 61 | } 62 | 63 | .reset-control img { 64 | width: 30px; 65 | height: auto; 66 | } 67 | 68 | .north-arrow-control img { 69 | width: 54px; 70 | height: auto; 71 | } 72 | 73 | .north-arrow-control { 74 | top: 0.5em; 75 | right: 0.5em; 76 | border-radius: 0 !important; 77 | background-color: transparent !important; 78 | } 79 | 80 | #area { 81 | position: absolute; 82 | bottom: 0; 83 | left: 0; 84 | z-index: 100; 85 | background: white; 86 | } 87 | 88 | .ol-overlaycontainer-stopevent { 89 | top: 0 !important; 90 | } 91 | 92 | .ol-print { 93 | bottom: 50px; 94 | left: 0.5em; 95 | } 96 | 97 | .ol-print img { 98 | width: 30px; 99 | height: auto; 100 | } 101 | 102 | .ol-attribution ul { 103 | display: block; 104 | } 105 | 106 | .ol-attribution li { 107 | display: list-item; 108 | } 109 | 110 | .ol-attribution a { 111 | color: $planx-blue; 112 | } 113 | 114 | .ol-attribution a:focus { 115 | color: black; 116 | background-color: $gov-uk-yellow; 117 | } 118 | -------------------------------------------------------------------------------- /src/components/my-map/utils.ts: -------------------------------------------------------------------------------- 1 | import { asArray, asString } from "ol/color"; 2 | import { buffer } from "ol/extent"; 3 | import { GeoJSON } from "ol/format"; 4 | import { GeoJSONObject } from "ol/format/GeoJSON"; 5 | import Geometry from "ol/geom/Geometry"; 6 | import { Feature } from "ol/index"; 7 | import Map from "ol/Map"; 8 | import { Vector } from "ol/source"; 9 | import VectorSource from "ol/source/Vector"; 10 | import { getArea } from "ol/sphere"; 11 | import { ProjectionEnum } from "./projections"; 12 | 13 | export type AreaUnitEnum = "m2" | "ha"; 14 | 15 | /** 16 | * Calculate the area of a polygon 17 | * @param polygon 18 | * @param unit - defaults to square metres ("m2"), or supports "ha" for hectares 19 | * @returns - the total area 20 | */ 21 | export function calculateArea( 22 | polygon: Geometry, 23 | unit: AreaUnitEnum = "m2", 24 | ): number { 25 | const area = getArea(polygon); 26 | 27 | const squareMetres = Math.round(area * 100) / 100; 28 | const hectares = squareMetres / 10000; // 1 square metre = 0.0001 hectare 29 | 30 | switch (unit) { 31 | case "m2": 32 | return squareMetres; 33 | case "ha": 34 | return hectares; 35 | } 36 | } 37 | 38 | /** 39 | * Fit map view to extent of data features, overriding default zoom & center 40 | * @param olMap - an OpenLayers map 41 | * @param olSource - an OpenLayers vector source 42 | * @param bufferValue - amount to buffer extent by, refer to https://openlayers.org/en/latest/apidoc/module-ol_extent.html#.buffer 43 | * @returns - a map view 44 | */ 45 | export function fitToData( 46 | olMap: Map, 47 | olSource: Vector>, 48 | bufferValue: number, 49 | ) { 50 | const extent = olSource.getExtent(); 51 | return olMap.getView().fit(buffer(extent, bufferValue)); 52 | } 53 | 54 | /** 55 | * Translate a hex color to an rgba string with opacity 56 | * @param hexColor - a hex color string 57 | * @param alpha - a decimal to represent opacity 58 | * @returns - a 'rgba(r,g,b,a)' string 59 | */ 60 | export function hexToRgba(hexColor: string, alpha: number) { 61 | const [r, g, b] = Array.from(asArray(hexColor)); 62 | return asString([r, g, b, alpha]); 63 | } 64 | 65 | /** 66 | * Generate a geojson object from a vector source or an array of features 67 | * @param source 68 | * @param projection 69 | * @returns 70 | */ 71 | export function makeGeoJSON( 72 | source: VectorSource> | Feature[], 73 | projection: ProjectionEnum, 74 | ): GeoJSONObject { 75 | // ref https://openlayers.org/en/latest/apidoc/module-ol_format_GeoJSON-GeoJSON.html#writeFeaturesObject 76 | const options = 77 | projection === "EPSG:27700" 78 | ? { 79 | dataProjection: projection, 80 | featureProjection: "EPSG:3857", 81 | } 82 | : { featureProjection: projection }; 83 | 84 | return new GeoJSON().writeFeaturesObject( 85 | source instanceof VectorSource ? source.getFeatures() : source, 86 | options, 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/components/postcode-search/index.ts: -------------------------------------------------------------------------------- 1 | import { html, LitElement, unsafeCSS } from "lit"; 2 | import { customElement, property, state } from "lit/decorators.js"; 3 | import { parse, toNormalised } from "postcode"; 4 | 5 | import styles from "./styles.scss?inline"; 6 | 7 | @customElement("postcode-search") 8 | export class PostcodeSearch extends LitElement { 9 | // ref https://github.com/e111077/vite-lit-element-ts-sass/issues/3 10 | static styles = unsafeCSS(styles); 11 | 12 | // configurable component properties 13 | @property({ type: String }) 14 | id = "postcode"; 15 | 16 | @property({ type: String }) 17 | errorId = "postcode-error"; 18 | 19 | @property({ type: String }) 20 | label = "Postcode"; 21 | 22 | @property({ type: String }) 23 | hintText = ""; 24 | 25 | @property({ type: String }) 26 | errorMessage = "Enter a valid UK postcode"; 27 | 28 | @property({ type: Boolean }) 29 | onlyQuestionOnPage = false; 30 | 31 | // internal reactive state 32 | @state() 33 | private _postcode: string = ""; 34 | 35 | @state() 36 | private _sanitizedPostcode: string | null = null; 37 | 38 | @state() 39 | private _showPostcodeError: boolean = false; 40 | 41 | _onInputChange(e: any) { 42 | // validate and set postcode 43 | // uses Lit ".value" syntax to set property, whereas "value" would set attribute 44 | const input: string = e.target.value; 45 | const isValid: boolean = parse(input.trim()).valid; 46 | 47 | if (isValid) { 48 | this._sanitizedPostcode = toNormalised(input.trim()); 49 | this._postcode = toNormalised(input.trim()) || input; 50 | this._showPostcodeError = false; 51 | } else { 52 | this._sanitizedPostcode = null; 53 | this._postcode = input.toUpperCase(); 54 | } 55 | 56 | // dispatch an event on every input change 57 | this.dispatch("postcodeChange", { 58 | postcode: this._sanitizedPostcode || input, 59 | isValid: isValid, 60 | }); 61 | } 62 | 63 | _onBlur() { 64 | if (!this._sanitizedPostcode) this._showPostcodeError = true; 65 | this._showError(); 66 | } 67 | 68 | _onKeyUp(e: KeyboardEvent) { 69 | if (e.key === "Enter" && !this._sanitizedPostcode) 70 | this._showPostcodeError = true; 71 | this._showError(); 72 | } 73 | 74 | // show an error message if applicable 75 | _showError() { 76 | const errorEl: HTMLElement | null | undefined = 77 | this.shadowRoot?.querySelector(`#${this.errorId}`); 78 | 79 | // display "none" ensures always present in DOM, which means role="status" will work for screenreaders 80 | if (errorEl) errorEl.style.display = "none"; 81 | if (errorEl && this._showPostcodeError) errorEl.style.display = ""; 82 | 83 | // additionally set error style on outer div to match govuk style 84 | const errorWrapperEl: HTMLElement | null | undefined = 85 | this.shadowRoot?.querySelector(`.govuk-form-group`); 86 | if (errorWrapperEl && this._showPostcodeError) 87 | errorWrapperEl.classList.add("govuk-form-group--error"); 88 | if (errorWrapperEl && !this._showPostcodeError) 89 | errorWrapperEl.classList.remove("govuk-form-group--error"); 90 | } 91 | 92 | // wrap the label in an h1 if it's the only question on the page 93 | // ref https://design-system.service.gov.uk/components/text-input/ 94 | _makeLabel() { 95 | return this.onlyQuestionOnPage 96 | ? html`

97 | 100 |

` 101 | : html``; 102 | } 103 | 104 | render() { 105 | return html`
106 | ${this._makeLabel()} 107 |
${this.hintText}
108 | 116 | 130 |
`; 131 | } 132 | 133 | /** 134 | * dispatches an event for clients to subscribe to 135 | * @param eventName 136 | * @param payload 137 | */ 138 | private dispatch = (eventName: string, payload?: any) => 139 | this.dispatchEvent( 140 | new CustomEvent(eventName, { 141 | detail: payload, 142 | }), 143 | ); 144 | } 145 | 146 | declare global { 147 | interface HTMLElementTagNameMap { 148 | "postcode-search": PostcodeSearch; 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/components/postcode-search/main.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from "happy-dom"; 2 | import { beforeEach, describe, it, expect, vi } from "vitest"; 3 | import userEvent from "@testing-library/user-event"; 4 | 5 | import { getShadowRoot, getShadowRootEl } from "../../test-utils"; 6 | 7 | import "./index"; 8 | 9 | declare global { 10 | interface Window extends IWindow {} 11 | } 12 | 13 | describe("PostcodeSearch on initial render with default props", async () => { 14 | beforeEach(async () => { 15 | document.body.innerHTML = ''; 16 | 17 | await window.happyDOM.whenAsyncComplete(); 18 | }, 1000); 19 | 20 | it("should render a custom element with a shadow root", () => { 21 | const input = document.body.querySelector("postcode-search"); 22 | expect(input).toBeTruthy; 23 | 24 | const inputShadowRoot = getShadowRoot("postcode-search"); 25 | expect(inputShadowRoot).toBeTruthy; 26 | }); 27 | 28 | it("should be keyboard navigable", () => { 29 | const input = getShadowRootEl("postcode-search", "input"); 30 | expect(input?.getAttribute("tabindex")).toEqual("0"); 31 | }); 32 | 33 | it("should have a label with the default text", () => { 34 | const label = getShadowRootEl("postcode-search", "label"); 35 | expect(label).toBeTruthy; 36 | expect(label?.innerHTML).toContain("Postcode"); 37 | expect(label?.className).toContain("govuk-label"); 38 | }); 39 | 40 | it("should associate the label with the input", () => { 41 | const label = getShadowRootEl("postcode-search", "label"); 42 | expect(label?.getAttribute("for")).toEqual("postcode-vitest"); 43 | 44 | const input = getShadowRootEl("postcode-search", "input"); 45 | expect(input?.id).toEqual("postcode-vitest"); 46 | }); 47 | 48 | it("should always render the error message container for screenreaders", () => { 49 | const error = 50 | getShadowRoot("postcode-search")?.getElementById("postcode-error"); 51 | expect(error).toBeTruthy; 52 | expect(error?.className).toContain("govuk-error-message"); 53 | expect(error?.getAttribute("role")).toEqual("status"); 54 | expect(error?.getAttribute("style")).toContain("display:none"); 55 | }); 56 | }); 57 | 58 | describe("PostcodeSearch on initial render with user configured props", async () => { 59 | it("should wrap the label in

if onlyQuestionOnPage prop is set", async () => { 60 | document.body.innerHTML = 61 | ''; 62 | 63 | await window.happyDOM.whenAsyncComplete(); 64 | 65 | const header = getShadowRootEl("postcode-search", "h1"); 66 | expect(header).toBeTruthy; 67 | expect(header?.className).toContain("govuk-label-wrapper"); 68 | expect(header?.innerHTML).toContain("label"); 69 | }); 70 | 71 | it("should show hintText if provided", async () => { 72 | document.body.innerHTML = 73 | ''; 74 | 75 | await window.happyDOM.whenAsyncComplete(); 76 | 77 | const hint = 78 | getShadowRoot("postcode-search")?.getElementById("postcode-hint"); 79 | expect(hint).toBeTruthy; 80 | expect(hint?.className).toContain("govuk-hint"); 81 | expect(hint?.innerHTML).toContain("Enter a UK postcode, not a US zip code"); 82 | 83 | const input = getShadowRootEl("postcode-search", "input"); 84 | expect(input?.getAttribute("aria-describedby")).toContain("postcode-hint"); 85 | }); 86 | }); 87 | 88 | describe("PostcodeSearch with input change", async () => { 89 | beforeEach(async () => { 90 | document.body.innerHTML = 91 | ''; 92 | 93 | await window.happyDOM.whenAsyncComplete(); 94 | }, 1000); 95 | 96 | it("should show error message onBlur if no valid input", async () => { 97 | const input = getShadowRootEl( 98 | "postcode-search", 99 | "input" 100 | ) as HTMLInputElement; 101 | 102 | // confirm error is not displayed yet (it should be _defined_ though for screenreaders) 103 | const error = getShadowRoot("postcode-search")?.getElementById( 104 | "postcode-error-vitest" 105 | ); 106 | expect(error).toBeTruthy; 107 | expect(error?.getAttribute("style")).toContain("display:none"); 108 | 109 | // focus & blur 110 | input?.focus(); 111 | input?.blur(); 112 | 113 | // confirm the error is now displayed 114 | expect(error?.getAttribute("style")).toBeNull(); 115 | expect(error?.innerHTML).toContain("Enter a valid UK postcode"); 116 | 117 | // confirm the error class is propagated to the entire form group 118 | const formGroup = 119 | getShadowRoot("postcode-search")?.querySelector(".govuk-form-group"); 120 | expect(formGroup?.className).toContain("govuk-form-group--error"); 121 | 122 | expect(input.getAttribute("aria-describedby")).toContain( 123 | "postcode-error-vitest" 124 | ); 125 | }); 126 | 127 | it("should dispatch event on input change", async () => { 128 | const spyPostcodeChange = vi.fn(); 129 | 130 | document 131 | .querySelector("postcode-search")! 132 | .addEventListener("postcodeChange", spyPostcodeChange); 133 | 134 | // input is empty on render 135 | const input = getShadowRootEl( 136 | "postcode-search", 137 | "input" 138 | ) as HTMLInputElement; 139 | expect(input!.value).toEqual(""); 140 | expect(spyPostcodeChange).not.toHaveBeenCalled(); 141 | 142 | // set input value, expect event to dispatch on each character 143 | await userEvent.type(input, "SE5"); 144 | expect(input!.value).toEqual("SE5"); 145 | expect(spyPostcodeChange).toHaveBeenCalledTimes(3); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/components/postcode-search/postcode-search.doc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: "PostcodeSearch", 3 | description: 4 | "PostcodeSearch is a Lit-wrapped, Gov.UK-styled text input that formats and validates UK postcodes using the npm package 'postcode'.", 5 | properties: [ 6 | { 7 | name: "label", 8 | type: "String", 9 | values: "Postcode (default)", 10 | }, 11 | { 12 | name: "hintText", 13 | type: "String", 14 | values: `"" (default)`, 15 | }, 16 | { 17 | name: "errorMessage", 18 | type: "String", 19 | values: "Enter a valid UK postcode (default)", 20 | }, 21 | { 22 | name: "onlyQuestionOnPage", 23 | type: "Boolean", 24 | values: "false (default), true if the label should be an

", 25 | }, 26 | { 27 | name: "id", 28 | type: "String", 29 | values: "postcode (default)", 30 | }, 31 | { 32 | name: "errorId", 33 | type: "String", 34 | values: "postcode-error (default)", 35 | }, 36 | ], 37 | methods: [ 38 | { 39 | name: "postcodeChange", 40 | params: [ 41 | { 42 | name: "postcodeChange", 43 | type: "Event Listener", 44 | values: "detail", 45 | description: 46 | "Dispatches on input change; returns the formatted input string and `true` if the input is a valid postcode", 47 | }, 48 | ], 49 | }, 50 | ], 51 | examples: [ 52 | { 53 | title: "Enter a UK postcode", 54 | description: "Standard case", 55 | template: ``, 56 | controller: function (document) { 57 | const input = document.querySelector("postcode-search"); 58 | 59 | input.addEventListener("postcodeChange", ({ detail }) => { 60 | console.debug({ detail }); 61 | }); 62 | }, 63 | }, 64 | ], 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/postcode-search/styles.scss: -------------------------------------------------------------------------------- 1 | @import "govuk-frontend/dist/govuk/index"; 2 | // @import "node_modules/govuk-frontend/govuk/components/input/_index.scss"; 3 | 4 | :host { 5 | $font-family: var( 6 | --postcode__font-family, 7 | "GDS Transport", 8 | arial, 9 | sans-serif 10 | ); 11 | $font-size: var(--postcode__font-size, 19px); 12 | $input-font-size: var(--postcode__input__font-size, 19px); 13 | $input-height: var(--postcode__input__height, 35px); 14 | 15 | .govuk-label, 16 | .govuk-hint, 17 | .govuk-error-message { 18 | font-family: $font-family; 19 | font-size: $font-size; 20 | } 21 | 22 | .govuk-input { 23 | font-family: $font-family; 24 | font-size: $input-font-size; 25 | height: $input-height; 26 | padding: var(--postcode__input__padding, 5px 34px 5px 5px); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { MyMap } from "./components/my-map/index"; 2 | export { AddressAutocomplete } from "./components/address-autocomplete/index"; 3 | export { PostcodeSearch } from "./components/postcode-search/index"; 4 | -------------------------------------------------------------------------------- /src/lib/ordnanceSurvey.test.ts: -------------------------------------------------------------------------------- 1 | import { constructURL, getServiceURL } from "./ordnanceSurvey"; 2 | 3 | describe("constructURL helper function", () => { 4 | test("simple URL construction", () => { 5 | const result = constructURL( 6 | "https://www.test.com", 7 | "/my-path/to-something" 8 | ); 9 | expect(result).toEqual("https://www.test.com/my-path/to-something"); 10 | }); 11 | 12 | test("URL with query params construction", () => { 13 | const result = constructURL( 14 | "https://www.test.com", 15 | "/my-path/to-something", 16 | { test: "params", test2: "more-params" } 17 | ); 18 | expect(result).toEqual( 19 | "https://www.test.com/my-path/to-something?test=params&test2=more-params" 20 | ); 21 | }); 22 | }); 23 | 24 | describe("getServiceURL helper function", () => { 25 | it("returns an OS service URL if an API key is passed in", () => { 26 | const result = getServiceURL({ 27 | service: "vectorTile", 28 | apiKey: "my-api-key", 29 | proxyEndpoint: "", 30 | params: { srs: "3857" }, 31 | }); 32 | 33 | expect(result).toBeDefined(); 34 | const { origin, pathname, searchParams } = new URL(result!); 35 | expect(origin).toEqual("https://api.os.uk"); 36 | expect(decodeURIComponent(pathname)).toEqual( 37 | "/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf" 38 | ); 39 | expect(searchParams.get("key")).toEqual("my-api-key"); 40 | expect(searchParams.get("srs")).toEqual("3857"); 41 | }); 42 | 43 | it("returns a proxy service URL if a proxy endpoint is passed in", () => { 44 | const result = getServiceURL({ 45 | service: "vectorTileStyle", 46 | proxyEndpoint: "https://www.my-site.com/api/proxy/os", 47 | apiKey: "", 48 | params: { srs: "3857" }, 49 | }); 50 | 51 | expect(result).toBeDefined(); 52 | const { origin, pathname, searchParams } = new URL(result!); 53 | expect(origin).toEqual("https://www.my-site.com"); 54 | expect(decodeURIComponent(pathname)).toEqual( 55 | "/api/proxy/os/maps/vector/v1/vts/resources/styles" 56 | ); 57 | expect(searchParams.get("key")).toBeNull(); 58 | expect(searchParams.get("srs")).toEqual("3857"); 59 | }); 60 | 61 | it("returns a proxy service URL if a proxy endpoint is passed in (with a trailing slash)", () => { 62 | const result = getServiceURL({ 63 | service: "xyz", 64 | proxyEndpoint: "https://www.my-site.com/api/proxy/os/", 65 | apiKey: "", 66 | }); 67 | 68 | expect(result).toBeDefined(); 69 | const { origin, pathname } = new URL(result!); 70 | expect(origin).toEqual("https://www.my-site.com"); 71 | expect(decodeURIComponent(pathname)).toEqual( 72 | "/api/proxy/os/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png" 73 | ); 74 | }); 75 | 76 | it("throws error without an API key or proxy endpoint", () => { 77 | expect(() => 78 | getServiceURL({ 79 | service: "xyz", 80 | apiKey: "", 81 | proxyEndpoint: "", 82 | }) 83 | ).toThrowError(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/lib/ordnanceSurvey.ts: -------------------------------------------------------------------------------- 1 | const OS_DOMAIN = "https://api.os.uk"; 2 | 3 | type OSServices = 4 | | "xyz" 5 | | "vectorTile" 6 | | "vectorTileStyle" 7 | | "places" 8 | | "features"; 9 | 10 | interface ServiceOptions { 11 | service: OSServices; 12 | apiKey: string; 13 | proxyEndpoint: string; 14 | params?: Record; 15 | } 16 | 17 | // Ordnance Survey sources 18 | const PATH_LOOKUP: Record = { 19 | xyz: "/maps/raster/v1/zxy/Light_3857/{z}/{x}/{y}.png", 20 | vectorTile: "/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf", 21 | vectorTileStyle: "/maps/vector/v1/vts/resources/styles", 22 | places: "/search/places/v1/postcode", 23 | features: "/features/v1/wfs", 24 | }; 25 | 26 | export function constructURL( 27 | domain: string, 28 | path: string, 29 | params: Record = {} 30 | ): string { 31 | const url = new URL(path, domain); 32 | url.search = new URLSearchParams(params).toString(); 33 | // OL requires that {z}/{x}/{y} are not encoded in order to substitue in real values 34 | const openLayersURL = decodeURI(url.href); 35 | return openLayersURL; 36 | } 37 | 38 | export function getOSServiceURL({ 39 | service, 40 | apiKey, 41 | params, 42 | }: Omit): string { 43 | const osServiceURL = constructURL(OS_DOMAIN, PATH_LOOKUP[service], { 44 | ...params, 45 | key: apiKey, 46 | }); 47 | return osServiceURL; 48 | } 49 | 50 | /** 51 | * Generate a proxied OS service URL 52 | * XXX: OS API key must be appended to requests by the proxy endpoint 53 | */ 54 | export function getProxyServiceURL({ 55 | service, 56 | proxyEndpoint, 57 | params, 58 | }: Omit): string { 59 | let { origin: proxyOrigin, pathname: proxyPathname } = new URL(proxyEndpoint); 60 | // Remove trailing slash on pathname if present 61 | proxyPathname = proxyPathname.replace(/\/$/, ""); 62 | const proxyServiceURL = constructURL( 63 | proxyOrigin, 64 | proxyPathname + PATH_LOOKUP[service], 65 | params 66 | ); 67 | return proxyServiceURL; 68 | } 69 | 70 | /** 71 | * Get either an OS service URL, or a proxied endpoint to an OS service URL 72 | */ 73 | export function getServiceURL({ 74 | service, 75 | apiKey, 76 | proxyEndpoint, 77 | params, 78 | }: ServiceOptions): string { 79 | if (proxyEndpoint) 80 | return getProxyServiceURL({ service, proxyEndpoint, params }); 81 | if (apiKey) return getOSServiceURL({ service, apiKey, params }); 82 | throw Error( 83 | `Unable to generate URL for OS ${service} API. Either an API key or proxy endpoint must be supplied` 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | // Helper method to access the shadow root of a custom element 2 | export function getShadowRoot(customEl: string): ShadowRoot | null | undefined { 3 | return document.body.querySelector(customEl)?.shadowRoot; 4 | } 5 | 6 | // Helper method to access a specific HTML element within the shadow root of a custom element 7 | export function getShadowRootEl( 8 | customEl: string, 9 | el: string 10 | ): Element | null | undefined { 11 | return document.body.querySelector(customEl)?.shadowRoot?.querySelector(el); 12 | } 13 | 14 | export async function setupMap(mapElement: any) { 15 | document.body.innerHTML = mapElement; 16 | await window.happyDOM.whenAsyncComplete(); 17 | window.olMap?.dispatchEvent("loadend"); 18 | } 19 | 20 | module.exports = { 21 | getShadowRoot, 22 | getShadowRootEl, 23 | setupMap, 24 | }; 25 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | VITE_APP_OS_API_KEY: string; 5 | VITE_APP_MAPBOX_ACCESS_TOKEN: string; 6 | } 7 | 8 | declare module "ol-mapbox-style/dist/stylefunction"; 9 | declare module "accessible-autocomplete"; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "experimentalDecorators": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["es2019", "dom", "dom.iterable"], 10 | "module": "es2020", 11 | "moduleResolution": "node", 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "outDir": "./types", 17 | "rootDir": "./src", 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "types": ["vitest/globals"], 21 | "useDefineForClassFields": false 22 | }, 23 | "include": ["src/**/*.ts", "types/*.d.ts"], 24 | "exclude": ["src/components/*.doc.js", "src/components/docs/*.doc.js"] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from "vite"; 3 | import path from "path"; 4 | import litcss from 'rollup-plugin-postcss-lit'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | lib: { 10 | entry: path.resolve(__dirname, "src/index.ts"), 11 | name: "ComponentLib", 12 | formats: ["es", "umd"], 13 | fileName: (format) => `component-lib.${format}.js`, 14 | }, 15 | rollupOptions: { 16 | // external: /^lit-element/, 17 | // input: { 18 | // main: resolve(__dirname, "index.html"), 19 | // }, 20 | }, 21 | }, 22 | plugins: [ 23 | // @ts-ignore 24 | { 25 | ...litcss(), 26 | enforce: "post", 27 | }, 28 | ], 29 | // https://vitest.dev/config/#options 30 | test: { 31 | globals: true, 32 | environment: "happy-dom", 33 | }, 34 | resolve: { 35 | alias: { 36 | "govuk-frontend": path.resolve( 37 | __dirname, 38 | "./node_modules/govuk-frontend" 39 | ), 40 | }, 41 | }, 42 | }); 43 | --------------------------------------------------------------------------------