Creating new monuments in PostGIS from a Livewire form, allowing user to click on the map to set the geometry

No screencast available yet for this post.

In this lesson, we will see how we can let users create their monuments using a form designed with Laravel Livewire. We will create a new tab in the map details, prepare a form to create monuments from Longitude and Latitude Coordinates, and let the user click on the map to locate the new monument.

  • The structure of the monuments table will change a little bit, we will make the image field nullable (at first), but we will store the user who created the monuments in the table too. To do so, let's recreate the table entirely in a new migration:
dr php artisan make:migration recreate_monuments_table
sudo chmod a+w database/migrations/*recreate_monuments_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
// First drop the table
        Schema::dropIfExists('monuments');

        Schema::create('monuments', function (Blueprint $table) {
            $table->id();
            $table->string('name');
// The image field is now nullable and we add a foreign id to the users table
            $table->string('image')->nullable();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->timestamps();
        });

        DB::statement("SELECT AddGeometryColumn('public', 'monuments', 'geom', 4326, 'POINT', 2)");
        DB::statement('CREATE INDEX sidx_monuments_geom ON monuments USING GIST (geom)');
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        //
    }
};
  • Then we run the migration:
dr php artisan migrate
  • Let's declare the new relation to the Monument model (app\Models\Modument.php)
<?php

namespace App\Models;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Monument extends Model
{
    use HasFactory;

    protected $guarded = [];
		
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}
  • And the User model (app\Models\User):
<?php

namespace App\Models;

use App\Models\Monument;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens;
    use HasFactory;
    use HasProfilePhoto;
    use Notifiable;
    use TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var string[]
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array
     */
    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_recovery_codes',
        'two_factor_secret',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

    /**
     * The accessors to append to the model's array form.
     *
     * @var array
     */
    protected $appends = [
        'profile_photo_url',
    ];
		
    public function monuments()
    {
        return $this->hasMany(Monument::class);
    }
}
  • The new structure is now ready, and the monuments table is empty. Let's ajust the resources/views/components/map.blade.php file to add a new tab to the details panel of the map (and do some minor tweaks to the tab names and transitions):
<div x-data="map()" x-init="initComponent()">
    <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 details"
                    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 overflow-y-auto rounded-md bg-white bg-opacity-75 p-2 pt-1">
                <div class="flex items-center justify-between pr-1">
                    <div class="flex justify-start space-x-3">
                        <h3 x-on:click.prevend="activeTab = 'legend'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'legend' && 'font-bold'">Legend</h3>
                        <h3 x-on:click.prevend="activeTab = 'monuments'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'monuments' && 'font-bold'">Monuments</h3>
                        <h3 x-on:click.prevend="activeTab = 'legend'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'legend' && 'font-bold'" title="Map's legend">Legend</h3>
                        <h3 x-on:click.prevend="activeTab = 'monuments'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'monuments' && 'font-bold'" title="Monuments list">List</h3>
                        <h3 x-on:click.prevend="activeTab = 'create-monument'" class="cursor-pointer text-slate-700"
                            x-bind:class="activeTab === 'create-monument' && 'font-bold'" title="Create new monument">New</h3>
                    </div>
                    <button x-on:click.prevent="legendOpened = false"
                        class="mb-1 text-2xl font-black text-slate-400 transition hover:text-[#3369A1] focus:text-[#3369A1] focus:outline-none">&times;</button>
                </div>
                <ul x-show="activeTab === 'legend'" x-transition:enter="transition-opacity duration-150"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    x-transition:leave="transition-opacity duration-150" x-transition:leave-start="opacity-100"
                    x-transition:leave-end="opacity-0"
                    class="mt-2 p-1 space-y-1 rounded-md border border-slate-300 bg-white">
                <ul x-show="activeTab === 'legend'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    class="mt-2 p-1 space-y-1 rounded-md border border-slate-300 bg-white">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center p-0.5">
                            <div x-id="['legend-range']" class="w-full rounded-md border border-gray-300 px-2 py-1">
                                <div class="space-y-1">
                                    <label x-bind:for="$id('legend-range')" class="flex items-center">
                                        <span class="text-sm text-slate-600" x-text="layer.get('label')"></span>
                                    </label>
                                    <div x-show="hasLegend(layer)">
                                        <img x-bind:src="legendUrl(layer)" alt="Legend">
                                    </div>
                                </div>
                                <div class="mt-2 text-sm text-slate-600">
                                    <input class="w-full accent-[#3369A1]" type="range" min="0" max="1"
                                        step="0.01" x-bind:id="$id('legend-range')" x-bind:value="layer.getOpacity()"
                                        x-on:change="layer.setOpacity(Number($event.target.value))">
                                </div>
                            </div>
                        </li>
                    </template>
                </ul>
                <div x-show="activeTab === 'monuments'" x-transition:enter="transition-opacity duration-150"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    x-transition:leave="transition-opacity duration-150" x-transition:leave-start="opacity-100"
                    x-transition:leave-end="opacity-0" class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                <div x-show="activeTab === 'monuments'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                     class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                    <livewire:monuments.index />
                </div>
                <div x-show="activeTab === 'create-monument'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                    Create Form
                </div>
            </div>
        </div>
        <div x-cloak x-ref="popup" class="ol-popup ol-control transition">
            <div class="m-0.5 rounded-md bg-white p-2">
                <div class="flex justify-between">
                    <h3 class="text-xs font-medium text-slate-400">Monument</h3>
                    <a href="#" title="Close" x-on:click.prevent="closePopup"
                        class="-mt-1 font-black text-slate-400 transition hover:text-slate-600 focus:text-slate-600 focus:outline-none">&times;</a>
                </div>
                <div x-ref="popupContent" class="mt-2 min-h-[200px] overflow-y-auto"></div>
            </div>
        </div>
    </div>
