Open Layers - Vector features

post on

9 min read

This article is a part of series on OpenLayers. The code of every new feature will be based on code from previous article.

GeoJSON vector data

Let’s add a vector feature on our map. Every vector feature should belong to a Vector Layer.
Open Layer Map is composed of list of layers. Each layer can be composed in layer groups. In other words every layer group contain a list of layers. And every vector layer contain a list of features.

First of all, to create a vector feature we need data.
I will go to geojson.io and draw a circle feature in center of Nantes.

Let’s create a new folder /src/mocks and add feature data here.

vectorData.ts
// generated from http://geojson.io/
export const circleFeatureCollection = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      properties: {},
      geometry: {
        type: "Polygon",
        coordinates: [...]
      }
    }
  ]
}
vectorData.ts
// generated from http://geojson.io/
export const circleFeatureCollection = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      properties: {},
      geometry: {
        type: "Polygon",
        coordinates: [...]
      }
    }
  ]
}

Now let’s creation a function that will init layers on our map. In defaults.ts add a function getMapLayers(). This function will load vector data (initVectorLayer method) in default projection, or in projection passed as parameters if any.

defaults.ts
export const getMapLayers = (projection = DEFAULT_PROJECTION) => {
  return [
    new TileLayer({
      source: new OSM(),
    }),
    initVectorLayer(projection),
  ]
}
 
// init Vector Layer
export const initVectorLayer = (
  projection = DEFAULT_PROJECTION
): VectorLayer<VectorSource> => {
  const features = new GeoJSON({ featureProjection: projection }).readFeatures(
    circleFeatureCollection
  )
  return new VectorLayer({
    source: new VectorSource({
      features,
    }),
    style: new Style({
      stroke: new Stroke({
        color: "rgba(255, 255, 0, 1)",
        width: 2,
      }),
      fill: new Fill({
        color: "rgba(255, 255, 0, 0.3)",
      }),
    }),
  })
}
 
// reset map after projection change
export const resetMapToNewProjection = (map: Map, newProjection: string) => {
  const updatedView = initView(newProjection)
 
  map.setLayers(getMapLayers(newProjection))
  map.setView(updatedView)
}
defaults.ts
export const getMapLayers = (projection = DEFAULT_PROJECTION) => {
  return [
    new TileLayer({
      source: new OSM(),
    }),
    initVectorLayer(projection),
  ]
}
 
// init Vector Layer
export const initVectorLayer = (
  projection = DEFAULT_PROJECTION
): VectorLayer<VectorSource> => {
  const features = new GeoJSON({ featureProjection: projection }).readFeatures(
    circleFeatureCollection
  )
  return new VectorLayer({
    source: new VectorSource({
      features,
    }),
    style: new Style({
      stroke: new Stroke({
        color: "rgba(255, 255, 0, 1)",
        width: 2,
      }),
      fill: new Fill({
        color: "rgba(255, 255, 0, 0.3)",
      }),
    }),
  })
}
 
// reset map after projection change
export const resetMapToNewProjection = (map: Map, newProjection: string) => {
  const updatedView = initView(newProjection)
 
  map.setLayers(getMapLayers(newProjection))
  map.setView(updatedView)
}
  1. To get vector data from mock object we call new GeoJSON(…).readFeatures() method
  2. We add vector data in VectorSource object of VectorLayer
  3. We define global styles of vector layer: stroke for border and fill to inside color of feature.

Now if you look on the map, you should see new feature on the screen:

Vector Select and Vector Feature properties

Let’s do something more interesting than just showing feature on map.
Remember, that vector data can have properties. Let’s show/hide feature properties on screen when click on/outside feature on map.

First of all let’s add feature properties in our mock.

vectorData.ts
export const circleFeatureCollection = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",  
      properties: {    <-- new properties here
        id: SELECT_FEATURE_ID,
        name: "random feature",
        property1: "important info",
      },
      geometry: {
        type: "Polygon",
        coordinates: [...]
      }
    }
  ]
}
vectorData.ts
export const circleFeatureCollection = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",  
      properties: {    <-- new properties here
        id: SELECT_FEATURE_ID,
        name: "random feature",
        property1: "important info",
      },
      geometry: {
        type: "Polygon",
        coordinates: [...]
      }
    }
  ]
}

Let’s also add new constants, it will be useful to target layer on our map and feature to select on layer:

consts.ts
// layers config
export const LAYER_ID = "layer-id"
export const VECTOR_LAYER = "vector-circle-features"
export const SELECT_FEATURE_ID = "feat-1"
consts.ts
// layers config
export const LAYER_ID = "layer-id"
export const VECTOR_LAYER = "vector-circle-features"
export const SELECT_FEATURE_ID = "feat-1"

Now in initVectorLayer() method add this line after creating vector layer:

defaults.ts
const vectorLayer = new VectorLayer({...})
vectorLayer.set(LAYER_ID, VECTOR_LAYER)   <-- layer identificator
return vectorLayer
defaults.ts
const vectorLayer = new VectorLayer({...})
vectorLayer.set(LAYER_ID, VECTOR_LAYER)   <-- layer identificator
return vectorLayer

We will select and show feature properties on 2 user actions:

  1. User click on feature on map
  2. User click on UI button on screen

Let’s create a new Vue component VectorEditor.vue that will contain all logic related to select.

VectorEditor.vue
<template>
  <div id="vector-editor">
    <div class="button icon" @click="toggleFeature">
      <SparklesIcon />
    </div>
    <!-- feature info  -->
    <div v-if="selectedFeature" class="feature-info">
      <p class="text">feature name : {{ getFeatureName }}</p>
      <p class="text">property1 : {{ getFeatureProperty }}</p>
    </div>
  </div>
</template>
 
<script setup lang="ts">
import { SparklesIcon } from "@heroicons/vue/20/solid"
import { Collection, Feature, Map } from "ol"
import { computed, onMounted, ref } from "vue"
import { Select } from "ol/interaction"
import { findLayerById, getFeatureById } from "../../application/ol"
import {
  LAYER_ID,
  SELECT_FEATURE_ID,
  VECTOR_LAYER,
} from "../../application/consts"
import VectorLayer from "ol/layer/Vector"
import VectorSource from "ol/source/Vector"
// props
const props = defineProps<{
  map: Map
}>()
// reactive data
let features = ref<Collection<Feature>>(new Collection(undefined))
let select = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === VECTOR_LAYER
    },
    features: features.value as Collection<Feature>,
  })
)
// data
let featureLayer = findLayerById(
  props.map,
  VECTOR_LAYER
) as VectorLayer<VectorSource> | null
// computed
const selectedFeature = computed(() => {
  return features.value.getArray()[0] ?? null
})
const getFeatureName = computed(() => {
  return selectedFeature.value?.getProperties().name
})
const getFeatureProperty = computed(() => {
  return selectedFeature.value?.getProperties().property1
})
// hooks
onMounted(() => {
  props.map.addInteraction(select.value as Select)
})
// functions
const toggleFeature = () => {
  if (features.value.getLength() > 0) {
    features.value.clear()   // <-- remove feature from Select
    return
  }
  const feature = getFeatureById(featureLayer, SELECT_FEATURE_ID)
  if (!feature) return
  features.value.push(feature) // <-- add feature to select
}
</script>
VectorEditor.vue
<template>
  <div id="vector-editor">
    <div class="button icon" @click="toggleFeature">
      <SparklesIcon />
    </div>
    <!-- feature info  -->
    <div v-if="selectedFeature" class="feature-info">
      <p class="text">feature name : {{ getFeatureName }}</p>
      <p class="text">property1 : {{ getFeatureProperty }}</p>
    </div>
  </div>
</template>
 
<script setup lang="ts">
import { SparklesIcon } from "@heroicons/vue/20/solid"
import { Collection, Feature, Map } from "ol"
import { computed, onMounted, ref } from "vue"
import { Select } from "ol/interaction"
import { findLayerById, getFeatureById } from "../../application/ol"
import {
  LAYER_ID,
  SELECT_FEATURE_ID,
  VECTOR_LAYER,
} from "../../application/consts"
import VectorLayer from "ol/layer/Vector"
import VectorSource from "ol/source/Vector"
// props
const props = defineProps<{
  map: Map
}>()
// reactive data
let features = ref<Collection<Feature>>(new Collection(undefined))
let select = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === VECTOR_LAYER
    },
    features: features.value as Collection<Feature>,
  })
)
// data
let featureLayer = findLayerById(
  props.map,
  VECTOR_LAYER
) as VectorLayer<VectorSource> | null
// computed
const selectedFeature = computed(() => {
  return features.value.getArray()[0] ?? null
})
const getFeatureName = computed(() => {
  return selectedFeature.value?.getProperties().name
})
const getFeatureProperty = computed(() => {
  return selectedFeature.value?.getProperties().property1
})
// hooks
onMounted(() => {
  props.map.addInteraction(select.value as Select)
})
// functions
const toggleFeature = () => {
  if (features.value.getLength() > 0) {
    features.value.clear()   // <-- remove feature from Select
    return
  }
  const feature = getFeatureById(featureLayer, SELECT_FEATURE_ID)
  if (!feature) return
  features.value.push(feature) // <-- add feature to select
}
</script>

