Repository: mstahv/spring-boot-spatial-example
Branch: master
Commit: 64c230df49c7
Files: 16
Total size: 26.7 KB
Directory structure:
gitextract_erka4_70/
├── .gitignore
├── README.md
├── pom.xml
└── src/
├── main/
│ ├── java/
│ │ └── org/
│ │ └── vaadin/
│ │ └── example/
│ │ ├── AbstractEntity.java
│ │ ├── AppShell.java
│ │ ├── Application.java
│ │ ├── EventEditor.java
│ │ ├── MainView.java
│ │ ├── SportEvent.java
│ │ ├── SportEventRepository.java
│ │ └── SportEventService.java
│ └── resources/
│ ├── application.properties
│ └── schema-geodb.sql
└── test/
└── java/
└── org/
└── vaadin/
└── example/
├── DatabaseTestContainerConfiguration.java
├── SpatialSpringBootAppApplicationTests.java
└── TestApp.java
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
## Maven stuff
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# Exclude maven wrapper
!/.mvn/wrapper/maven-wrapper.jar
## Eclipse stuff
.metadata
bin/
tmp/
*.tmp
*.bak
*.swp
*~.nib
local.properties
.settings/
.loadpath
.recommenders
# Eclipse Core
.project
# External tool builders
.externalToolBuilders/
# Locally stored "Eclipse launch configurations"
*.launch
# PyDev specific (Python IDE for Eclipse)
*.pydevproject
# CDT-specific (C/C++ Development Tooling)
.cproject
# JDT-specific (Eclipse Java Development Tools)
.classpath
# Java annotation processor (APT)
.factorypath
# PDT-specific (PHP Development Tools)
.buildpath
# sbteclipse plugin
.target
# Tern plugin
.tern-project
# TeXlipse plugin
.texlipse
# STS (Spring Tool Suite)
.springBeans
# Code Recommenders
.recommenders/
frontend/generated
================================================
FILE: README.md
================================================
# A Spring Boot example editing spatial data in relational database

