Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 134 additions & 37 deletions src/components/my-map/index.ts
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";
Expand Down Expand Up @@ -58,7 +60,6 @@ import {
hexToRgba,
makeGeoJSON,
} from "./utils";
import { GeoJSONFeatureCollection } from "ol/format/GeoJSON";

type MarkerImageEnum = "circle" | "pin";
type ResetControlImageEnum = "unicode" | "trash";
Expand All @@ -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";

Expand Down Expand Up @@ -269,16 +273,18 @@ export class MyMap extends LitElement {
collapseAttributions = false;

@property({ type: Object })
clipGeojsonData = {
type: "Feature",
geometry: {
coordinates: [],
},
};
clipGeojsonData: GeoJSONFeature | undefined = undefined;
Copy link
Copy Markdown
Member Author

@jessicamcinchak jessicamcinchak May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lit Object types 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).


@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;

Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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 = "";
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This follows the pattern established by postcode-search and address-autocomplete, so should hopefully be a11y compliant come our next audit 🤞


// 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));
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Showing search" isn't as simple as setting only the showOSSearch prop, we also want to confirm that these other conditions are met for the best UI/UX.

This should avoid a few weird edge cases which could otherwise be created, for example:

  • Showing search on a static map, not in draw mode
  • Showing OS-based search on a draw mode map using Mapbox Satellite basemap

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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...

  • Set that up in Lit
  • Enforce this in a consumer application (exporting prop types somewhere?)

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
Expand Down
28 changes: 28 additions & 0 deletions src/components/my-map/my-map.stories.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Expand Up @@ -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:
Expand Down Expand Up @@ -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}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 alpha release regardless, justt in case!

</my-map>`,
};

// ---------------------------------------------------------------------------
// GeoJSON
// ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion src/components/my-map/snapping.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,7 +37,7 @@ export const pointsLayer = new VectorLayer({
*/
export function getSnapPointsFromVectorTiles(
basemap: VectorTileLayer,
extent: number[],
extent: Extent,
) {
const points: number[] =
basemap &&
Expand Down
2 changes: 2 additions & 0 deletions src/components/my-map/styles.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "govuk-frontend/dist/govuk/index";
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now required for styling govuk error classes.


$gov-uk-yellow: #ffdd00;
$planx-blue: #0010a4;
$planx-dark-grey: #2c2c2c;
Expand Down