From c0db6b7f9b0b73f5ea1c993c4f4ce7cbe51071fe Mon Sep 17 00:00:00 2001 From: John Rees Date: Wed, 29 Sep 2021 17:31:05 +0100 Subject: [PATCH 01/10] snapping test --- src/draw.ts | 20 ++++++++++----- src/my-map.ts | 70 +++++++++++++++++++++++++++++++++++++++++---------- tsconfig.json | 2 +- 3 files changed, 72 insertions(+), 20 deletions(-) diff --git a/src/draw.ts b/src/draw.ts index ccb58c10..0d5d3cd0 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -1,11 +1,18 @@ -import MultiPoint from 'ol/geom/MultiPoint'; +import MultiPoint from "ol/geom/MultiPoint"; import { Draw, Modify, Snap } from "ol/interaction"; import { Vector as VectorLayer } from "ol/layer"; import { Vector as VectorSource } from "ol/source"; -import { Circle as CircleStyle, Fill, RegularShape, Stroke, Style } from "ol/style"; +import { + Circle as CircleStyle, + Fill, + RegularShape, + Stroke, + Style, +} from "ol/style"; +import { pointsSource } from "./my-map"; const redLineBase = { - color: '#ff0000', + color: "#ff0000", width: 3, }; @@ -30,7 +37,7 @@ const drawingPointer = new CircleStyle({ const drawingVertices = new Style({ image: new RegularShape({ fill: new Fill({ - color: "#fff" + color: "#fff", }), stroke: new Stroke({ color: "#ff0000", @@ -57,7 +64,7 @@ export const drawingLayer = new VectorLayer({ stroke: redLineStroke, }), drawingVertices, - ] + ], }); export const draw = new Draw({ @@ -71,7 +78,8 @@ export const draw = new Draw({ }); export const snap = new Snap({ - source: drawingSource, + // source: drawingSource, + source: pointsSource, pixelTolerance: 15, }); diff --git a/src/my-map.ts b/src/my-map.ts index 3fe4460a..5aacf910 100644 --- a/src/my-map.ts +++ b/src/my-map.ts @@ -1,26 +1,32 @@ import { css, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { Feature } from "ol"; import { Control, defaults as defaultControls } from "ol/control"; import { GeoJSON } from "ol/format"; +import Point from "ol/geom/Point"; import { defaults as defaultInteractions } from "ol/interaction"; import { Vector as VectorLayer } from "ol/layer"; import Map from "ol/Map"; import { fromLonLat, transformExtent } from "ol/proj"; import { Vector as VectorSource } from "ol/source"; -import { Stroke, Style } from "ol/style"; +import { Fill, Stroke, Style } from "ol/style"; +import CircleStyle from "ol/style/Circle"; import View from "ol/View"; -import { last } from "rambda"; - +import { last, splitEvery } from "rambda"; import { draw, drawingLayer, drawingSource, modify, snap } from "./draw"; -import { scaleControl } from "./scale-line" import { + getFeaturesAtPoint, makeFeatureLayer, outlineSource, - getFeaturesAtPoint, } from "./os-features"; import { makeOsVectorTileBaseMap, makeRasterBaseMap } from "./os-layers"; +import { scaleControl } from "./scale-line"; import { AreaUnitEnum, fitToData, formatArea } from "./utils"; +export const pointsSource = new VectorSource({ + features: [], + wrapX: false, +}); @customElement("my-map") export class MyMap extends LitElement { // default map size, can be overridden with CSS @@ -112,11 +118,11 @@ export class MyMap extends LitElement { staticMode = false; @property({ type: String }) - areaUnit: AreaUnitEnum = "m2" + areaUnit: AreaUnitEnum = "m2"; @property({ type: String }) ariaLabel = "Interactive map"; - + @property({ type: Boolean }) showScale = false; @@ -206,6 +212,41 @@ export class MyMap extends LitElement { map.getViewport().style.cursor = "grab"; }); + const pointsLayer = new VectorLayer({ + source: pointsSource, + style: function (feature) { + return new Style({ + image: new CircleStyle({ + radius: 4, + fill: new Fill({ color: "green" }), + }), + }); + }, + }); + map.addLayer(pointsLayer); + + map.on("moveend", () => { + if (map.getView().getZoom() < 20) return; + + const extent = map.getView().calculateExtent(map.getSize()); + const points = osVectorTileBaseMap + .getSource() + .getFeaturesInExtent(extent) + .filter((x) => x.getGeometry().getType() === "Polygon") + .flatMap((x: any) => x.flatCoordinates_); + + pointsSource.clear(); + + (splitEvery(2, points) as [number, number][]).forEach((pair, i) => { + pointsSource.addFeature( + new Feature({ + geometry: new Point(pair), + i, + }) + ); + }); + }); + // add a vector layer to display static geojson if features are provided const geojsonSource = new VectorSource(); @@ -265,7 +306,10 @@ export class MyMap extends LitElement { }) ); - this.dispatch("areaChange", formatArea(lastSketchGeom, this.areaUnit)); + this.dispatch( + "areaChange", + formatArea(lastSketchGeom, this.areaUnit) + ); // limit to drawing a single polygon, only allow modifications to existing shape map.removeInteraction(draw); @@ -300,7 +344,10 @@ export class MyMap extends LitElement { // log total area of feature or merged features const data = outlineSource.getFeatures()[0].getGeometry(); - console.log("feature(s) total area:", formatArea(data, this.areaUnit)); + console.log( + "feature(s) total area:", + formatArea(data, this.areaUnit) + ); } }); } @@ -317,10 +364,7 @@ export class MyMap extends LitElement { render() { return html` -
`; +
`; } /** diff --git a/tsconfig.json b/tsconfig.json index 29f59a22..429ffacb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "emitDeclarationOnly": true, "experimentalDecorators": true, "forceConsistentCasingInFileNames": true, - "lib": ["es2017", "dom", "dom.iterable"], + "lib": ["es2019", "dom", "dom.iterable"], "module": "es2020", "moduleResolution": "node", "noFallthroughCasesInSwitch": true, From 510450b3b446171f0fd60f30d48987a879efdc30 Mon Sep 17 00:00:00 2001 From: John Rees Date: Wed, 29 Sep 2021 17:32:59 +0100 Subject: [PATCH 02/10] remove unused feature --- src/my-map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/my-map.ts b/src/my-map.ts index 5aacf910..2d2b2572 100644 --- a/src/my-map.ts +++ b/src/my-map.ts @@ -214,7 +214,7 @@ export class MyMap extends LitElement { const pointsLayer = new VectorLayer({ source: pointsSource, - style: function (feature) { + style: function () { return new Style({ image: new CircleStyle({ radius: 4, From 0d385594af0a1ba6b7e93d74b4034cdda667b50f Mon Sep 17 00:00:00 2001 From: John Rees Date: Wed, 29 Sep 2021 17:36:43 +0100 Subject: [PATCH 03/10] remove circular imports --- src/draw.ts | 2 +- src/my-map.ts | 8 ++------ src/snapping.ts | 6 ++++++ 3 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/snapping.ts diff --git a/src/draw.ts b/src/draw.ts index 0d5d3cd0..ab0a7db3 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -9,7 +9,7 @@ import { Stroke, Style, } from "ol/style"; -import { pointsSource } from "./my-map"; +import { pointsSource } from "./snapping"; const redLineBase = { color: "#ff0000", diff --git a/src/my-map.ts b/src/my-map.ts index 2d2b2572..f6cdce2f 100644 --- a/src/my-map.ts +++ b/src/my-map.ts @@ -21,12 +21,8 @@ import { } from "./os-features"; import { makeOsVectorTileBaseMap, makeRasterBaseMap } from "./os-layers"; import { scaleControl } from "./scale-line"; +import { pointsSource } from "./snapping"; import { AreaUnitEnum, fitToData, formatArea } from "./utils"; - -export const pointsSource = new VectorSource({ - features: [], - wrapX: false, -}); @customElement("my-map") export class MyMap extends LitElement { // default map size, can be overridden with CSS @@ -226,7 +222,7 @@ export class MyMap extends LitElement { map.addLayer(pointsLayer); map.on("moveend", () => { - if (map.getView().getZoom() < 20) return; + if (map.getView().getZoom() < 18) return; const extent = map.getView().calculateExtent(map.getSize()); const points = osVectorTileBaseMap diff --git a/src/snapping.ts b/src/snapping.ts new file mode 100644 index 00000000..79600179 --- /dev/null +++ b/src/snapping.ts @@ -0,0 +1,6 @@ +import VectorSource from "ol/source/Vector"; + +export const pointsSource = new VectorSource({ + features: [], + wrapX: false, +}); From 64d0c8a27b87cb624950c1f13b05d513cdd1f6f9 Mon Sep 17 00:00:00 2001 From: John Rees Date: Wed, 29 Sep 2021 17:38:17 +0100 Subject: [PATCH 04/10] change min zoom level for snaps --- src/my-map.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/my-map.ts b/src/my-map.ts index f6cdce2f..1cf54e16 100644 --- a/src/my-map.ts +++ b/src/my-map.ts @@ -222,7 +222,7 @@ export class MyMap extends LitElement { map.addLayer(pointsLayer); map.on("moveend", () => { - if (map.getView().getZoom() < 18) return; + if (map.getView().getZoom() < 19) return; const extent = map.getView().calculateExtent(map.getSize()); const points = osVectorTileBaseMap From 705fd76b62071508c92f24b7cecfc8bca4b29acf Mon Sep 17 00:00:00 2001 From: John Rees Date: Wed, 29 Sep 2021 17:45:30 +0100 Subject: [PATCH 05/10] tweaks --- index.html | 2 +- src/my-map.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 07f7783d..4b080fe4 100644 --- a/index.html +++ b/index.html @@ -14,7 +14,7 @@ /> - + diff --git a/package.json b/package.json index 0bf2ac89..7608137e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opensystemslab/map", - "version": "0.3.2", + "version": "0.3.5", "license": "OGL-UK-3.0", "private": false, "repository": { diff --git a/src/draw.ts b/src/drawing.ts similarity index 94% rename from src/draw.ts rename to src/drawing.ts index bf95ab63..f3abb03e 100644 --- a/src/draw.ts +++ b/src/drawing.ts @@ -78,9 +78,8 @@ export const draw = new Draw({ }); export const snap = new Snap({ - // source: drawingSource, - source: pointsSource, - pixelTolerance: 10, + source: pointsSource, // empty if OS VectorTile basemap is disabled & zoom > 20 + pixelTolerance: 15, }); export const modify = new Modify({ diff --git a/src/my-map.ts b/src/my-map.ts index 4de6edad..6a88d31e 100644 --- a/src/my-map.ts +++ b/src/my-map.ts @@ -1,19 +1,17 @@ import { css, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; -import { Feature } from "ol"; import { Control, defaults as defaultControls } from "ol/control"; import { GeoJSON } from "ol/format"; -import Point from "ol/geom/Point"; import { defaults as defaultInteractions } from "ol/interaction"; import { Vector as VectorLayer } from "ol/layer"; import Map from "ol/Map"; import { fromLonLat, transformExtent } from "ol/proj"; import { Vector as VectorSource } from "ol/source"; import { Fill, Stroke, Style } from "ol/style"; -import CircleStyle from "ol/style/Circle"; import View from "ol/View"; -import { last, splitEvery } from "rambda"; -import { draw, drawingLayer, drawingSource, modify, snap } from "./draw"; +import { last } from "rambda"; + +import { draw, drawingLayer, drawingSource, modify, snap } from "./drawing"; import { getFeaturesAtPoint, makeFeatureLayer, @@ -21,8 +19,13 @@ import { } from "./os-features"; import { makeOsVectorTileBaseMap, makeRasterBaseMap } from "./os-layers"; import { scaleControl } from "./scale-line"; -import { pointsSource } from "./snapping"; -import { AreaUnitEnum, fitToData, formatArea } from "./utils"; +import { + getSnapPointsFromVectorTiles, + pointsLayer, + pointsSource, +} from "./snapping"; +import { AreaUnitEnum, fitToData, formatArea, hexToRgba } from "./utils"; + @customElement("my-map") export class MyMap extends LitElement { // default map size, can be overridden with CSS @@ -74,6 +77,15 @@ export class MyMap extends LitElement { @property({ type: Boolean }) drawMode = false; + @property({ type: Object }) + drawGeojsonData = { + type: "Feature", + geometry: {}, + }; + + @property({ type: Number }) + drawGeojsonDataBuffer = 100; + @property({ type: Boolean }) showFeaturesAtPoint = false; @@ -83,6 +95,9 @@ export class MyMap extends LitElement { @property({ type: String }) featureColor = "#0000ff"; + @property({ type: Boolean }) + featureFill = false; + @property({ type: Number }) featureBuffer = 40; @@ -95,6 +110,9 @@ export class MyMap extends LitElement { @property({ type: String }) geojsonColor = "#ff0000"; + @property({ type: Boolean }) + geojsonFill = false; + @property({ type: Number }) geojsonBuffer = 12; @@ -208,48 +226,7 @@ export class MyMap extends LitElement { map.getViewport().style.cursor = "grab"; }); - const pointsLayer = new VectorLayer({ - source: pointsSource, - style: function () { - return new Style({ - image: new CircleStyle({ - radius: 4, - fill: new Fill({ color: "green" }), - }), - }); - }, - }); - map.addLayer(pointsLayer); - - map.on("moveend", () => { - if (map.getView().getZoom() < 20) { - pointsSource.clear(); - return; - } - - setTimeout(() => { - pointsSource.clear(); - - const extent = map.getView().calculateExtent(map.getSize()); - console.log(extent); - const points = osVectorTileBaseMap - .getSource() - .getFeaturesInExtent(extent) - .filter((feature) => feature.getGeometry().getType() !== "Point") - .flatMap((feature: any) => feature.flatCoordinates_); - - (splitEvery(2, points) as [number, number][]).forEach((pair, i) => { - pointsSource.addFeature( - new Feature({ - geometry: new Point(pair), - i, - }) - ); - }); - }, 200); - }); - - // add a vector layer to display static geojson if features are provided + // display static geojson if features are provided const geojsonSource = new VectorSource(); if (this.geojsonData.type === "FeatureCollection") { @@ -271,6 +248,11 @@ export class MyMap extends LitElement { color: this.geojsonColor, width: 3, }), + fill: new Fill({ + color: this.geojsonFill + ? hexToRgba(this.geojsonColor, 0.2) + : hexToRgba(this.geojsonColor, 0), + }), }), }); @@ -280,17 +262,32 @@ export class MyMap extends LitElement { // fit map to extent of geojson features, overriding default zoom & center fitToData(map, geojsonSource, this.geojsonBuffer); - // log total area of first feature (assumes geojson is a single polygon for now) + // log total area of static geojson data (assumes single polygon for now) const data = geojsonSource.getFeatures()[0].getGeometry(); - console.log("geojsonData total area:", formatArea(data, this.areaUnit)); + this.dispatch("geojsonDataArea", formatArea(data, this.areaUnit)); } + // draw interactions if (this.drawMode) { - // ensure we start from an empty array of features - drawingSource.clear(); + // check if single polygon feature was provided to load as the initial drawing + const loadInitialDrawing = + Object.keys(this.drawGeojsonData.geometry).length > 0; + if (loadInitialDrawing) { + let feature = new GeoJSON().readFeature(this.drawGeojsonData, { + featureProjection: "EPSG:3857", + }); + drawingSource.addFeature(feature); + // fit map to extent of intial feature, overriding zoom & lat/lng center + fitToData(map, drawingSource, this.drawGeojsonDataBuffer); + } else { + drawingSource.clear(); + } + map.addLayer(drawingLayer); - map.addInteraction(draw); + if (!loadInitialDrawing) { + map.addInteraction(draw); + } map.addInteraction(snap); map.addInteraction(modify); @@ -320,6 +317,31 @@ export class MyMap extends LitElement { }); } + // show snapping points when in drawMode, with vector tile basemap enabled, and at zoom > 20 + if ( + this.drawMode && + Boolean(this.osVectorTilesApiKey) && + !this.disableVectorTiles + ) { + map.addLayer(pointsLayer); + drawingLayer.setZIndex(1001); // display draw vertices on top of snap points + + map.on("moveend", () => { + if (map.getView().getZoom() < 20) { + pointsSource.clear(); + return; + } + + // extract snap-able points from the basemap, and display them as points on the map + setTimeout(() => { + pointsSource.clear(); + const extent = map.getView().calculateExtent(map.getSize()); + getSnapPointsFromVectorTiles(osVectorTileBaseMap, extent); + }, 200); + }); + } + + // OS Features API & click-to-select interactions if (this.showFeaturesAtPoint && Boolean(this.osFeaturesApiKey)) { getFeaturesAtPoint( fromLonLat([this.longitude, this.latitude]), @@ -332,7 +354,10 @@ export class MyMap extends LitElement { }); } - const outlineLayer = makeFeatureLayer(this.featureColor); + const outlineLayer = makeFeatureLayer( + this.featureColor, + this.featureFill + ); map.addLayer(outlineLayer); // ensure getFeaturesAtPoint has fetched successfully @@ -344,12 +369,17 @@ export class MyMap extends LitElement { // fit map to extent of features fitToData(map, outlineSource, this.featureBuffer); - // log total area of feature or merged features - const data = outlineSource.getFeatures()[0].getGeometry(); - console.log( - "feature(s) total area:", - formatArea(data, this.areaUnit) + // write the geojson representation of the feature or merged features + this.dispatch( + "featuresGeojsonChange", + new GeoJSON().writeFeaturesObject(outlineSource.getFeatures(), { + featureProjection: "EPSG:3857", + }) ); + + // calculate the total area of the feature or merged features + const data = outlineSource.getFeatures()[0].getGeometry(); + this.dispatch("featuresAreaChange", formatArea(data, this.areaUnit)); } }); } diff --git a/src/os-features.ts b/src/os-features.ts index 823505b9..9a340b0a 100644 --- a/src/os-features.ts +++ b/src/os-features.ts @@ -3,7 +3,9 @@ import { GeoJSON } from "ol/format"; import { Vector as VectorLayer } from "ol/layer"; import { toLonLat } from "ol/proj"; import { Vector as VectorSource } from "ol/source"; -import { Stroke, Style } from "ol/style"; +import { Fill, Stroke, Style } from "ol/style"; + +import { hexToRgba } from "./utils"; const featureServiceUrl = "https://api.os.uk/features/v1/wfs"; @@ -11,7 +13,7 @@ const featureSource = new VectorSource(); export const outlineSource = new VectorSource(); -export function makeFeatureLayer(color: string) { +export function makeFeatureLayer(color: string, featureFill: boolean) { return new VectorLayer({ source: outlineSource, style: new Style({ @@ -19,6 +21,9 @@ export function makeFeatureLayer(color: string) { width: 3, color: color, }), + fill: new Fill({ + color: featureFill ? hexToRgba(color, 0.2) : hexToRgba(color, 0), + }), }), }); } diff --git a/src/snapping.ts b/src/snapping.ts index 79600179..382ce065 100644 --- a/src/snapping.ts +++ b/src/snapping.ts @@ -1,6 +1,51 @@ +import { Feature } from "ol"; +import Point from "ol/geom/Point"; +import { Vector as VectorLayer } from "ol/layer"; +import VectorTileLayer from "ol/layer/VectorTile"; import VectorSource from "ol/source/Vector"; +import { Fill, Style } from "ol/style"; +import CircleStyle from "ol/style/Circle"; +import { splitEvery } from "rambda"; export const pointsSource = new VectorSource({ features: [], wrapX: false, }); + +export const pointsLayer = new VectorLayer({ + source: pointsSource, + style: new Style({ + image: new CircleStyle({ + radius: 3, + fill: new Fill({ + color: "black", + }), + }), + }), +}); + +/** + * Extract points that are available to snap to when a VectorTileLayer basemap is displayed + * @param basemap - a VectorTileLayer + * @param extent - an array of 4 points + * @returns - a VectorSource populated with points within the extent + */ +export function getSnapPointsFromVectorTiles( + basemap: VectorTileLayer, + extent: number[] +) { + const points = basemap + .getSource() + .getFeaturesInExtent(extent) + .filter((feature) => feature.getGeometry().getType() !== "Point") + .flatMap((feature: any) => feature.flatCoordinates_); + + return (splitEvery(2, points) as [number, number][]).forEach((pair, i) => { + pointsSource.addFeature( + new Feature({ + geometry: new Point(pair), + i, + }) + ); + }); +} diff --git a/src/utils.ts b/src/utils.ts index f108e8a3..2324365c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,10 +1,11 @@ +import { asArray, asString } from "ol/color"; import { buffer } from "ol/extent"; import Geometry from "ol/geom/Geometry"; import Map from "ol/Map"; import { Vector } from "ol/source"; import { getArea } from "ol/sphere"; -export type AreaUnitEnum = "m2" | "ha"; +export type AreaUnitEnum = "m2" | "ha"; /** * Calculate & format the area of a polygon @@ -43,3 +44,14 @@ export function fitToData( const extent = olSource.getExtent(); return olMap.getView().fit(buffer(extent, bufferValue)); } + +/** + * Translate a hex color to an rgba string with opacity + * @param hexColor - a hex color string + * @param alpha - a decimal to represent opacity + * @returns - a 'rgba(r,g,b,a)' string + */ +export function hexToRgba(hexColor: string, alpha: number) { + const [r, g, b] = Array.from(asArray(hexColor)); + return asString([r, g, b, alpha]); +}