</div>

@once
    @push('styles')
        @vite(['resources/css/components/map.css'])
    @endpush
    @push('scripts')
        @vite(['resources/js/components/map.js'])
    @endpush
@endonce
  • We now have a new tab to put the form for creating new monuments:

Creating features in PostGIS using OpenLayers draw interactions in Alpine.js and Livewire

  • We will use a Livewire component to host the form for creating new monuments:
dr php artisan make:livewire monuments.create
sudo chmod a+w app/Http/Livewire/Monuments/Create.php
sudo chmod a+w resources/views/livewire/monuments/create.blade.php
  • Let's adust the resources/views/components/map.blade.php file to load the newly created component:
(...)
                <div x-show="activeTab === 'monuments'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                     class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
                    <livewire:monuments.index />
                </div>
                <div x-show="activeTab === 'create-monument'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    class="mt-2 p-1 rounded-md border border-slate-300 bg-white">
					Create Form
                    <livewire:monuments.create />
                </div>
            </div>
        </div>
(...)
  • Now, we will have a form with labels and inputs in the component. To avoid repeating code and styles, let's create two new blade components: resources/views/components/input/label.blade.php and resources/views/components/input/text.blade.php with the following content:

label.blade.php

@props(['value'])

<!-- $attributes->merge will forward any attributes we pass to the component and merge the class attribute -->
<!-- with the tailwind classes defined here, this component will accept either a value attribute or a slot -->
<label
    {{ $attributes->merge(['class' => 'whitespace-nowrap block font-medium text-sm text-gray-700 mb-1']) }}>
    {{ $value ?? $slot }}
</label>

text.blade.php

<input type="text" {{ $attributes->merge(['class' => 'w-full rounded-md border-gray-300 px-2 py-1.5']) }}>
  • Great, we will be able to reuse these components in all of the application, and they will always look the same; now let's create our form with longitude, latitude, and name fields in resources/views/livewire/monuments/create.blade.php:
<div class="px-1 py-2">
<!-- We use wire:submit.prevent to call the save method and prevent the default html behaviour when the form is submitted -->
    <form action="" wire:submit.prevent="save" class="mt-3 space-y-1">
        <div>
            <x-input.label for="longitude" value="Longitude" />
<!-- We use wire:model.defer to defer the synchronisation of the value to the next Livewire call (submit in our case) -->
            <x-input.text wire:model.defer="coordinates.0" id="longitude" />
        </div>

        <div>
            <x-input.label for="latitude" value="Latitude" />
            <x-input.text wire:model.defer="coordinates.1" id="latitude" />
        </div>

        <div>
            <x-input.label for="name" value="Name" />
            <x-input.text wire:model.defer="name" id="name" />
        </div>

        <div class="pt-3 flex justify-end items-center space-x-3">
