Now that we have a working map with a legend and some static data, let's try to load the same data from the server. We will use a popular open format for geospatial data: geojson. Those familiar with the json format will quickly pick up on this format. To try it out, edit or create examples in geojson, I strongly recommend playing around with Geojson.io.
- For this example, let's add a geojson file in our resources directory:
mkdir resources/geojson
touch resources/geojson/monuments.geojson
Now the content of this file, please note that, compared to our hardcoded features in Javascript, I removed the yearly_visitors attribute and added an image attribute with a url to a Wikipedia picture (it will be useful in the next post). I added a new monument as well, so we see that we are loading the correct features:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"name": "Eiffel Tower",
"image": "https://en.wikipedia.org/wiki/Eiffel_Tower#/media/File:Tour_Eiffel_Wikimedia_Commons.jpg"
},
"geometry": {
"type": "Point",
"coordinates": [2.2944960089681175, 48.85824068679814]
}
},
{
"type": "Feature",
"properties": {
"name": "Statue of Liberty",
"image": "https://en.wikipedia.org/wiki/Statue_of_Liberty#/media/File:Lady_Liberty_under_a_blue_sky_(cropped).jpg"
},
"geometry": {
"type": "Point",
"coordinates": [-74.04455265662958, 40.68928126997774]
}
},
{
"type": "Feature",
"properties": {
"name": "Rome Colosseum",
"image": "https://en.wikipedia.org/wiki/Colosseum#/media/File:Colosseo_2020.jpg"
},
"geometry": {
"type": "Point",
"coordinates": [12.492283213388305, 41.890266877448695]
}
},
{
"type": "Feature",
"properties": {
"name": "Door of No Return",
"image": "https://en.wikipedia.org/wiki/Door_of_No_Return,_Ouidah#/media/File:The_Door_of_No_Return_in_Ouidah,_November_2007_(3).jpg"
},
"geometry": {
"type": "Point",
"coordinates": [2.0895860296820206, 6.324244153348859]
}
}
]
}
- Let's load and serve the content of this file; we will temporarily work in the routes/web.php file because this code will not be permanent.
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('/', function () {
return view('welcome');
});
Route::middleware([
'auth:sanctum',
config('jetstream.auth_session'),
'verified'
])->group(function () {
Route::get('/dashboard', function () {
return view('dashboard');
// we get the content of the file and pass it to the view in a variable called geojson
$geojson = file_get_contents(resource_path('/geojson/monuments.geojson'));
return view('dashboard', ['geojson' => $geojson]);
})->name('dashboard');
});
- Now, in the resources/view/dashboard.blade.php file, we can pass this variable down to our x-map anonymous component:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<x-map />
<x-map :monuments="$geojson"/>
</div>
</div>
</div>
</x-app-layout>
- We need to accept the monuments property in the resources/views/components/map.blade.php and pass it to the alpine.js component:
<!-- Accept the monuments property -->
@props(['monuments'])
<div x-data="map()">
<div x-ref="map" class="relative h-[600px] rounded-md border border-slate-300 shadow-lg">
<!-- Pass the monuments property down to the alpine.js component, we renamed the init()
-- funtion initComponent() so it's not automatically called by alpine, then we explicitly call
-- the new initComponent() function with the monuments argument using x-init property -->
<div x-data="map()" x-init="initComponent({{ json_encode($monuments) }})">
<div x-ref="map" class="relative h-[600px] overflow-clip rounded-md border border-slate-300 shadow-lg">
<div class="absolute top-2 right-8 z-10 rounded-md bg-white bg-opacity-75">
<div class="ol-unselectable ol-control">
<button x-on:click.prevent="legendOpened = ! legendOpened" title="Open/Close legend"
class="absolute inset-0 flex items-center justify-center">
<!-- Heroicon name: outline/globe -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 pl-0.5" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
(...)
- Finally, in our alpine component (file resources/js/components/map.js, we need to rename the init() function to initComponent(), accept the monuments, and replace our hardcoded features with the ones coming from the monuments variable. Please note that I reverted the styles to be static as our new features don't have a yearly_visitors attribute anymore:
import Map from 'ol/Map.js';
import View from 'ol/View.js';
import TileLayer from 'ol/layer/Tile.js';
import Feature from 'ol/Feature';
import VectorSource from 'ol/source/Vector';
import VectorLayer from 'ol/layer/Vector';
import OSM from 'ol/source/OSM.js';
import Point from 'ol/geom/Point';
import { Style, Fill, Stroke, Circle, Text } from 'ol/style.js';
import { createProjection } from 'ol/proj';
import GeoJSON from 'ol/format/GeoJSON';
document.addEventListener('alpine:init', () => {
Alpine.data('map', function () {
return {
legendOpened: false,
map: {},
// Remove our hardcoded features as we will receive geojson from the backend
features: [
new Feature({
geometry: new Point([2.2944960089681175, 48.85824068679814]),
name: 'Eiffel Tower',
yearly_visitors: 8810000,
}),
new Feature({
geometry: new Point([-74.04455265662958, 40.68928126997774]),
name: 'Statue of Liberty',
yearly_visitors: 4600000,
}),
new Feature({
geometry: new Point([12.492283213388305, 41.890266877448695]),
name: 'Rome Colosseum',
yearly_visitors: 3800000,
}),
],
features: [],
init() {
// Rename the init function, accept the monuments geojson and read it in the component features
// variable with the GeoJSON.readFeatures() function, this way, our component will continue
// working event if the feature source has changed. Please note that the geojson format supports
// more than only point features, our component doesn't account for different types of features
// in the geojson feature collection yet.
initComponent(monuments) {
this.features = new GeoJSON().readFeatures(monuments)
this.map = new Map({
target: this.$refs.map,
layers: [
new TileLayer({
source: new OSM(),
label: 'OpenStreetMap',
}),
new VectorLayer({
source: new VectorSource({
features: this.features,
}),
style: this.styleFunction,
label: 'Monuments',
})
],
view: new View({
projection: 'EPSG:4326',
center: [0, 0],
zoom: 2,
}),
})
},
// Rollback the style to something not depending on the yearly_visitors attribute as it doesn't
// exist anymore in the new geojson dataset
styleFunction(feature) {
styleFunction(feature, resolution)
let radius = Math.round(feature.get('yearly_visitors') / 1000000)
let color = 'rgba(0, 255, 255, 0.5)'
if(radius > 4) {
color = 'rgba(255, 255, 0, 0.5)'
}
if(radius > 5) {
color = 'rgba(255, 0, 0, 0.5)'
}
return new Style({
image: new Circle({
radius: radius,
radius: 4
fill: new Fill({
color: color,
color: 'rgba(0, 255, 255, 1)'
}),
stroke: new Stroke({
color: 'rgba(192, 192, 192, 1)',
width: 2
}),
}),
text: new Text({
font: '12px sans-serif',
textAlign: 'left',
text: feature.get('name'),
offsetY: -15,
offsetX: 5,
backgroundFill: new Fill({
color: 'rgba(255, 255, 255, 0.5)',
}),
backgroundStroke: new Stroke({
color: 'rgba(227, 227, 227, 1)',
}),
padding: [5, 2, 2, 5]
})
})
}
};
});
});
After a "dr npm run build" and page reload in the browser, you should be able to see the new server-side rendered features on the map:
Now, let's show our features in the legend and allow the user to navigate to each one with ease.
- Make the following changes to the map blade component (resources/views/components/map.blade.php):
@props(['monuments'])
<div x-data="map()" x-init="initComponent({{ json_encode($monuments) }})">
<div x-ref="map" class="relative h-[600px] overflow-clip rounded-md border border-slate-300 shadow-lg">
<div class="absolute top-2 right-8 z-10 rounded-md bg-white bg-opacity-75">
<div class="ol-unselectable ol-control">
<button x-on:click.prevent="legendOpened = ! legendOpened" title="Open/Close legend"
class="absolute inset-0 flex items-center justify-center">
<!-- Heroicon name: outline/globe -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 pl-0.5" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
<div x-cloak x-show="legendOpened" x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-300" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute right-0 top-16 left-2 bottom-2 z-10 max-w-sm rounded-md border border-slate-300 bg-white bg-opacity-50 shadow-sm">
<div class="absolute inset-1 rounded-md bg-white bg-opacity-75 p-2">
<div class="flex items-start justify-between">
<h3 class="text-lg font-medium text-slate-700">Legend</h3>
<button x-on:click.prevent="legendOpened = false"
class="text-2xl font-black text-slate-400 transition hover:text-[#3369A1] focus:text-[#3369A1] focus:outline-none">×</button>
</div>
<ul class="mt-2 space-y-1 rounded-md border border-slate-300 bg-white p-2">
<template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
<li class="flex items-center px-2 py-1">
<div x-id="['legend-checkbox']">
<label x-bind:for="$id('legend-checkbox')" class="flex items-center">
<input type="checkbox" x-bind:checked="layer.getVisible()"
x-bind:id="$id('legend-checkbox')"
x-on:change="layer.setVisible(!layer.getVisible())"
class="rounded border-slate-300 text-[#3369A1] shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-slate-600" x-text="layer.get('label')"></span>
</label>
// Only display the content of this template if the current layer is the Monuments
// layer and it is visible on the map (checked)
<template x-if="layer.get('label') === 'Monuments' && layer.getVisible()">
<div class="mt-2 ml-6 text-sm text-slate-600">
// Loop through the layer's features and display a link that will call the gotoFeature
// function (defined in the alpine component) on click passing down the feature
<template x-for="(feature, index) in layer.getSource().getFeatures()" :key="index">
<a href="#"
:title="'Go to ' + feature.get('name')"
x-text="feature.get('name')"
x-on:click.prevent="gotoFeature(feature)"
class="block hover:underline hover:text-slate-800 focus:outline-none focus:underline focus:text-slate-800 transition">
</a>
</template>
</div>
</template>
</div>
</li>
</template>
</ul>
</div>
</div>
</div>
</div>
@once
@push('styles')
<link rel="stylesheet" href="{{ mix('css/map.css') }}">
@endpush
@push('scripts')
<script src="{{ mix('js/map.js') }}"></script>
@endpush
@endonce
- Now implement the gotoFeature function in the alpine component (resources/js/components/map.js):
import Map from "ol/Map.js";
import View from "ol/View.js";
import TileLayer from "ol/layer/Tile.js";
import VectorSource from "ol/source/Vector";
import VectorLayer from "ol/layer/Vector";
import OSM from "ol/source/OSM.js";
import { Style, Fill, Stroke, Circle, Text } from "ol/style.js";
import GeoJSON from "ol/format/GeoJSON";
document.addEventListener("alpine:init", () => {
Alpine.data("map", function () {
return {
legendOpened: false,
map: {},
features: [],
initComponent(monuments) {
this.features = new GeoJSON().readFeatures(monuments);
this.map = new Map({
target: this.$refs.map,
layers: [
new TileLayer({
source: new OSM(),
label: "OpenStreetMap",
}),
new VectorLayer({
source: new VectorSource({
features: this.features,
}),
style: this.styleFunction,
label: "Monuments",
}),
],
view: new View({
projection: "EPSG:4326",
center: [0, 0],
zoom: 2,
}),
});
},
styleFunction(feature, resolution) {
return new Style({
image: new Circle({
radius: 4,
fill: new Fill({
color: "rgba(0, 255, 255, 1)",
}),
stroke: new Stroke({
color: "rgba(192, 192, 192, 1)",
width: 2,
}),
}),
text: new Text({
font: "12px sans-serif",
textAlign: "left",
text: feature.get("name"),
offsetY: -15,
offsetX: 5,
backgroundFill: new Fill({
color: "rgba(255, 255, 255, 0.5)",
}),
backgroundStroke: new Stroke({
color: "rgba(227, 227, 227, 1)",
}),
padding: [5, 2, 2, 5],
}),
});
}
},
// The gotoFeature function accepts the feature, sets the map center to it's coordinates and
// the zoom level to 10 (hardcoded).
gotoFeature(feature) {
this.map.getView().setCenter(feature.getGeometry().getCoordinates());
this.map.getView().setZoom(10);
},
};
});
});
After a "dr npm run build" and page reload in the browser, you should be able to see the monument list in the legend when the layer is checked. You can also click on the link to pan and zoom to each monument automatically:
- You probably noticed no transition between locations/zoom levels, making our map a little "bumpy." Openlayers supports cool animations in the view; let's adjust our code to use it:
gotoFeature(feature) {
this.map.getView().setCenter(feature.getGeometry().getCoordinates());
this.map.getView().setZoom(10);
// Will pan/zoom to the features location over the course of 2000ms
this.map.getView().animate({
center: feature.getGeometry().getCoordinates(),
zoom: 10,
duration: 2000,
});
},
Great! We now have a map that consumes server-rendered vector data, and we can control each feature, display it, pan, and zoom to it. Stay along; in the next post, we will setup automatic asset bundling and hot reloading with Vite.
The commit for this post is available here: server-side-geojson-data