Wow there is a lot of stuff! Let’s decompose:

  1. In template we create a clickable button and block with text info about selected feature. When button is clicked, toggleFeature() function is called and info about feature show/hide next to the button.
  2. We init our select as Vue reactive object. It is very important that features of our reactive select should also be reactive (features variable). Otherwise Vue will not detect a moment when feature added or removed from Select programmatically.
    Feature can be selected either by user click on feature (handled by open layers by default). Or can be selected programmatically (when user click the UI button). In the last case we should manually add a feature in our select. When feature is added in select it is considered as selected.
  3. In onMounted hook we add select interaction to our map. You can create Select, but as long as it’s not related to current map, the interaction will not have any effect on map.
  4. We add implementation for toggleFeature() function. Our select will contain only one feature.
    When user click button we check if feature currently selected. If it is the case, we remove feature from select. Otherwise we find our feature on vector layer thanks to “getFeatureById” function (we will see implementation later) and if feature exist on layer, we add this feature in Select.
  5. Finally to show feature properties, we use some derived state (computed properties):
  • selectedFeature to check if we have currently selected feature
  • getFeatureName to get name of feature from it’s properties
  • getFeatureProperty to get another property of feature just for sake of example

The last but not least, we should know which vector layer to interact with, and how to get feature from this vector layer. We create new file with list of helpers methods:

ol.ts
import { Map } from "ol"
import { LAYER_ID } from "./consts"
import VectorLayer from "ol/layer/Vector"
import VectorSource from "ol/source/Vector"
 
export const findLayerById = (map: Map, layerId: string) => {
  const vectorLayer = map
    .getLayers()
    .getArray()
    .find((l) => l.get(LAYER_ID) === layerId)
  return vectorLayer ?? null
}
 
export const getVectorLayerFeatures = (
  vectorLayer: VectorLayer<VectorSource>
) => {
  return vectorLayer.getSource()?.getFeatures() ?? []
}
 
export const getFeatureById = (
  vectorLayer: VectorLayer<VectorSource>,
  featureId: string
) => {
  if (!vectorLayer || !featureId) return
  const vectorLayerFeatures = getVectorLayerFeatures(vectorLayer)
  const feature = vectorLayerFeatures.find(
    (feature) =>
      feature.getId() === featureId || feature.getProperties().id === featureId
  )
  return feature
}
ol.ts
import { Map } from "ol"
import { LAYER_ID } from "./consts"
import VectorLayer from "ol/layer/Vector"
import VectorSource from "ol/source/Vector"
 
export const findLayerById = (map: Map, layerId: string) => {
  const vectorLayer = map
    .getLayers()
    .getArray()
    .find((l) => l.get(LAYER_ID) === layerId)
  return vectorLayer ?? null
}
 
export const getVectorLayerFeatures = (
  vectorLayer: VectorLayer<VectorSource>
) => {
  return vectorLayer.getSource()?.getFeatures() ?? []
}
 
export const getFeatureById = (
  vectorLayer: VectorLayer<VectorSource>,
  featureId: string
) => {
  if (!vectorLayer || !featureId) return
  const vectorLayerFeatures = getVectorLayerFeatures(vectorLayer)
  const feature = vectorLayerFeatures.find(
    (feature) =>
      feature.getId() === featureId || feature.getProperties().id === featureId
  )
  return feature
}
  1. findLayerById allows to find a layer by it’s identificator (identificator VECTOR_LAYER for our case)
  2. getFeatureById allows to find a feature on layer by it’s identificator : our feature has id SELECT_FEATURE_ID.

Finally add a VectorEditor component in our App.vue:

App.vue
<template>
  <div id="map"></div>
  <MapBar :map="(map as Map | null)" ref="mapBarRef" />
  <VectorEditor v-if="map" :map="(map as Map)" />
</template>
App.vue
<template>
  <div id="map"></div>
  <MapBar :map="(map as Map | null)" ref="mapBarRef" />
  <VectorEditor v-if="map" :map="(map as Map)" />
</template>

Let’s see result:

If you stuck, you can check branch for this tutorial

P.S:
There is a one pitfall with Select feature on map that I think is important to remind: the feature will not be selected by click inside of feature, if the feature don’t have a fill style. You should at least define fill style as transparent to be able to toggle feature on map by clicking inside of feature. This knowledge can avoid you a lot of headache, trust me 😁

Advanced use case : 2 independent selects with overlapping features.

Let’s consided a next use case. You have 2 independent vector layers on map. Each layer have it’s proper vector features. And both layer features are selectable. Now what if is geometry of features from second layer can be inside geometry of features of first layer. You would probably want to select only feature of second layer by clicking inside of it, but it will selected features of both layers.

Let’s add a new mock for 2nd vector layer and see how can we fix default open layers behaviour.
You can get 2 mocks from git source branch. I named it as vectorDataLayer1 and vectorDataLayer2, where geometry of vectorDataLayer2 feature is inside of vectorDataLayer1 feature.
Also the feature from vectorDataLayer2 will have identificator SELECT_INNER_FEATURE_ID.

Now add a 2nd vector layer on map:

defaults.ts
const getMapLayers = (projection = DEFAULT_PROJECTION) => {
  return [
    new TileLayer({
      source: new OSM(),
    }),
    initVectorLayer(projection),
    initSecondVectorLayer(projection), // <-- second vector layer
  ]
}
 
const initSecondVectorLayer = (
  projection = DEFAULT_PROJECTION
): VectorLayer<VectorSource> => {
  const features = new GeoJSON({ featureProjection: projection }).readFeatures(
    vectorDataLayer2
  )
  const vectorLayer = new VectorLayer({
    source: new VectorSource({
      features,
    }),
    style: new Style({
      stroke: new Stroke({
        color: "rgba(133, 239, 133, 1)",  // <-- green color insted of yellow to differ features
        width: 2,
      }),
      fill: new Fill({
        color: "rgba(133, 239, 133, 0.3)",
      }),
    }),
  })
  vectorLayer.set(LAYER_ID, SECOND_VECTOR_LAYER) // <-- identificator of 2nd vector layer
  return vectorLayer
}
defaults.ts
const getMapLayers = (projection = DEFAULT_PROJECTION) => {
  return [
    new TileLayer({
      source: new OSM(),
    }),
    initVectorLayer(projection),
    initSecondVectorLayer(projection), // <-- second vector layer
  ]
}
 
const initSecondVectorLayer = (
  projection = DEFAULT_PROJECTION
): VectorLayer<VectorSource> => {
  const features = new GeoJSON({ featureProjection: projection }).readFeatures(
    vectorDataLayer2
  )
  const vectorLayer = new VectorLayer({
    source: new VectorSource({
      features,
    }),
    style: new Style({
      stroke: new Stroke({
        color: "rgba(133, 239, 133, 1)",  // <-- green color insted of yellow to differ features
        width: 2,
      }),
      fill: new Fill({
        color: "rgba(133, 239, 133, 0.3)",
      }),
    }),
  })
  vectorLayer.set(LAYER_ID, SECOND_VECTOR_LAYER) // <-- identificator of 2nd vector layer
  return vectorLayer
}

And add a new Select interaction for this vector layer:

VectorEditor.vue
<script>
...
let select2 = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === SECOND_VECTOR_LAYER
    },
  })
)
...
 
onMounted(() => {
  props.map.addInteraction(select.value as Select)
  props.map.addInteraction(select2.value as Select)
})
</script>
VectorEditor.vue
<script>
...
let select2 = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === SECOND_VECTOR_LAYER
    },
  })
)
...
 
onMounted(() => {
  props.map.addInteraction(select.value as Select)
  props.map.addInteraction(select2.value as Select)
})
</script>

Currently we deal with default open layers behaviour : when click on green feature inside yellow feature, both features will be selected on map. Let’s fix it by adding “condition” function to our first select:

VectorEditor.vue
<script>
...
let select = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === VECTOR_LAYER
    },
    features: features.value as Collection<Feature>,
    condition: (e) => {
      if (e.type === "click") {
        const pixel = e.pixel
        let shouldHandleClick = true
        props.map.forEachFeatureAtPixel(pixel, (feature, layer) => {
          if (layer.get(LAYER_ID) === SECOND_VECTOR_LAYER) {
            shouldHandleClick = false
            return
          }
        })
        return shouldHandleClick
      }
      return false
    },
  })
)
let select2 = ...
...
</script>
VectorEditor.vue
<script>
...
let select = ref<Select>(
  new Select({
    multi: false,
    layers: (layer) => {
      return layer.get(LAYER_ID) === VECTOR_LAYER
    },
    features: features.value as Collection<Feature>,
    condition: (e) => {
      if (e.type === "click") {
        const pixel = e.pixel
        let shouldHandleClick = true
        props.map.forEachFeatureAtPixel(pixel, (feature, layer) => {
          if (layer.get(LAYER_ID) === SECOND_VECTOR_LAYER) {
            shouldHandleClick = false
            return
          }
        })
        return shouldHandleClick
      }
      return false
    },
  })
)
let select2 = ...
...
</script>
  1. Condition function should return a boolean value which indicate when feature can be selected/removed from select. It takes MapBrowserEvent as input parameter.
    MapBrowserEvent contains multiples properties, the 2 most interested in our case:
  • type : type of map event. We want to handle only type “click” on map.
  • pixel : pixel of user click on map. We will using pixel information to find a list of all features existing on clicked pixel.
  1. We call map function forEachFeatureAtPixel() which takes 2 input parameters for every element in pixel: feature at pixel and layer of this feature.
    We check if layer of one of feature in clicked pixel is a second layer. If it is the case, it means that pixel where user clicked contain features from both layers : first one and second. Then we ignore click for select by returning false from this function, otherwise function return true and selection is handled the same way as by default behaviour.

  1. #GIS
  2. #OpenLayers