In this comprehensive tutorial series, you’ll learn how to integrate OpenLayers maps into your Vue.js web application, enabling you to create interactive maps with vector tiles, apply dynamic styling, and enhance the user experience. The tutorial is divided into multiple steps, each building upon the previous one.
This blog post is the 6th part in OpenLayers-VueJS integration series.
To get full source code for this tutorial, click here.
To check the live demo for this tutorial, click here.
You project directory looks like below structure where all our vue components are defined under src directory
Step 1: Generating Vector Tiles
In this initial step of the tutorial, the focus is on generating vector tiles for use in a web mapping application. Vector tiles are a compact and efficient way to serve map data, making maps interactive and responsive. The Python code provided accomplishes the following key tasks:
- Database Configuration: The code sets up a connection to a PostgreSQL database with PostGIS extension, which is a geospatial database extension for PostgreSQL. It includes essential database configuration parameters such as the database name, user, password, host, and port.
- Coordinate Conversion Function: The
deg2num
function is defined to calculate the x and y tile coordinates for a given latitude, longitude, and zoom level. It uses the Web Mercator projection to perform this calculation. The function takes latitude, longitude, and zoom level as input and returns the corresponding tile coordinates. - Database Session Setup: The code initializes a database session using SQLAlchemy. This session will be used to execute database queries.
- Fetching Geographic Extent: The code executes a SQL query to determine the geographic extent of the data in the database. The query calculates the minimum and maximum x and y coordinates (min_x, min_y, max_x, max_y) for the dataset, allowing for efficient tile generation based on this extent.
- Zoom Level Definition: A list of zoom levels is defined, typically ranging from 0 to 17, specifying the range of zoom levels for which vector tiles will be generated.
- SQL Query Template: A template for the SQL query to fetch geometries for each zoom level is defined. It includes:
- A call to
ST_AsMVT
, which prepares the data as Mapbox Vector Tiles (MVT). - A transformation of the geometries to the Web Mercator projection using
ST_Transform
. - Calculation of tile geometries using
ST_TileEnvelope
. - A filtering condition to select geometries that intersect with the tile envelope.
- A call to
- Generating Vector Tiles: The code iterates over different layers (e.g., ‘buildings’, ‘lines’, ‘pois’) and zoom levels to generate vector tiles for specific extents. This is achieved by calculating the tile coordinates for each extent, executing the SQL query using the defined template, and saving the resulting MVTs to local directories. The tiles are compressed using gzip to reduce their file size.
This step sets the foundation for creating vector tiles that can be integrated into your web mapping application using OpenLayers and Vue.js, which will be covered in subsequent steps of the tutorial.
Note: We have hosted the vector tiles in https://github.com/iamgeoknight/vector_tiles_sample_data. While running vue-layers application locally, you have to download the vector tiles from given link and host them in your local server and change the url accordingly in layers.ts configuration file(Described in Step) for vector tile layer
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
import os from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import geopandas as gpd from sqlalchemy.sql import text import math import gzip # Set up your PostGIS connection db_config = { 'dbname': 'postgres', 'user': 'postgres', 'password': 'admin', 'host': 'localhost', 'port': '5432' } # Connect to the database connection_string = f"postgresql://{db_config['user']}:{db_config['password']}@{db_config['host']}:{db_config['port']}/{db_config['dbname']}" engine = create_engine(connection_string) # The purpose of this function is to calculate the x and y tile coordinates for a given latitude, longitude, and zoom level, using the Web Mercator projection. def deg2num(lat_deg, lon_deg, zoom): lat_rad = math.radians(lat_deg) n = 2.0 ** zoom xtile = int((lon_deg + 180.0) / 360.0 * n) ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) return (xtile, ytile) Session = sessionmaker(bind=engine) session = Session() # get the extent extent_query = 'SELECT ST_XMin(ST_Extent(geom)) as min_x, ST_YMin(ST_Extent(geom)) as min_y, ST_XMax(ST_Extent(geom)) as max_x, ST_YMax(ST_Extent(geom)) as max_y from blog.tricity' min_x, min_y, max_x, max_y = session.execute(text(extent_query)).all()[0] # Define the zoom levels zoom_levels = list(range(0, 19)) # Example zoom levels from 0 to 17 # Define the SQL query template to fetch geometries for each zoom level sql_template = ''' SELECT ST_AsMVT(q, '{layer}', 4096, 'geom') as mvt FROM ( SELECT ST_AsMVTGeom( ST_Transform(geom, 3857), ST_TileEnvelope({z}, {x}, {y}), 4096, 0, true ) AS geom, * FROM blog.{layer} WHERE geom && ST_Transform(ST_TileEnvelope({z}, {x}, {y}), 4326) ) q; ''' # Generate vector tiles for the specified extent and zoom levels and locally store them for layer in ['buildings', 'lines', 'pois']: for z in zoom_levels: min_tile_x, max_tile_y = deg2num(min_y, min_x, z) max_tile_x, min_tile_y = deg2num(max_y, max_x, z) for x in range(min_tile_x, max_tile_x + 1): for y in range(min_tile_y, max_tile_y + 1): print(z, x, y) sql = sql_template.format(layer=layer, z=z, x=x, y=y) result = session.execute(text(sql)).scalar() if result: tile_path = f"static/tiles/{layer}/{z}/{x}" os.makedirs(tile_path, exist_ok=True) # compressing tiles using gzip to reduce the size with gzip.open(f"{tile_path}/{y}.mvt", "wb") as tile_file: tile_file.write(result) |
Step 2: Defining Layers Configuration
In this step, you’ll create a layers.ts
configuration file, which defines various map layers and their associated styles. The configuration includes layer types, styles, and other properties. Here’s a brief overview of the src/static/layers/layers.ts
file you provided:
- Create
layers.ts
: In your project, create a file namedlayers.ts
to centralize the configuration of your map layers. - Layer Definitions:
- Each layer is defined as an object within an array in the
layers
array. - For each layer, provide a unique
"name"
to identify it.
- Each layer is defined as an object within an array in the
- Layer Type:
- Specify the
"layerType"
for each layer. This can be either"geojson"
for GeoJSON layers or"vectortile"
for vector tile layers.
- Specify the
- Style Rules:
- Define the visual style for each layer within the
"style"
object. - Within the
"style"
object, you have an array of"rules"
that apply different styles based on conditions. - For each rule, you can define:
"name"
: A name for the rule."filter"
: A filter condition to selectively style features."symbolizers"
: Specify how the features should be styled, including color, outline, width, and other visual properties.
- Define the visual style for each layer within the
- Layer Visibility:
- Set
"visible"
totrue
orfalse
to control whether the layer is initially visible on the map.
- Set
- Zoom Levels:
- Use
"minZoom"
and"maxZoom"
to set the minimum and maximum zoom levels at which the layer should be visible. This helps in controlling when a layer should appear on the map, depending on zoom level.
- Use
- Vector Tile URL:
- For vector tile layers, specify the
"url"
where the vector tiles can be fetched. This URL can point to an online tile server or a local source.
- For vector tile layers, specify the
By following this step, you’ve defined a layers.ts
configuration file that contains information about your map layers, their types, styles, visibility, zoom levels, and sources. This file serves as a crucial component for configuring the appearance and behavior of map layers in your Vue.js mapping application.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
const layers: Array<object> = [ ......................... { "name": 'Buildings', "visible": true, "minZoom": 15, "maxZoom": 20, "style": { "rules": [ { "name": "Kharar", "filter": [ "==", "name_3", "Kharar" ], "symbolizers": [{ "kind": "Fill", "color": "#eb7dc7ff", "outlineColor": "#525252ff", "outlineWidth": 1 }] }, { "name": "Rajpura", "filter": [ "==", "name_3", "Rajpura" ], "symbolizers": [{ "kind": "Fill", "color": "#ff3700ff", "outlineColor": "#525252ff", "outlineWidth": 1 }] }, { "name": "Chandigarh", "filter": [ "==", "name_3", "Chandigarh" ], "symbolizers": [{ "kind": "Fill", "color": "#3578dfff", "outlineColor": "#525252ff", "outlineWidth": 1 }] }, { "name": "Kalka", "filter": [ "==", "name_3", "Kalka" ], "symbolizers": [{ "kind": "Fill", "color": "#3b9b37ff", "outlineColor": "#525252ff", "outlineWidth": 1 }] } ] }, "layerType": 'vectortile', "url": "https://iamgeoknight.github.io/vector_tiles_sample_data/tiles/buildings/{z}/{x}/{y}.mvt" } ]; export default layers; |
Step 3: Creating the Layers Panel Component
In this step, you’ll create a Vue.js component called src/components/LayersPanel.vue
to manage your map layers. This component reads the layer configuration from the layers.ts
file, adds vector tile and GeoJSON layers to the OpenLayers map, and provides user interaction to toggle layer visibility and styles. Additionally, it uses the GeoStyler-OpenLayers Parser to parse the layer styles from your configuration file.
Here’s the LayersPanel.vue
component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
<template> <div> <q-tabs v-model="tab" inline-label class="bg-green text-white"> <q-tab name="layers" icon="layers" label="Layers" /> </q-tabs> <span v-if="map.hasOwnProperty('disposed')" > <q-list separator bordered clickable :key="index" v-for="(layer, index) in map.getLayers().getArray().slice().reverse()"> <q-item style="height: 10px;" v-if="layer.get('name') !== 'highlight_layer'"> <q-item-section avatar> <span class="material-symbols-outlined cursor-pointer" style="color: #54b582; font-size: 40px; font-variation-settings: 'FILL' 1;" @click = "toggleLayer(layer, $event)" v-if="!layer.get('isBaseMap')"> check_circle </span> </q-item-section> <q-item-section class = "label_align"> {{layer.get('name')}} </q-item-section> <q-item-section avatar> <q-icon name="keyboard_arrow_down" class="expansion_align cursor-pointer" size="25px" @click="toggleSwitch(layer, $event)" v-if="!layer.get('isBaseMap')"/> </q-item-section> </q-item> <q-separator/> <InLegend :layer = "layer"/> </q-list> </span> <style-dialog/> </div> </template> <script lang="ts"> import { defineComponent } from 'vue' import OpenLayersParser from "geostyler-openlayers-parser"; import layers from '../static/layers/layers'; import InLegend from "./InLegend.vue"; import { VectorTile as VectorTileLayer} from 'ol/layer'; import { VectorTile as VectorTileSource } from 'ol/source'; import { VectorImage } from 'ol/layer'; import VectorSource from 'ol/source/Vector'; import { useMapStore } from '@/stores/mapStore'; import { MVT } from 'ol/format' import { GeoJSON } from 'ol/format'; import StyleDialog from './styles/StyleDialog.vue'; import type { Style } from "geostyler-style"; import pako from 'pako'; interface CustomOptions1 extends VectorTileLayer { geostyle: Style; geostyleTwin: Style; name: string; isExpanded: boolean; } interface CustomOptions2 extends VectorImage<VectorSource> { geostyle: Style; geostyleTwin: Style; name: string; isExpanded: boolean; } interface CustomLayer { name: string; minZoom: number; maxZoom: number; style: Style; layerType: string, url: string; visible: boolean; layer: VectorImage<VectorSource> } const parser = new OpenLayersParser(); export default defineComponent({ name: 'LayersPanel', data() { return { splitterModel: 1, tab: 'mails', } }, components: { InLegend, 'style-dialog': StyleDialog }, watch: { 'map': { handler() { this.addLayers(); }, deep: false } }, computed: { map() { const store = useMapStore(); return store.getMap } }, updated() { const store = useMapStore(); let legendRuleKeys = {}; // @ts-ignore let allLayers: CustomOptions1[] = this.map.getAllLayers(); allLayers.forEach((layer) => { if (!(layer.get('name') === 'OpenStreetMap' || layer.get('name') === 'highlight_layer')) { layer.geostyle.rules.forEach((i, j) => { let key = `${layer.get('name')}-${j}`; Object.assign(legendRuleKeys, { [key]: 0 }) }); } }); store.setLegendRuleKeys(legendRuleKeys); }, methods: { tileLoadFunction(tile: any, url: any) { tile.setLoader(function (extent: any, resolution: any, projection: any) { fetch(url, { headers: { "Content-Type": "application/vnd.mapbox-vector-tile", 'Content-Encoding': "gzip" } }).then(function (response) { response.arrayBuffer().then(function (data) { const format = tile.getFormat() if (response.status == 200) { let raw_features = pako.ungzip(data); const features = format.readFeatures(raw_features, { extent: extent, featureProjection: projection }); tile.setFeatures(features); } else { const features = format.readFeatures([], { extent: extent, featureProjection: projection }); tile.setFeatures(features); } }); }) }); }, addLayers() { (layers as CustomLayer[]).forEach((lyr) => { if (lyr.layerType === 'vectortile') { let current_layer = new VectorTileLayer({ source: new VectorTileSource({ tilePixelRatio: 1, format: new MVT(), tileLoadFunction: this.tileLoadFunction, url: lyr.url, tileSize: 256 } as Object), minZoom: lyr.minZoom, maxZoom: lyr.maxZoom, visible: lyr.visible, renderMode: 'vector' }) as CustomOptions1; current_layer.setProperties({ name: lyr.name, isExpanded: false }); current_layer.geostyle = lyr.style; current_layer.geostyleTwin = JSON.parse(JSON.stringify(lyr.style)); if (lyr.style) { parser .writeStyle(lyr.style) .then(({ output: olStyle }) => { current_layer.setStyle(olStyle); }) .catch(error => console.log(error)); } this.map.addLayer(current_layer); } else if (lyr.layerType === 'geojson') { let current_layer = new VectorImage({ source: new VectorSource({ features: new GeoJSON({ 'featureProjection': 'EPSG:3857' }).readFeatures(lyr.layer) }) }) as CustomOptions2; current_layer.setProperties({ name: lyr.name, isExpanded: false }); current_layer.geostyle = lyr.style; current_layer.geostyleTwin = JSON.parse(JSON.stringify(lyr.style)); if (lyr.style) { parser .writeStyle(lyr.style) .then(({ output: olStyle }) => { current_layer.setStyle(olStyle); }) .catch(error => console.log(error)); } this.map.addLayer(current_layer); } }); }, toggleLayer(layer: any, e: Event) { let flag = layer.getVisible(); const target = e.target as HTMLElement; layer.setVisible(!flag); if (flag == false) { target.style.fontVariationSettings = '"FILL" 1'; } else { target.style.fontVariationSettings = '"FILL" 0'; } }, toggleSwitch(layer: any, e: Event) { let flag = layer.get('isExpanded'); const target = e.target as HTMLElement; layer.set('isExpanded', !flag); if (flag == false ) { target.innerHTML = "keyboard_arrow_up"; } else { target.innerHTML = "keyboard_arrow_down"; } } } }) </script> <style scoped> .label_align { text-align: left; } .expansion_align { text-align: right; } </style> |
This component integrates with your OpenLayers map, reads the layer configuration from the layers.ts
file, and creates the necessary layers, styles, and user interaction for controlling the visibility of layers. It uses the GeoStyler-OpenLayers Parser to parse the layer styles from your configuration file and applies them to the layers. The component also handles events to toggle layer visibility and manage layer expansion.
Let’s discuss the methods from the LayersPanel
component in your Vue.js application:
tileLoadFunction(tile: any, url: any)
: This method is used to load vector tile features from a given URL. It is responsible for fetching vector tile data, decoding it (if it’s compressed with gzip), and setting the features for the tile. This function is essential for vector tile layers.addLayers()
: This method is used to add layers to the map based on the configuration provided in thelayers.ts
file. It iterates through each layer configuration in thelayers
array and, depending on thelayerType
, creates a Vector Tile Layer or a GeoJSON Layer. It sets properties likename
,minZoom
,maxZoom
, andstyle
for each layer. If a layer has a style defined, it uses the GeoStyler-OpenLayers Parser to convert the style into OpenLayers format and applies it to the layer.toggleLayer(layer: any, e: Event)
: This method handles the user interaction for toggling the visibility of a layer. When a user clicks on the “check_circle” icon next to a layer, this function is called. It toggles the visibility of the layer and updates the appearance of the icon accordingly.toggleSwitch(layer: any, e: Event)
: This method is responsible for toggling layer expansion. When a user clicks on the “keyboard_arrow_down” or “keyboard_arrow_up” icon next to a layer, it expands or collapses the layer information. This is especially useful for layers with additional details.
These methods are crucial for managing the layers on your map, providing interactivity for users to control layer visibility, and ensuring that the styles are correctly applied to the layers based on your configuration.
Step 4: Defining StyleDialogue component
In Step 4, you’ll define the src/compoenets/styles/StyleDialog
.vue component, which is responsible for dynamically rendering different style editing components based on the selected layer’s style. This component allows users to modify the style of a layer interactively. Here’s the implementation:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
<template> <div style="position: absolute; top:0px; left: 300px; width: 100%;" v-if="layer || ruleIndex" :key="styleKey"> <FillVectorStyle :layer="layer" :ruleIndex="ruleIndex" v-if=" // @ts-ignore layer.geostyle.rules[ruleIndex].symbolizers[0].kind == 'Fill'"/> <LineVectorStyle :layer="layer" :ruleIndex="ruleIndex" v-if=" // @ts-ignore layer.geostyle.rules[ruleIndex].symbolizers[0].kind == 'Line'"/> <IconVectorStyle :layer="layer" :ruleIndex="ruleIndex" v-if=" // @ts-ignore layer.geostyle.rules[ruleIndex].symbolizers[0].kind == 'Mark' || layer.geostyle.rules[ruleIndex].symbolizers[0].kind == 'Icon' "/> <q-icon name="highlight_off" class="expansion_align cursor-pointer" size="md" style="position: fixed; top: 0px; right: -100%; color: #4caf50;" @click="closeStyleDialog"/> </div> </template> <script lang="ts"> import { defineComponent } from 'vue' import FillVectorStyle from './changeStyle/FillVectorStyle.vue'; import LineVectorStyle from './changeStyle/LineVectorStyle.vue'; import IconVectorStyle from './changeStyle/IconVectorStyle.vue'; import { useMapStore } from '@/stores/mapStore'; export default defineComponent({ name: "StyleDialog", components: { FillVectorStyle, LineVectorStyle, IconVectorStyle }, computed: { layer() { const store = useMapStore(); return store.getStyleLayer }, ruleIndex() { const store = useMapStore(); return store.getRuleIndex }, styleKey() { const store = useMapStore(); return store.getStyleKey } }, methods: { closeStyleDialog() { const store = useMapStore(); let layer = store.getStyleLayer; let ruleIndex = store.getRuleIndex; // refresh legend on close // @ts-ignore store.setLegendRuleKey(`${layer.get('name')}-${ruleIndex}`); // Hide Style dialog on close store.setRuleIndex(null); store.setStyleLayer(null); } } }); </script> <style scoped></style> |
In this step, the StyleDialog
component dynamically renders different style editing components (e.g., FillVectorStyle
, LineVectorStyle
, or IconVectorStyle
) based on the type of symbolizer used in the selected layer’s style.
The component also provides a way to close the style editing dialog, which triggers a refresh of the legend and hides the dialog itself.
This step completes the implementation of the StyleDialog
component, allowing users to edit styles for different symbolizer types within the selected layer.
src/components/styles/changeStyle/FillVectorStyle
component allows users to dynamically change the fill style of a vector layer. This component provides color and width customization options for the fill symbolizer(Polygon vector tile layer).
src/components/styles/changeStyle/LineVectorStyle
component allows users to dynamically change the line style of a vector layer. This component provides color and width customization options for the line symbolizer(Line vector tile layer).
src/components/styles/changeStyle/IconVectorStyle
component allows users to dynamically change the point style of a vector layer. This component provides color, radius and stroke customization options for the Icon symbolizer(Point vector tile layer).
In this tutorial, we created a web mapping application using Vue.js and OpenLayers. We set up the project, defined layer configurations, implemented a LayersPanel to add vector tile layers to the map, created a StyleDialog for dynamic style adjustments, and built FillVectorStyle, IconVectorStyle and LineVectorStyle component for customizing feature styles. This application serves as a solid foundation for web mapping projects and can be extended to meet specific mapping needs.
I hope this tutorial will create a good foundation for you. If you want tutorials on another GIS topic or you have any queries, please send an email at contact@spatial-dev.guru.