<!-- Laravel Jetstream comes with built-in components, we will use them for the action message and the save button -->
            <x-jet-action-message class="mr-3" on="saved" />

            <x-jet-secondary-button type="submit" wire:loading.attr="disabled" wire:target="save">
                Save
            </x-jet-secondary-button>
        </div>
    </form>
</div>
  • In the server-side part of the Livewire component, let's create the public properties and the save method:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;

class Create extends Component
{
    public $coordinates = [];
    public $name;

    public function save()
    {
        dd($this->coordinates, $this->name);
    }

    public function render()
    {
        return view('livewire.monuments.create');
    }
}
  • Let's try to fill and submit the form:

  • Great! We submitted the form and have the latitude, longitude, and name for the monument server-side; it's all we need to create a new monument with the new structure. Now, let's create it in the database in the save method of our component:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use Illuminate\Support\Facades\DB;

class Create extends Component
{
    public $coordinates = [];
    public $name;

    public function save()
    {
        dd($this->coordinates, $this->name);
// We use the monuments relation on the User model to create a new monument with name and geom
// For the geom, we simply use the built-in PostGIS function ST_GeomFromText to create a point geometry
// with the coordinates, it's a simple as that
        auth()->user()->monuments()->create([
            'name' => $this->name,
            'geom' => DB::raw("ST_GeomFromText('POINT({$this->coordinates[0]} {$this->coordinates[1]})')"),
        ]);

// reset the form
        $this->coordinates = [];
        $this->name = '';

// Emit a saved event that will be picked-up by the Index and the Jetstream components
        $this->emit('saved');
    }

    public function render()
    {
        return view('livewire.monuments.create');
    }
}
  • Before testing, let's make a slight adjustment to the app/Http/Livewire/Monuments/Index.php component so it gets notified about newly created monuments:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use App\Models\Monument;
use Illuminate\Support\Facades\DB;
use Livewire\WithPagination;

class Index extends Component
{
    use WithPagination;

    public $search;

// Listen for the saved event and rerender when received
    protected $listeners = ['saved' => 'render'];

    public function updatedSearch()
    {
        $this->resetPage();
    }

    public function render()
    {
        return view('livewire.monuments.index', [
            'monuments' => Monument::query()
                ->leftJoin('world-administrative-boundaries', DB::raw('st_within(monuments.geom, "world-administrative-boundaries".geom)'), '=', DB::raw('true'))
                ->selectRaw('monuments.name as name, "world-administrative-boundaries".name as country, st_asgeojson(monuments.*) as geojson')
                ->when($this->search, function ($query, $search) {
                    $search = '%' . $search . '%';
                    $query->where('monuments.name', 'ilike', $search)
                        ->orWhere('world-administrative-boundaries.name', 'ilike', $search);
                })
                ->orderBy('monuments.name')
                ->simplePaginate(10)
        ]);
    }
}
  • Let's try to create a monument:

  • As you can see, the monument is created in the database, we get a nice "Saved" message at the left of the button, the form is reset, and the new monument automatically appears in the list in the other tab, thanks to the "saved" Livewire event. Now the map doesn't automatically refresh yet; we have to zoom or pan to make the monument appear. We will see how we can notify the map about the new component later. For now, the form is working, but as you probably know, in the web world, we cannot trust any input from the user; we have to implement some validation before going on and saving the monument to the database. Fortunately for us, Laravel is very good at validation, and implementing it in Livewire is very elegant. First, let's adjust the form with some more components to display the errors; again, we will reuse a Jetstream component in the resources/views/livewire/monuments/create.blade.php file:
<div class="px-1 py-2">
    <form action="" wire:submit.prevent="save" class="mt-3 space-y-1">
        <div>
            <x-input.label for="longitude" value="Longitude" />
            <x-input.text wire:model.defer="coordinates.0" id="longitude" />
            <x-jet-input-error for="coordinates.0" class="mt-1" />
        </div>

        <div>
            <x-input.label for="latitude" value="Latitude" />
            <x-input.text wire:model.defer="coordinates.1" id="latitude" />
            <x-jet-input-error for="coordinates.1" class="mt-1" />
        </div>

        <div>
            <x-input.label for="name" value="Name" />
            <x-input.text wire:model.defer="name" id="name" />
            <x-jet-input-error for="name" class="mt-1" />
        </div>

        <div class="pt-3 flex justify-end items-center space-x-3">
            <x-jet-action-message class="mr-3" on="saved" />

            <x-jet-secondary-button type="submit" wire:loading.attr="disabled" wire:target="save">
                Save
            </x-jet-secondary-button>
        </div>
    </form>