## How To run?
Make sure you have Docker installed and running (needed to create test DB) and a modern Java IDE that supports at least JDK 21. (see older versions of the example if you are tied to some legacy versions).
Even if you don't want to run it, you probably want to first import the code to your favourite Java IDE (tested in IntelliJ last time) for easier exploring of the demo code. Then locate the TestApp class, and it's main method (src/main/test), run it! This will:
* Use Docker to get a postgres with postgis extensions
* Wire that to this Spring Boot app for development
* Run the Vaadin UI in development mode
Alternatively, if you have Maven installed, run from CLI:
mvn spring-boot:test-run
## What it showcases
This is a small example app that shows how one can use:
* [Spring Boot](http://projects.spring.io/spring-boot/) and [Spring Data](https://spring.io/projects/spring-data)
* Latest [Hibernate](http://hibernate.org/orm/) with spatial features. At the application API, only standard JPA stuff (and Spring Data) is used.
* ~~The example also uses [QueryDSL](http://www.querydsl.com) spatial query as an example. QueryDSL contain excellent support for spatial types.~~ QueryDSL example replaced with plain JPQL(with Hibernate spatial extensions) as the latest version is not compatible with latest JTS/Hibernate. See https://github.com/querydsl/querydsl/issues/2404. If you want to see the example of QueryDSL usage in this setup, check out a bit older version of the example.
* Relational database, like PostGis (default, Postgres + extentiosn), H2GIS or MySQL, which supports basic spatial types. The example automatically launches Docker image with PostGis for the demo using TestContainers, if run via TestApp class in src/test/java/org/vaadin/example. Not that Hibernate might need tiny adjustments for other databases.
* [Vaadin](https://vaadin.com/) and [MapLibreGL }> add-on](https://vaadin.com/directory/component/maplibregl--add-on) to build the UI layer. MapLibre add-on is a Vaadin wrapper for [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) slippy map widget and [mapbox-gl-draw](https://github.com/mapbox/mapbox-gl-draw). Its Vaadin field implementations which make it dead simple to edit [JTS](https://locationtech.github.io/jts/) data types directly from the JPA entities.
* As base layer for maps, crisp vector format [OpenStreetMap](https://www.openstreetmap.org/) data via [MapTiler](https://www.maptiler.com) is used, but naturally any common background map can be used.
...to build a full-stack web app handling spatial data efficiently.
As the data is in an optimized form in the DB, it is possible to create efficient queries to the backend and e.g. only show features relevant to the current viewport of the map visualizing features or what ever you can with the spatial queries.
Enjoy!
================================================
FILE: pom.xml
================================================
4.0.0
org.springframework.boot
spring-boot-starter-parent
4.0.0
org.vaadin
spatial-spring-boot-app
0.0.1-SNAPSHOT
spatial-spring-boot-app
Spatial example
21
25.0.0-rc2
com.vaadin
vaadin-bom
${vaadin.version}
pom
import
org.springframework.boot
spring-boot-starter-data-jpa
org.hibernate.orm
hibernate-spatial
org.springframework.boot
spring-boot-starter-validation
com.vaadin
vaadin-spring-boot-starter
com.vaadin
vaadin-dev
true
org.parttio
maplibre
2.0.0
in.virit
viritin
3.0.0
org.postgresql
postgresql
runtime
org.springframework.boot
spring-boot-starter-test
test
org.springframework.boot
spring-boot-testcontainers
test
org.testcontainers
testcontainers-junit-jupiter
test
org.testcontainers
testcontainers-postgresql
test
org.springframework.boot
spring-boot-devtools
provided
org.springframework.boot
spring-boot-maven-plugin
com.vaadin
vaadin-maven-plugin
${vaadin.version}
build-frontend
================================================
FILE: src/main/java/org/vaadin/example/AbstractEntity.java
================================================
package org.vaadin.example;
import java.io.Serializable;
import java.util.Objects;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Version;
/**
*
* @author Matti Tahvonen
*/
@MappedSuperclass
public abstract class AbstractEntity implements Serializable, Cloneable {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Version
private int version;
public Long getId() {
return id;
}
protected void setId(Long id) {
this.id = id;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if(this.id == null) {
return false;
}
if (obj instanceof AbstractEntity && obj.getClass().equals(getClass())) {
return this.id.equals(((AbstractEntity) obj).id);
}
return false;
}
@Override
public int hashCode() {
int hash = 5;
hash = 43 * hash + Objects.hashCode(this.id);
return hash;
}
}
================================================
FILE: src/main/java/org/vaadin/example/AppShell.java
================================================
package org.vaadin.example;
import com.vaadin.flow.component.dependency.StyleSheet;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.theme.lumo.Lumo;
@StyleSheet(Lumo.STYLESHEET)
public class AppShell implements AppShellConfigurator {
}
================================================
FILE: src/main/java/org/vaadin/example/Application.java
================================================
package org.vaadin.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.vaadin.addons.maplibre.BaseMapConfigurer;
/**
* This would be the main app for deployment artifact.
* For deployment, you need to configure DB.
* For local testing & development, use TestApp that
* launches PostGIS using TestContainers.
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
// Configure default base map
@Bean
BaseMapConfigurer baseMapProvider() {
return map -> {
// NOTE, Create your own API key in maptiler! This is registered to work on localhost for the demo only
map.initStyle("https://api.maptiler.com/maps/streets/style.json?key=G5n7stvZjomhyaVYP0qU");
};
}
}
================================================
FILE: src/main/java/org/vaadin/example/EventEditor.java
================================================
package org.vaadin.example;
import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.datepicker.DatePicker;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.Route;
import org.vaadin.addons.maplibre.LineStringField;
import org.vaadin.addons.maplibre.PointField;
import org.vaadin.firitin.components.orderedlayout.VHorizontalLayout;
import org.vaadin.firitin.components.orderedlayout.VVerticalLayout;
import org.vaadin.firitin.components.textfield.VTextField;
import org.vaadin.firitin.form.AbstractForm;
@Route
public class EventEditor extends AbstractForm {
private final SportEventService service;
private TextField title = new VTextField("Title");
private DatePicker date = new DatePicker("Date");
private PointField location = new PointField("Location");
private LineStringField route = new LineStringField("Route");
public EventEditor(SportEventService service) {
super(SportEvent.class);
this.service = service;
setSavedHandler(sportevent -> {
service.save(sportevent);
UI.getCurrent().navigate(MainView.class);
});
setResetHandler(sportevent -> {
UI.getCurrent().navigate(MainView.class);
});
getDeleteButton().setVisible(false);
}
@Override
protected Component createContent() {
getContent().setSizeFull();
location.setSizeFull();
route.setSizeFull();
return new VVerticalLayout()
.withComponent(new VHorizontalLayout(title, date))
.withExpanded(new VHorizontalLayout(location, route)
.withSizeFull())
.withComponent(getToolbar())
.withFullHeight();
}
}
================================================
FILE: src/main/java/org/vaadin/example/MainView.java
================================================
package org.vaadin.example;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.icon.VaadinIcon;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.Route;
import in.virit.color.NamedColor;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.vaadin.addons.maplibre.DrawControl;
import org.vaadin.addons.maplibre.LinePaint;
import org.vaadin.addons.maplibre.MapLibre;
import org.vaadin.addons.maplibre.Marker;
import org.vaadin.firitin.components.RichText;
import org.vaadin.firitin.components.button.DeleteButton;
import org.vaadin.firitin.components.button.VButton;
import org.vaadin.firitin.components.grid.VGrid;
import org.vaadin.firitin.components.orderedlayout.VVerticalLayout;
import org.vaadin.firitin.components.textfield.VTextField;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author mstahv
*/
@Route
public class MainView extends VVerticalLayout {
private final SportEventService service;
TextField filter = new VTextField()
.withPlaceholder("Filter by name...");
private Map eventToMarker = new HashMap<>();
private RichText infoText = new RichText().withMarkDown(
"###V-Leaflet example\n\n"
+ "This is small example app to demonstrate how to add simple GIS "
+ "features to your Spring Boot Vaadin app. "
+ "[Check out the sources!](https://github.com/mstahv/spring-boot-spatial-example)");
private VGrid table = new VGrid<>(SportEvent.class);
private Button addNew = new VButton("New event...")
.withIcon(VaadinIcon.PLUS.create())
.withClickListener(e -> {
UI.getCurrent().navigate(EventEditor.class)
.get().setEntity(new SportEvent());
});
// Note, the base map here and in editors could be defined
// here, but are instead defined application wide in Application class
private MapLibre map = new MapLibre();
public MainView(SportEventService service) {
this.service = service;
service.ensureTestData();
var drawControl = new DrawControl(map);
drawControl.addGeometryChangeListener(e -> {
Polygon p = (Polygon) e.getGeom().getGeometryN(0);
loadEventsWithinBounds(p);
drawControl.clear();
});
add(new HorizontalLayout(
addNew,
new VButton("Draw area to list events", e -> {
drawControl.setMode(DrawControl.DrawMode.DRAW_POLYGON);
}),
new VButton("List events within viewport", e -> {
loadEventsInViewport();
}),
filter
));
withExpanded(map, table);
filter.addValueChangeListener(e -> {
loadEventsByNameFilter(e.getValue());
});
table.addComponentColumn(sportEvent ->
new HorizontalLayout(
new VButton(VaadinIcon.EDIT.create(), e-> {
UI.getCurrent().navigate(EventEditor.class).get()
.setEntity(sportEvent);
}),
new DeleteButton(() -> {
delete(sportEvent);
})
));
table.asSingleSelect().addValueChangeListener(e -> {
SportEvent sportEvent = e.getValue();
if(e.isFromClient() && sportEvent != null) {
// open marker popup and center the map to event
Marker marker = eventToMarker.get(sportEvent);
marker.openPopup();
map.flyTo(marker.getGeometry(), 10);
}
});
loadEventsByNameFilter("");
map.fitToContent();
}
public void delete(SportEvent event) {
service.delete(event);
loadEventsInViewport();
}
private void loadEventsByNameFilter(String value) {
List events = service.filterByTitle(value);
map.fitToContent();
setEvents(events);
}
private void loadEventsInViewport() {
map.getViewPort().thenAccept(vp -> {
Polygon bounds = vp.getBounds();
loadEventsWithinBounds(bounds);
});
}
private void loadEventsWithinBounds(Polygon bounds) {
setEvents(service.filterByBounds(bounds));
}
private void setEvents(List events) {
/* Populate table... */
table.setItems(events);
/* ... and map */
map.removeAll();
eventToMarker.clear();
for (final SportEvent sportEvent : events) {
/*
* Adds geometries to the map. Note that this method adds a separate
* layer per geometry and is thus not very optimised. For better
* performance with a large number of geometries, combine layers
* or load features as vector tiles (~ lazy load only the visible portion)
* if there is a ton of those.
*/
if(sportEvent.getLocation() != null){
Point p = sportEvent.getLocation();
Marker marker = map.addMarker(p)
.withPopup(sportEvent.getTitle());
eventToMarker.put(sportEvent,marker);
marker.addClickListener( () -> {
// focus in Table
table.asSingleSelect().setValue(sportEvent);
});
}
if(sportEvent.getRoute() != null) {
map.addLineLayer(sportEvent.getRoute(), new LinePaint(NamedColor.BLUE, 3.0));
// TODO add click listener also for lines
}
}
}
}
================================================
FILE: src/main/java/org/vaadin/example/SportEvent.java
================================================
package org.vaadin.example;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import java.time.LocalDate;
@Entity
public class SportEvent extends AbstractEntity {
@NotEmpty
private String title;
@Column(columnDefinition = "DATE")
private LocalDate date;
@NotNull
@Column(columnDefinition = "geometry")
private Point location;
@Column(columnDefinition = "geometry")
private LineString route;
public SportEvent() {
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public LineString getRoute() {
return route;
}
public void setRoute(LineString route) {
this.route = route;
}
public Point getLocation() {
return location;
}
public void setLocation(Point location) {
this.location = location;
}
}
================================================
FILE: src/main/java/org/vaadin/example/SportEventRepository.java
================================================
package org.vaadin.example;
import java.util.List;
import org.locationtech.jts.geom.Geometry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* @author mstahv
*/
public interface SportEventRepository extends JpaRepository {
/**
* Example method of a GIS query. This uses Hibernate spatial extensions, so
* it does not work with other JPA implementations.
*
* @param bounds the geometry
* @return SpatialEvents inside given geometry and with given filter for the title
*/
@Query(value = "SELECT se FROM SportEvent se WHERE within(se.location, :bounds) = true")
public List findAllWithin(@Param("bounds") Geometry bounds);
public List findByTitleContainingIgnoreCase(String title);
}
================================================
FILE: src/main/java/org/vaadin/example/SportEventService.java
================================================
package org.vaadin.example;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Point;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.io.ParseException;
import org.locationtech.jts.io.WKTReader;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
@Service
public class SportEventService {
private final SportEventRepository repo;
public SportEventService(SportEventRepository repository) {
this.repo = repository;
}
public void ensureTestData() {
if (repo.count() == 0) {
GeometryFactory factory = new GeometryFactory();
WKTReader wktReader = new WKTReader(new GeometryFactory(new PrecisionModel(), 4326));
try {
SportEvent theEvent = new SportEvent();
theEvent.setTitle("Lutakon kierros");
theEvent.setDate(LocalDate.now());
theEvent.setLocation((Point) wktReader.read("POINT (25.77554278820253 62.2272018311204)"));
theEvent.setRoute((LineString) wktReader.read("LINESTRING (25.77554278820253 62.2272018311204, 25.779524086129754 62.22702791012401, 25.787113435301052 62.22627424088853, 25.800301484682706 62.22586841119525, 25.802540964765228 62.2265061411197, 25.813587285546873 62.23417854401356, 25.816200012311214 62.23904679898311, 25.816448843431488 62.240843218395554, 25.80587352081554 62.24101705975687, 25.79380521148309 62.24345073358492, 25.788330926834163 62.24374044357751, 25.779497422061553 62.248201625968164, 25.77277898180992 62.251503634515785, 25.76394547703589 62.249070610653376, 25.754987556701792 62.244319855211785, 25.753743401100365 62.24240775456448, 25.749388856494107 62.23927860104487, 25.760710672470935 62.23632299124034, 25.76842443720369 62.231686157027355, 25.771270557328904 62.229011191767825, 25.77500302413449 62.227387987489095)"));
repo.save(theEvent);
SportEvent eventWithPath = new SportEvent();
eventWithPath.setRoute((LineString) wktReader.read("LINESTRING (22.69504539358053 60.41742475722279, 22.697454647646992 60.41690574465318, 22.698768786227845 60.41649485382865, 22.700389557144803 60.41675436442708, 22.701046626435698 60.41671111280451, 22.702273155778926 60.415456790730445, 22.70354348974095 60.41446194917475, 22.70823058401524 60.41346707719282, 22.708537216350322 60.41281823124683, 22.710026573409664 60.41203959902907, 22.711121688894224 60.41048227868464, 22.71291767828862 60.41095813447669, 22.71414420763176 60.41193145419163, 22.715589760071282 60.411888196155985, 22.71686009403328 60.412104485759016, 22.71830564647621 60.41266683200189, 22.717604772566403 60.413164284033, 22.715940197029653 60.41283985965799, 22.713881379918092 60.412147743511554, 22.708975262547455 60.41126094817949, 22.70849341173391 60.411087912125595, 22.708186779398915 60.41242891747186, 22.708756239451105 60.412904744791774, 22.710289401129273 60.41299125810218, 22.71195397666608 60.413553589012, 22.70595274380986 60.41454845834818, 22.70595274380986 60.41521889660916, 22.70463860522895 60.41640834984031, 22.703806317460618 60.41710037534173, 22.701440868013975 60.417338255707534, 22.70043336176809 60.41720850299629, 22.69949230755762 60.417445646906515, 22.698791433647784 60.41798627584606, 22.697915341259574 60.41869989228667, 22.696075547246295 60.41872151678288, 22.694410971709488 60.418462021879435, 22.694104339373496 60.4180727756424, 22.694104339373496 60.41764027436014, 22.695155650239172 60.417424021562056)"));
eventWithPath.setLocation((Point) wktReader.read("POINT (22.694516300414023 60.4174531804353)"));
eventWithPath.setDate(LocalDate.now());
eventWithPath.setTitle("MTB cup 1/10");
repo.save(eventWithPath);
} catch (ParseException e) {
throw new RuntimeException(e);
}
}
}
public void save(SportEvent sportevent) {
repo.save(sportevent);
}
public void delete(SportEvent event) {
repo.deleteById(event.getId());
}
public List filterByTitle(String value) {
return repo.findByTitleContainingIgnoreCase(value);
}
public List filterByBounds(Polygon bounds) {
return repo.findAllWithin(bounds);
}
}
================================================
FILE: src/main/resources/application.properties
================================================
#spring.datasource.url=jdbc:mysql://localhost/spatialdemo
#spring.datasource.username=root
#spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
#spring.jpa.properties.hibernate.dialect=org.hibernate.spatial.dialect.h2geodb.GeoDBDialect
#spring.jpa.properties.hibernate.dialect=org.hibernate.spatial.dialect.mysql.MySQL56InnoDBSpatialDialect
# basic log level for all messages
logging.level.org.hibernate=info
# SQL statements and parameters
logging.level.org.hibernate.SQL=debug
logging.level.org.hibernate.orm.jdbc.bind=trace
# Statistics and slow queries
logging.level.org.hibernate.stat=debug
logging.level.org.hibernate.SQL_SLOW=info
# 2nd Level Cache
logging.level.org.hibernate.cache=debug
================================================
FILE: src/main/resources/schema-geodb.sql
================================================
CREATE ALIAS InitGeoDB for "geodb.GeoDB.InitGeoDB";
CALL InitGeoDB();
================================================
FILE: src/test/java/org/vaadin/example/DatabaseTestContainerConfiguration.java
================================================
package org.vaadin.example;
import org.springframework.boot.devtools.restart.RestartScope;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
@TestConfiguration(proxyBeanMethods = false)
class DatabaseTestContainerConfiguration {
@Bean
@ServiceConnection
@RestartScope
PostgreSQLContainer postgis() {
PostgreSQLContainer> postgis = new PostgreSQLContainer<>(
DockerImageName.parse("postgis/postgis:16-3.4-alpine").asCompatibleSubstituteFor("postgres")
);
return postgis;
}
}
================================================
FILE: src/test/java/org/vaadin/example/SpatialSpringBootAppApplicationTests.java
================================================
package org.vaadin.example;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.junit4.SpringRunner;
@Import(DatabaseTestContainerConfiguration.class)
@SpringBootTest
class DemoApplicationTests {
@Test
void contextLoads() {
}
}
================================================
FILE: src/test/java/org/vaadin/example/TestApp.java
================================================
package org.vaadin.example;
import org.springframework.boot.SpringApplication;
public class TestApp {
public static void main(String[] args) {
SpringApplication.from(Application::main)
.with(DatabaseTestContainerConfiguration.class)
.run(args);
}
}