-
Notifications
You must be signed in to change notification settings - Fork 1
feat: optionally show a search bar when drawing on an OS basemap to position the map view #618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6b3d9e7
55e6842
1c4aed5
4c6985a
2589207
a591b2e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,11 @@ | ||
| import { html, LitElement, unsafeCSS } from "lit"; | ||
| import { customElement, property } from "lit/decorators.js"; | ||
| import { customElement, property, state } from "lit/decorators.js"; | ||
| import apply from "ol-mapbox-style"; | ||
| import { defaults as defaultControls, ScaleLine } from "ol/control"; | ||
| import { containsCoordinate, Extent } from "ol/extent"; | ||
| import { FeatureLike } from "ol/Feature"; | ||
| import { GeoJSON } from "ol/format"; | ||
| import { GeoJSONFeature, GeoJSONFeatureCollection } from "ol/format/GeoJSON"; | ||
| import { Geometry, Point } from "ol/geom"; | ||
| import { Feature } from "ol/index"; | ||
| import { defaults as defaultInteractions } from "ol/interaction"; | ||
|
|
@@ -58,7 +60,6 @@ import { | |
| hexToRgba, | ||
| makeGeoJSON, | ||
| } from "./utils"; | ||
| import { GeoJSONFeatureCollection } from "ol/format/GeoJSON"; | ||
|
|
||
| type MarkerImageEnum = "circle" | "pin"; | ||
| type ResetControlImageEnum = "unicode" | "trash"; | ||
|
|
@@ -72,6 +73,9 @@ export class MyMap extends LitElement { | |
| @property({ type: String }) | ||
| id = "map"; | ||
|
|
||
| @property({ type: Boolean }) | ||
| showOSSearch = false; | ||
|
|
||
| @property({ type: String }) | ||
| dataTestId = "map-test-id"; | ||
|
|
||
|
|
@@ -269,16 +273,18 @@ export class MyMap extends LitElement { | |
| collapseAttributions = false; | ||
|
|
||
| @property({ type: Object }) | ||
| clipGeojsonData = { | ||
| type: "Feature", | ||
| geometry: { | ||
| coordinates: [], | ||
| }, | ||
| }; | ||
| clipGeojsonData: GeoJSONFeature | undefined = undefined; | ||
|
|
||
| @property({ type: String }) | ||
| ariaLabelOlFixedOverlay = ""; | ||
|
|
||
| // internal reactive state | ||
| @state() | ||
| private _showSearch: boolean = false; | ||
|
|
||
| @state() | ||
| private _searchError: string | undefined = undefined; | ||
|
|
||
| // set class property (map doesn't require any reactivity using @state) | ||
| map?: Map; | ||
|
|
||
|
|
@@ -288,7 +294,7 @@ export class MyMap extends LitElement { | |
| } | ||
|
|
||
| // runs after the initial render | ||
| firstUpdated() { | ||
| async firstUpdated() { | ||
| const target = this.renderRoot.querySelector(`#${this.id}`) as HTMLElement; | ||
|
|
||
| const isUsingOS = Boolean(this.osApiKey || this.osProxyEndpoint); | ||
|
|
@@ -337,29 +343,30 @@ export class MyMap extends LitElement { | |
| "EPSG:3857", | ||
| ); | ||
|
|
||
| const clipFeature = | ||
| this.clipGeojsonData.geometry?.coordinates?.length > 0 && | ||
| new GeoJSON().readFeature(this.clipGeojsonData, { | ||
| // Define a clip extent for the map viewport | ||
| let clipExtent: Extent | undefined; | ||
| if (this.clipGeojsonData) { | ||
| const clipFeature = new GeoJSON().readFeature(this.clipGeojsonData, { | ||
| featureProjection: "EPSG:3857", | ||
| }); | ||
| const clipExtent = | ||
| clipFeature && | ||
| !Array.isArray(clipFeature) && | ||
| clipFeature.getGeometry()?.getExtent(); | ||
| if (clipFeature && !Array.isArray(clipFeature)) { | ||
| clipExtent = clipFeature.getGeometry()?.getExtent(); | ||
| } | ||
| } else { | ||
| // Fallback to UK boundary if no user prop | ||
| clipExtent = transformExtent( | ||
| [-10.76418, 49.528423, 1.9134116, 61.331151], | ||
| "EPSG:4326", | ||
| "EPSG:3857", | ||
| ); | ||
| } | ||
|
|
||
| const map = new Map({ | ||
| target, | ||
| layers: basemapLayers, | ||
| view: new View({ | ||
| projection: "EPSG:3857", | ||
| extent: clipExtent | ||
| ? clipExtent | ||
| : transformExtent( | ||
| // UK Boundary | ||
| [-10.76418, 49.528423, 1.9134116, 61.331151], | ||
| "EPSG:4326", | ||
| "EPSG:3857", | ||
| ), | ||
| extent: clipExtent, | ||
| minZoom: this.minZoom, | ||
| maxZoom: this.maxZoom, | ||
| center: centerCoordinate, | ||
|
|
@@ -785,6 +792,42 @@ export class MyMap extends LitElement { | |
| }); | ||
| } | ||
|
|
||
| if (this._showSearch) { | ||
| const search = this.renderRoot?.querySelector("geocode-autocomplete"); | ||
| if (search) { | ||
| // Give the browser a chance to paint | ||
| // Ref https://lit.dev/docs/v1/components/events/#add-event-listeners-after-first-paint | ||
| await new Promise((r) => setTimeout(r, 0)); | ||
|
|
||
| search.addEventListener( | ||
| "addressSelection", | ||
| ({ detail: address }: any) => { | ||
| console.debug("searched", { detail: address }); | ||
| const searchedAddress = address?.address?.LPI; | ||
| const newCenterCoordinate = transform( | ||
| [searchedAddress.LNG, searchedAddress.LAT], | ||
| "EPSG:4326", // LPI output srs | ||
| "EPSG:3857", | ||
| ); | ||
|
|
||
| // Validate that the searched point is within the clip extent of the map viewport | ||
| const searchedPointWithinClip = | ||
| clipExtent && containsCoordinate(clipExtent, newCenterCoordinate); | ||
| if (searchedPointWithinClip) { | ||
| // Navigate to the searched address point | ||
| map.getView().setCenter(newCenterCoordinate); | ||
| map.getView().setZoom(20); | ||
| } else { | ||
| // Show an error | ||
| this._searchError = | ||
| "Selected address not within map view extent, try another."; | ||
| this._showSearchError(); | ||
| } | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Add an aria-label to the overlay canvas for accessibility | ||
| const olCanvas = this.renderRoot?.querySelector("canvas.ol-fixedoverlay"); | ||
| olCanvas?.setAttribute("aria-label", this.ariaLabelOlFixedOverlay); | ||
|
|
@@ -797,21 +840,75 @@ export class MyMap extends LitElement { | |
| }, 500); | ||
| } | ||
|
|
||
| _showSearchError() { | ||
| const errorEl: HTMLElement | null | undefined = | ||
| this.shadowRoot?.querySelector(`#geocode-autocomplete-error`); | ||
|
|
||
| // display "none" ensures always present in DOM, which means role="status" will work for screenreaders | ||
| if (errorEl) errorEl.style.display = "none"; | ||
| if (errorEl && this._searchError) errorEl.style.display = ""; | ||
| } | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This follows the pattern established by |
||
|
|
||
| // render the map | ||
| render() { | ||
| return html`<link | ||
| rel="stylesheet" | ||
| href="https://cdn.skypack.dev/ol@^6.6.1/ol.css" | ||
| /> | ||
| <div | ||
| id="${this.id}" | ||
| class="map" | ||
| role="${this.staticMode && !this.collapseAttributions | ||
| ? "presentation" | ||
| : "application"}" | ||
| tabindex="${this.staticMode && !this.collapseAttributions ? -1 : 0}" | ||
| data-testid="${this.dataTestId}" | ||
| />`; | ||
| this._showSearch = | ||
| this.showOSSearch && | ||
| this.drawMode && | ||
| ["OSVectorTile", "OSRaster"].includes(this.basemap) && | ||
| (Boolean(this.osApiKey) || Boolean(this.osProxyEndpoint)); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Showing search" isn't as simple as setting only the This should avoid a few weird edge cases which could otherwise be created, for example:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense to me...! We have a few similar guards here where props need to be grouped. If this was a pure React component it would be a good case for a discriminated union of props to enforce this, but I'm not too sure how we'd...
One to have a look at (timeboxed) in future maybe. |
||
|
|
||
| return this._showSearch | ||
| ? html` <div | ||
| id="error-message-container" | ||
| class="${this._searchError ? "govuk-warning-text" : ""}" | ||
| role="status" | ||
| > | ||
| <div | ||
| id="geocode-autocomplete-error" | ||
| class="govuk-error-message" | ||
| style="display:none" | ||
| role="status" | ||
| > | ||
| <span class="govuk-visually-hidden">Error:</span> | ||
| ${this._searchError} | ||
| </div> | ||
| <div style="margin-bottom: 1em; background-color: white"> | ||
| <geocode-autocomplete | ||
| id="geocode-autocomplete" | ||
| arrowStyle="light" | ||
| labelStyle="static" | ||
| label="Search for an address to position the map" | ||
| osApiKey="${this.osApiKey}" | ||
| osProxyEndpoint="${this.osProxyEndpoint}" | ||
| /> | ||
| </div> | ||
| </div> | ||
| <link | ||
| rel="stylesheet" | ||
| href="https://cdn.skypack.dev/ol@^6.6.1/ol.css" | ||
| /> | ||
| <div | ||
| id="${this.id}" | ||
| class="map" | ||
| role="${this.staticMode && !this.collapseAttributions | ||
| ? "presentation" | ||
| : "application"}" | ||
| tabindex="${this.staticMode && !this.collapseAttributions ? -1 : 0}" | ||
| data-testid="${this.dataTestId}" | ||
| />` | ||
| : html` <link | ||
| rel="stylesheet" | ||
| href="https://cdn.skypack.dev/ol@^6.6.1/ol.css" | ||
| /> | ||
| <div | ||
| id="${this.id}" | ||
| class="map" | ||
| role="${this.staticMode && !this.collapseAttributions | ||
| ? "presentation" | ||
| : "application"}" | ||
| tabindex="${this.staticMode && !this.collapseAttributions ? -1 : 0}" | ||
| data-testid="${this.dataTestId}" | ||
| />`; | ||
| } | ||
|
|
||
| // unmount the map | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great to see this included 👍 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -234,6 +234,16 @@ const meta: Meta = { | |
| defaultValue: { summary: '"m2"' }, | ||
| }, | ||
| }, | ||
| showOSSearch: { | ||
| description: | ||
| "Show a search bar (<geocode-autocomplete />) to position (re-center) the map at a known address when drawing on an OS Basemap", | ||
| control: "boolean", | ||
| table: { | ||
| category: "Drawing", | ||
| type: { summary: "Boolean" }, | ||
| defaultValue: { summary: "false" }, | ||
| }, | ||
| }, | ||
| // ── GeoJSON ────────────────────────────────────────────────── | ||
| geojsonData: { | ||
| description: | ||
|
|
@@ -506,6 +516,24 @@ export const DrawModeGeoJSONOutput: Story = { | |
| }, | ||
| }; | ||
|
|
||
| /** | ||
| * Show a search bar to position (re-center) the map when drawing on an OS basemap. | ||
| */ | ||
| export const DrawModeWithSearch: Story = { | ||
| name: "Drawing: draw mode with search", | ||
| render: () => ` | ||
| <my-map | ||
| id="draw-mode-with-search" | ||
| zoom="20" | ||
| maxZoom="23" | ||
| drawMode | ||
| drawPointer="dot" | ||
| showOSSearch | ||
| basemap="OSVectorTile" | ||
| osVectorTilesApiKey="${osApiKey}" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I actually can't get this to render on the Netlify env, or locally, when I pass in either a key or proxy endpoint. This might be a problem with how I originally set this up though, the same applied to other stories 🤔 Were you able to get this to work locally?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was also not able to get Storybook working locally, even with API key as plain string passed in, but not uniuqe to this new story for me! Happy to come back to this as a follow-up, meant to similarly point out here! Local dev & index.html examples were completely fine/working as usual, so not anticipating issues with FindProperty integration. But will be an |
||
| </my-map>`, | ||
| }; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // GeoJSON | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| @import "govuk-frontend/dist/govuk/index"; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now required for styling |
||
|
|
||
| $gov-uk-yellow: #ffdd00; | ||
| $planx-blue: #0010a4; | ||
| $planx-dark-grey: #2c2c2c; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lit
Objecttypes have always been a bit weird with default value handling, but this feels a lot more semantically meaningful now and should be functioning exactly the same (eg snap feature which has good unit test coverage also relies on clip extent).