</div>
  • Now let's define and apply some Laravel validation rules and custom error messages in the app/Http/Livewire/Monuments/Create.php file:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use Illuminate\Support\Facades\DB;

class Create extends Component
{
    public $coordinates = [];
    public $name;

// The array of rules
    protected $rules = [
        'coordinates.0' => 'required|numeric|between:-180,180',
        'coordinates.1' => 'required|numeric|between:-90,90',
        'name' => 'required|string|max:255|unique:monuments,name',
    ];

// One custom message per rule, it's not mandatory, otherwise, default messages will be shown
    protected $messages = [
        'coordinates.0.required' => 'The longitude is required.',
        'coordinates.0.numeric' => 'The longitude must be a number.',
        'coordinates.0.between' => 'The longitude must be between -180 and 180.',
        'coordinates.1.required' => 'The latitude is required.',
        'coordinates.1.numeric' => 'The latitude must be a number.',
        'coordinates.1.between' => 'The latitude must be between -90 and 90.',
        'name.required' => 'The name is required.',
        'name.string' => 'The name must be a string.',
        'name.max' => 'The name may not be greater than 255 characters.',
        'name.unique' => 'The name has already been taken.',
    ];

    public function save()
    {
// Validate on save, if we pass this function we are sure that $this->coordinates and $this->name are safe
        $this->validate();

        auth()->user()->monuments()->create([
            'name' => $this->name,
            'geom' => DB::raw("ST_GeomFromText('POINT({$this->coordinates[0]} {$this->coordinates[1]})')"),
        ]);

        $this->coordinates = [];
        $this->name = '';

        $this->emit('saved');
    }

    public function render()
    {
        return view('livewire.monuments.create');
    }
}

Creating features in PostGIS using OpenLayers draw interactions in Alpine.js and Livewire

  • We are making excellent progress; we can create new monuments from validated user input; now, let's see how we can notify our OpenLayers map component of this new monument, so it can react accordingly (refresh the layer, zoom to the monument, etc.). Let's first make some adjustments to the resources/js/components/map.js file:
