From 6b3d9e72753305e730ea93a4653ffda56e4fde1a Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Sun, 26 Apr 2026 19:58:27 -0400 Subject: [PATCH 1/5] wip --- src/components/my-map/index.ts | 97 +++++++++++++++++++++---- src/components/my-map/my-map.stories.ts | 28 +++++++ 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/src/components/my-map/index.ts b/src/components/my-map/index.ts index 842ebd8..26cd002 100644 --- a/src/components/my-map/index.ts +++ b/src/components/my-map/index.ts @@ -72,6 +72,9 @@ export class MyMap extends LitElement { @property({ type: String }) id = "map"; + @property({ type: Boolean }) + showOSSearch = false; + @property({ type: String }) dataTestId = "map-test-id"; @@ -288,7 +291,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); @@ -785,6 +788,41 @@ export class MyMap extends LitElement { }); } + // Duplicated logic in render() + const showSearch = + this.showOSSearch && + this.drawMode && + ["OSVectorTile", "OSRaster"].includes(this.basemap) && + (Boolean(this.osApiKey) || Boolean(this.osProxyEndpoint)); + + if (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", + ); + + // TODO handle validation here before recentering (eg is searched point within clip extent) + // error wrapper on geocode autocomplete?? or popup/toast on map? + + map.getView().setCenter(newCenterCoordinate); + map.getView().setZoom(20); + }, + ); + } + } + // Add an aria-label to the overlay canvas for accessibility const olCanvas = this.renderRoot?.querySelector("canvas.ol-fixedoverlay"); olCanvas?.setAttribute("aria-label", this.ariaLabelOlFixedOverlay); @@ -799,19 +837,50 @@ export class MyMap extends LitElement { // render the map render() { - return html` -
`; + // Duplicated logic in firstUpdated() + const showSearch = + this.showOSSearch && + this.drawMode && + ["OSVectorTile", "OSRaster"].includes(this.basemap) && + (Boolean(this.osApiKey) || Boolean(this.osProxyEndpoint)); + + return showSearch + ? html`
+ +
+ +
` + : html` +
`; } // unmount the map diff --git a/src/components/my-map/my-map.stories.ts b/src/components/my-map/my-map.stories.ts index 54fd432..4881104 100644 --- a/src/components/my-map/my-map.stories.ts +++ b/src/components/my-map/my-map.stories.ts @@ -234,6 +234,16 @@ const meta: Meta = { defaultValue: { summary: '"m2"' }, }, }, + showOSSearch: { + description: + "Show a search bar () 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", + // TOOD ! Mock realistic "propose a new address" props + render: () => ` + `, +}; + // --------------------------------------------------------------------------- // GeoJSON // --------------------------------------------------------------------------- From 55e68420861daca8b27d11aa2195732e3c9231ef Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 27 Apr 2026 20:27:33 -0400 Subject: [PATCH 2/5] point in extent validation --- src/components/my-map/index.ts | 38 +++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/components/my-map/index.ts b/src/components/my-map/index.ts index 26cd002..e880a2f 100644 --- a/src/components/my-map/index.ts +++ b/src/components/my-map/index.ts @@ -2,8 +2,10 @@ import { html, LitElement, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import apply from "ol-mapbox-style"; import { defaults as defaultControls, ScaleLine } from "ol/control"; +import { containsCoordinate } 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"; @@ -272,11 +273,13 @@ export class MyMap extends LitElement { collapseAttributions = false; @property({ type: Object }) - clipGeojsonData = { + clipGeojsonData: GeoJSONFeature = { type: "Feature", geometry: { + type: "Polygon", coordinates: [], }, + properties: {}, }; @property({ type: String }) @@ -340,11 +343,9 @@ export class MyMap extends LitElement { "EPSG:3857", ); - const clipFeature = - this.clipGeojsonData.geometry?.coordinates?.length > 0 && - new GeoJSON().readFeature(this.clipGeojsonData, { - featureProjection: "EPSG:3857", - }); + const clipFeature = new GeoJSON().readFeature(this.clipGeojsonData, { + featureProjection: "EPSG:3857", + }); const clipExtent = clipFeature && !Array.isArray(clipFeature) && @@ -813,11 +814,24 @@ export class MyMap extends LitElement { "EPSG:3857", ); - // TODO handle validation here before recentering (eg is searched point within clip extent) - // error wrapper on geocode autocomplete?? or popup/toast on map? - - map.getView().setCenter(newCenterCoordinate); - map.getView().setZoom(20); + // Validate that the searched point is within the clip extent of the map viewport + const searchedPointWithinClip = + clipExtent && containsCoordinate(clipExtent, newCenterCoordinate); + let searchedPointNotWithinClipError: string | undefined; + + if (searchedPointWithinClip) { + map.getView().setCenter(newCenterCoordinate); + map.getView().setZoom(20); + } else { + searchedPointNotWithinClipError = + "Selected address not within map view extent, try another."; + } + + // TODO render autcomplete error message + console.log( + searchedPointWithinClip, + searchedPointNotWithinClipError, + ); }, ); } From 1c4aed55313c6739e80eea0358ae5fc7e63c1abe Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 27 Apr 2026 21:29:59 -0400 Subject: [PATCH 3/5] use ol type --- src/components/my-map/snapping.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/my-map/snapping.ts b/src/components/my-map/snapping.ts index bf54c15..59a9a3d 100644 --- a/src/components/my-map/snapping.ts +++ b/src/components/my-map/snapping.ts @@ -1,4 +1,5 @@ import { Feature } from "ol"; +import { Extent } from "ol/extent"; import { Geometry } from "ol/geom"; import Point from "ol/geom/Point"; import { Vector as VectorLayer } from "ol/layer"; @@ -36,7 +37,7 @@ export const pointsLayer = new VectorLayer({ */ export function getSnapPointsFromVectorTiles( basemap: VectorTileLayer, - extent: number[], + extent: Extent, ) { const points: number[] = basemap && From 258920763c966116b2b72a1ed50b6dbf698349b9 Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Tue, 5 May 2026 14:53:17 -0400 Subject: [PATCH 4/5] better clip types --- src/components/my-map/index.ts | 44 ++++++++++++++++------------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/components/my-map/index.ts b/src/components/my-map/index.ts index e880a2f..8e6834e 100644 --- a/src/components/my-map/index.ts +++ b/src/components/my-map/index.ts @@ -2,7 +2,7 @@ import { html, LitElement, unsafeCSS } from "lit"; import { customElement, property } from "lit/decorators.js"; import apply from "ol-mapbox-style"; import { defaults as defaultControls, ScaleLine } from "ol/control"; -import { containsCoordinate } from "ol/extent"; +import { containsCoordinate, Extent } from "ol/extent"; import { FeatureLike } from "ol/Feature"; import { GeoJSON } from "ol/format"; import { GeoJSONFeature, GeoJSONFeatureCollection } from "ol/format/GeoJSON"; @@ -273,14 +273,7 @@ export class MyMap extends LitElement { collapseAttributions = false; @property({ type: Object }) - clipGeojsonData: GeoJSONFeature = { - type: "Feature", - geometry: { - type: "Polygon", - coordinates: [], - }, - properties: {}, - }; + clipGeojsonData: GeoJSONFeature | undefined = undefined; @property({ type: String }) ariaLabelOlFixedOverlay = ""; @@ -343,27 +336,30 @@ export class MyMap extends LitElement { "EPSG:3857", ); - const clipFeature = new GeoJSON().readFeature(this.clipGeojsonData, { - featureProjection: "EPSG:3857", - }); - const clipExtent = - clipFeature && - !Array.isArray(clipFeature) && - clipFeature.getGeometry()?.getExtent(); + // 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", + }); + 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, From a591b2ec2bbd979956001966ab1330ac47d87d4f Mon Sep 17 00:00:00 2001 From: Jessica McInchak Date: Mon, 11 May 2026 16:45:44 -0400 Subject: [PATCH 5/5] handle map search errors via reactive @state --- src/components/my-map/index.ts | 78 +++++++++++++++---------- src/components/my-map/my-map.stories.ts | 8 +-- src/components/my-map/styles.scss | 2 + 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/src/components/my-map/index.ts b/src/components/my-map/index.ts index 8e6834e..1d54a1b 100644 --- a/src/components/my-map/index.ts +++ b/src/components/my-map/index.ts @@ -1,5 +1,5 @@ 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"; @@ -278,6 +278,13 @@ export class MyMap extends LitElement { @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; @@ -785,14 +792,7 @@ export class MyMap extends LitElement { }); } - // Duplicated logic in render() - const showSearch = - this.showOSSearch && - this.drawMode && - ["OSVectorTile", "OSRaster"].includes(this.basemap) && - (Boolean(this.osApiKey) || Boolean(this.osProxyEndpoint)); - - if (showSearch) { + if (this._showSearch) { const search = this.renderRoot?.querySelector("geocode-autocomplete"); if (search) { // Give the browser a chance to paint @@ -813,21 +813,16 @@ export class MyMap extends LitElement { // Validate that the searched point is within the clip extent of the map viewport const searchedPointWithinClip = clipExtent && containsCoordinate(clipExtent, newCenterCoordinate); - let searchedPointNotWithinClipError: string | undefined; - if (searchedPointWithinClip) { + // Navigate to the searched address point map.getView().setCenter(newCenterCoordinate); map.getView().setZoom(20); } else { - searchedPointNotWithinClipError = + // Show an error + this._searchError = "Selected address not within map view extent, try another."; + this._showSearchError(); } - - // TODO render autcomplete error message - console.log( - searchedPointWithinClip, - searchedPointNotWithinClipError, - ); }, ); } @@ -845,25 +840,48 @@ 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 = ""; + } + // render the map render() { - // Duplicated logic in firstUpdated() - const showSearch = + this._showSearch = this.showOSSearch && this.drawMode && ["OSVectorTile", "OSRaster"].includes(this.basemap) && (Boolean(this.osApiKey) || Boolean(this.osProxyEndpoint)); - return showSearch - ? html`
- + return this._showSearch + ? html`
+ +
+ +
` - `, }; diff --git a/src/components/my-map/styles.scss b/src/components/my-map/styles.scss index d8d09ba..2b159a7 100644 --- a/src/components/my-map/styles.scss +++ b/src/components/my-map/styles.scss @@ -1,3 +1,5 @@ +@import "govuk-frontend/dist/govuk/index"; + $gov-uk-yellow: #ffdd00; $planx-blue: #0010a4; $planx-dark-grey: #2c2c2c;