import Map from "ol/Map.js";
import View from "ol/View.js";
import {
    Tile as TileLayer
} from 'ol/layer.js';
import OSM from "ol/source/OSM.js";
import Overlay from "ol/Overlay.js";
import TileWMS from 'ol/source/TileWMS.js';

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            activeTab: 'legend',
// We change the monumentsLayer to a component wide variable so we can access it easily
            monumentsLayer: {},
            initComponent() {
                let monumentsLayer = new TileLayer({
                this.monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });

                let worldAdministrativeBoundariesLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-administrative-boundaries',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Administrative Boundaries',
                });

                let worldRiversLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-rivers',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Rivers',
                });

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        worldAdministrativeBoundariesLayer,
                        worldRiversLayer,
                        monumentsLayer
                        this.monumentsLayer
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [-78.2161, -0.7022],
                        zoom: 8,
                    }),
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });

                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }

                    let overlay = this.map.getOverlayById('info')
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''

                    const viewResolution = /** @type {number} */ (event.map.getView().getResolution())

                    const url = monumentsLayer.getSource().getFeatureInfoUrl(
                    const url = this.monumentsLayer.getSource().getFeatureInfoUrl(
                        event.coordinate,
                        viewResolution,
                        'EPSG:4326', {
                            'INFO_FORMAT': 'application/json'
                        })

                    if (url) {
                        fetch(url)
                            .then((response) => response.json())
                            .then((json) => {
                                if (json.features.length > 0) {
                                    this.gotoMonument(json.features[0])
                                }
                            });
                    }

                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
            gotoMonument(jsonFeature) {
                this.map.getView().animate({
                    center: jsonFeature.geometry.coordinates,
                    zoom: 15,
                    duration: 500,
                });

                let content =
                    '<h4 class="text-gray-500 font-bold">' +
                    jsonFeature.properties.name +
                    '</h4>'

// We made the image nullable, so we show a placeholder if no image is present
// The placeholder-image.png is committed in public/img/placeholder-image.png
                let image = jsonFeature.properties.image || '/img/placeholder-image.png'
                content +=
                    '<img src="' +
                    jsonFeature.properties.image +
                    image +
                    '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                this.$refs.popupContent.innerHTML = content

// Tile and Image layers are a bit complex because images are served, and browsers tend to cache them (this
// is a good thing in general but not in this case) so refresh it by adding a timestamp to the queries.
// It will force the browser to ask the server for new tile images.
                this.monumentsLayer.getSource().updateParams({
                    'TIMESTAMP': Date.now()
                })
								
                setTimeout(() => {
                    this.map.getOverlayById('info').setPosition(
                        jsonFeature.geometry.coordinates
                    );
                }, 500)

            },
            hasLegend(layer) {
                return layer.getSource() instanceof TileWMS
            },
            legendUrl(layer) {
                if (this.hasLegend(layer)) {
                    return layer
                        .getSource()
                        .getLegendUrl(this.map.getView().getResolution(), {
                            LEGEND_OPTIONS: 'forceLabels:on'
                        })
                }
            }
        };
    });
});
  • These changes didn't break anything, but we prepared the gotoMonument function to be called in the context of a new monument created event. We also added a placeholder image as we don't have images for our monuments yet (we will cover how to upload images with Livewire in the next lesson). For now, the only thing we have to do is to call the gotoMonument function when a new feature is created. But how can we do it from the Livewire component (server-side)? Let's start by adding an Alpine.js listener on the component in the resources/views/components/map.blade.php file:
<!-- Just a single adjustment, we tell the component to listen for a new-monument event on the window object
     and to call the gotoMonument function with the monument parameter from the event as an argument
     we will dispatch this event from the Livewire component -->
<div x-data="map()" x-init="initComponent()">
<div x-data="map()" x-init="initComponent()" x-on:new-monument.window="gotoMonument(JSON.parse($event.detail.monument))">
    <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">
(...)
  • Now the last piece of the puzzle, let's dispatch this new-monument event with the monument geojson representation from the Livewire component:
<?php

namespace App\Http\Livewire\Monuments;

use Livewire\Component;
use App\Models\Monument;
use Illuminate\Support\Facades\DB;

class Create extends Component
{
    public $coordinates = [];
    public $name;

    protected $rules = [
        'coordinates.0' => 'required|numeric|between:-180,180',
        'coordinates.1' => 'required|numeric|between:-90,90',
        'name' => 'required|string|max:255|unique:monuments,name',
    ];

    protected $messages = [
        'coordinates.0.required' => 'The longitude is required.',
        'coordinates.0.numeric' => 'The longitude must be a number.',
        'coordinates.0.between' => 'The longitude must be between -180 and 180.',
        'coordinates.1.required' => 'The latitude is required.',
        'coordinates.1.numeric' => 'The latitude must be a number.',
        'coordinates.1.between' => 'The latitude must be between -90 and 90.',
        'name.required' => 'The name is required.',
        'name.string' => 'The name must be a string.',
        'name.max' => 'The name may not be greater than 255 characters.',
        'name.unique' => 'The name has already been taken.',
    ];

    public function save()
    {
        $this->validate();

        $monument = auth()->user()->monuments()->create([
            'name' => $this->name,
            'geom' => DB::raw("ST_GeomFromText('POINT({$this->coordinates[0]} {$this->coordinates[1]})')"),
        ]);
// We switch to this syntax so we don't have to retrieve the monument full model, just the new id
        $monumentId = Monument::insertGetId([
            'name' => $this->name,
            'geom' => DB::raw("ST_GeomFromText('POINT({$this->coordinates[0]} {$this->coordinates[1]})')"),
            'user_id' => auth()->id(),
        ]);

        $this->coordinates = [];
        $this->name = '';

        $this->emit('saved');

// We get the geojson with the st_asgeojson PostGIS function, just like we dis in the Index component
        $geojson = Monument::query()
            ->where('id', $monumentId)
            ->selectRaw('st_asgeojson(monuments.*) as geojson')
            ->firstOrFail()
            ->geojson;

// We use Livewire dispatchBrowserEvent funtion to dispatch the event along with the geojson data
        $this->dispatchBrowserEvent('new-monument', ['monument' => $geojson]);
    }

    public function render()
    {
        return view('livewire.monuments.create');
    }
}
  • Now let's try to create a new monument:

  • Voil√†! The end-user is now able to create new monuments, and all of the application is behaving smoothly; there is attribute validation, and the new monument is automatically appearing in the list and is searchable. We even zoom to the location and show the information popup on the map. This is excellent progress; however, we cannot assume that the user will know the exact coordinates of the monument (unless he gets it from a GPS). The next step is to allow them to click on the map and get the coordinates. Fortunately, this is not that difficult with our stack (OpenLayers, Alpine.js, and Laravel Livewire). Let's do it; first, let's add a few tweaks and functions to the resources/js/components/map.js file:
import Map from "ol/Map.js";
import View from "ol/View.js";
import {
    Tile as TileLayer
} from 'ol/layer.js';
import OSM from "ol/source/OSM.js";
import Overlay from "ol/Overlay.js";
import TileWMS from 'ol/source/TileWMS.js';
// We will need a vector layer to hold the geometries of the users drawings
import VectorSource from "ol/source/Vector.js";
import VectorLayer from "ol/layer/Vector.js";
import {
    Icon,
    Style,
} from "ol/style.js";
// We will use the Draw OpenLayers interaction
import {
    Draw,
} from "ol/interaction.js";

document.addEventListener("alpine:init", () => {
    Alpine.data("map", function () {
        return {
            legendOpened: false,
            map: {},
            activeTab: 'legend',
            monumentsLayer: {},
// We declare draw and source at the component level so we can reference them everywhere
            draw: {},
            source: new VectorSource({}),
// We will have two modes for the map, view and draw
            mode: 'view',
            initComponent() {
// We make sure we stop drawing and go back to view mode whenever the details panel tab changes
                this.$watch('activeTab', (value) => {
                    this.stopDrawMonument()
                })

// We initialise the draw interaction with the vector source and Point geometry type
// Lines and Polygons are also supported, we will cover it in a future lesson
                this.draw = new Draw({
                    source: this.source,
                    type: 'Point',
                });
								
                this.monumentsLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:monuments',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'Monuments',
                });
                let worldAdministrativeBoundariesLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-administrative-boundaries',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Administrative Boundaries',
                });

                let worldRiversLayer = new TileLayer({
                    source: new TileWMS({
                        url: 'http://localhost:8080/geoserver/wms',
                        params: {
                            'LAYERS': 'laravelgis:world-rivers',
                            'TILED': true
                        },
                        serverType: 'geoserver',
                    }),
                    label: 'World Rivers',
                });

// We create a new vector layer that will hold the points drawn by the user
// For demonstration purposes, we style it with an Icon; the file is committed in public/imp/location-marker.png
// This layer will not be shown in the legend, so we don't set any label
                let drawLayer = new VectorLayer({
                    source: this.source,
                    style: new Style({
                        image: new Icon({
                            anchor: [0.5, 1],
                            anchorXUnits: 'fraction',
                            anchorYUnits: 'fraction',
                            src: '/img/location-marker.png',
                        }),
                    }),
                })

                this.map = new Map({
                    target: this.$refs.map,
                    layers: [
                        new TileLayer({
                            source: new OSM(),
                            label: 'OpenStreetMap',
                        }),
                        worldAdministrativeBoundariesLayer,
                        worldRiversLayer,
// We add the layer to the map
                        this.monumentsLayer
                        this.monumentsLayer,
                        drawLayer,
                    ],
                    view: new View({
                        projection: "EPSG:4326",
                        center: [-78.2161, -0.7022],
                        zoom: 8,
                    }),
                    overlays: [
                        new Overlay({
                            id: 'info',
                            element: this.$refs.popup,
                            stopEvent: true,
                        }),
                    ],
                });

                this.map.on("singleclick", (event) => {
                    if (event.dragging) {
                        return;
                    }

                    let overlay = this.map.getOverlayById('info')
                    overlay.setPosition(undefined)
                    this.$refs.popupContent.innerHTML = ''

                    const viewResolution = /** @type {number} */ (event.map.getView().getResolution())

                    const url = this.monumentsLayer.getSource().getFeatureInfoUrl(
                        event.coordinate,
                        viewResolution,
                        'EPSG:4326', {
                            'INFO_FORMAT': 'application/json'
                        })

                    if (url) {
                        fetch(url)
                            .then((response) => response.json())
                            .then((json) => {
                                if (json.features.length > 0) {
                                    this.gotoMonument(json.features[0])
                                }
                            });
                    }

                });
            },
            closePopup() {
                let overlay = this.map.getOverlayById('info')
                overlay.setPosition(undefined)
                this.$refs.popupContent.innerHTML = ''
            },
            gotoMonument(jsonFeature) {
// We make sure we stop drawing when this function is called (especially when a new monument has been created)
                this.stopDrawMonument()
                
				this.map.getView().animate({
                    center: jsonFeature.geometry.coordinates,
                    zoom: 15,
                    duration: 500,
                });

                let content =
                    '<h4 class="text-gray-500 font-bold">' +
                    jsonFeature.properties.name +
                    '</h4>'

                let image = jsonFeature.properties.image || '/img/placeholder-image.png'
                content +=
                    '<img src="' +
                    image +
                    '" class="mt-2 w-full max-h-[200px] rounded-md shadow-md object-contain overflow-clip">'

                this.$refs.popupContent.innerHTML = content

                this.monumentsLayer.getSource().refresh()

                this.monumentsLayer.getSource().updateParams({
                    'TIMESTAMP': Date.now()
                })
								
                setTimeout(() => {
                    this.map.getOverlayById('info').setPosition(
                        jsonFeature.geometry.coordinates
                    );
                }, 500)

            },
            hasLegend(layer) {
                return layer.getSource() instanceof TileWMS
            },
            legendUrl(layer) {
                if (this.hasLegend(layer)) {
                    return layer
                        .getSource()
                        .getLegendUrl(this.map.getView().getResolution(), {
                            LEGEND_OPTIONS: 'forceLabels:on'
                        })
                }
            }
            },
// Function to swith to draw mode
            startDrawMonument() {
                this.mode = "draw"

// We receive this event before the point is added to the source, we clear the source so we
// make sure there is never more than one point in the vector source
                this.draw.on("drawend", (e) => {
                    this.source.clear();
                });

// When a change is detected in the source, we receive this event
                this.source.on("change", (e) => {
                    const features = this.source.getFeatures()

// If there is one point in the source, it means the user has just finished clicking on the map
                    if (features.length === 1) {
                        const coordinates = features[0].getGeometry().getCoordinates()

// We use this magic function to set the coordinates property of the Livewire component to
// the coordinates of the point we found in the source
                        this.$wire.set('coordinates', coordinates)

// We pan to point (the map will automatically center at this point coordinates
                        this.map.getView().animate({
                            center: coordinates,
                            duration: 500,
                        });
                    }
                });

// We add the draw to the map interactions, it will actually set the map in draw mode
                this.map.addInteraction(this.draw);
            },
// Function to stop drawing and go back to view mode
            stopDrawMonument() {
                this.source.clear();

                this.map.removeInteraction(this.draw);

                this.mode = "view";
            }
        };
    });
});
  • Let's make a minor tweak to the resources/views/components/map.blade.php file, so the newly created vector layer is not shown in the legend:
(...)
                <ul x-show="activeTab === 'legend'" x-transition:enter="transition-opacity duration-300"
                    x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
                    class="mt-2 p-1 space-y-1 rounded-md border border-slate-300 bg-white">
                    <template x-for="(layer, index) in map.getAllLayers().reverse()" :key="index">
                        <li class="flex items-center p-0.5">
                        <li x-show="layer.get('label')" class="flex items-center p-0.5">
                            <div x-id="['legend-range']" class="w-full rounded-md border border-gray-300 px-2 py-1">
                                <div class="space-y-1">
                                    <label x-bind:for="$id('legend-range')" class="flex items-center">
                                        <span class="text-sm text-slate-600" x-text="layer.get('label')"></span>
                                    </label>
                                    <div x-show="hasLegend(layer)">
                                        <img x-bind:src="legendUrl(layer)" alt="Legend">
                                    </div>
                                </div>
                                <div class="mt-2 text-sm text-slate-600">
                                    <input class="w-full accent-[#3369A1]" type="range" min="0" max="1"
                                        step="0.01" x-bind:id="$id('legend-range')" x-bind:value="layer.getOpacity()"
                                        x-on:change="layer.setOpacity(Number($event.target.value))">
                                </div>
                            </div>
                        </li>
                    </template>
                </ul>
(...)
  • Finally, we need to create buttons allowing the user to toggle draw mode on the map. We will do so just before the form in the resources/views/livewire/monuments/create.blade.php file:
<div class="px-1 py-2">
<!-- We use wire:ignore to make sure Livewire doesn't recreates the button when doing it's round trips to the server -->
    <div wire:ignore>
<!-- The button to start drawing button is only shown when the map is in view mode -->
        <button x-show="mode === 'view'" x-on:click="startDrawMonument()" title="Start drawing"
            class="text-slate-500 transition hover:text-slate-800 focus:text-slate-800 focus:outline-none">
            Start drawing
        </button>
<!-- The button to stop drawing button is only shown when the map is draw mode -->
        <button x-show="mode === 'draw'" x-on:click="stopDrawMonument()" title="Stop drawing"
            class="text-slate-500 transition hover:text-slate-800 focus:text-slate-800 focus:outline-none">
            Stop drawing
        </button>
    </div>
    <form action="" wire:submit.prevent="save" class="mt-3 space-y-1">
    <form action="" wire:submit.prevent="save" class="mt-1 space-y-1">
        <div>
            <x-input.label for="longitude" value="Longitude" />
            <x-input.text wire:model.defer="coordinates.0" id="longitude" />
            <x-jet-input-error for="coordinates.0" class="mt-1" />
        </div>

        <div>
            <x-input.label for="latitude" value="Latitude" />
            <x-input.text wire:model.defer="coordinates.1" id="latitude" />
            <x-jet-input-error for="coordinates.1" class="mt-1" />
        </div>

        <div>
            <x-input.label for="name" value="Name" />
            <x-input.text wire:model.defer="name" id="name" />
            <x-jet-input-error for="name" class="mt-1" />
        </div>

        <div class="pt-3 flex justify-end items-center space-x-3">
            <x-jet-action-message class="mr-3" on="saved" />

            <x-jet-secondary-button type="submit" wire:loading.attr="disabled" wire:target="save">
                Save
            </x-jet-secondary-button>
        </div>
    </form>
</div>
  • Let's try it in the browser now:

  • As you can witness, when the map is in draw mode, and the user clicks on it, it puts a pin on the location clicked, and the inputs for longitude and latitude automatically get filled/updated. The user can then fill in the name of the new monument and click save to persist it to the database.

Well, that was another big lesson; we've learned how to create forms and validate data in Laravel Livewire, to create new features in PostGIS with Laravel, to emit events between Livewire components, to dispatch browser events from Livewire to Alpine.js and to use the OpenLayers draw interaction to let the user create monuments by clicking on the map. Our application still feels like a SPA, but it's not! Stay along; in the next post and following few lessons, we will learn about uploading images with Livewire and FilePond, the built-in browser Location API, custom validation rules using Laravel and PostGIS, and other sources of base map apart from OpenStreetMap that we can use, including aerial imagery. We will also continue to improve the user experience with the OpenLayers map.

The commit for this post is available here: creating-features-in-postgis-using-openlayers-draw-interactions-in-alpinejs-and-livewire (please note that I've made a few more changes to the code, I changed the reload-monuments command, so it works with the new monuments table structure, I removed the images of the monuments from the public/img directory, and I updated npm and composer dependencies)

First published 10 months ago
Latest update 9 months ago
wim debbaut
Posted by wim debbaut 10 months ago

Great contribution again! No any hick-ups with this chapter...

To be honest, I am not a web developper (merely a telecom engineer). So I am just able to copy/paste the adjusted files. In any case, we are looking forward to insert the images and another base map which we actually use at our premises in Leuven. On the long term, I would like to insert a vector layer too in order to read the feature attributes. Have a nice day.


No response yet
You need to be signed in to post comments, you can sign in here if you already have an account or register here if you don't.