Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
- New community module `hakunapi-tiles-source-gpkg`: a `MapTileSource` and a `VectorTileSource` serving the tiles stored in a GeoPackage tile pyramid, read through `sqlite-jdbc`. Not part of the reference webapp.
- New community module `hakunapi-tiles-source-pmtiles`: a `MapTileSource` and a `VectorTileSource` serving the tiles of a PMTiles v3 archive, read in place from a local file or over HTTP range requests. Not part of the reference webapp.
- New community module `hakunapi-tiles-generator`: the seed tool `TilesGenerator` writes a vector tile pyramid into a GeoPackage (`hakunapi-tiles-source-gpkg` can serve it), and `PMTilesConvert` converts such a pyramid into a PMTiles v3 archive. Not part of the reference webapp.
- HTML items and item pages: the Leaflet map shows a background tile layer only for CRSs an open tile service fits. The default OpenStreetMap layer is used for geographic CRSs and EPSG:3857 (which is now plotted correctly); for other CRSs, such as EPSG:3067 or ETRS-GKnn, only the feature geometries are drawn unless `formats.html.<srid>.tileUrl` is configured.
- `collectionInfo.ftl` shows the collection id alongside its title and
description, and links to the collection's schema. `collections.ftl` lists
entries as `collection_id (Title)`.
Expand Down
9 changes: 7 additions & 2 deletions docker/cfg/addresses.properties
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ formats.geojson.type=json
# formats.geojson.forceLonLat=true
formats.html.type=html
# HTML tile layer configuration per SRID
formats.html.4258.tileUrl=https://avoin-karttakuva.maanmittauslaitos.fi/avoin/wmts/1.0.0/taustakartta/default/WGS84_Pseudo-Mercator/{z}/{y}/{x}.png?api-key=7cd2ddae-9f2e-481c-99d0-404e7bc7a0b2
formats.html.4258.tileAttribution=&copy; <a href="https://www.nls.fi">National Land Survey of Finland</a>
formats.html.3067.tileUrl=https://avoin-karttakuva.maanmittauslaitos.fi/avoin/wmts/1.0.0/taustakartta/default/ETRS-TM35FIN/{z}/{y}/{x}.png?api-key=7cd2ddae-9f2e-481c-99d0-404e7bc7a0b2
formats.html.3067.tileAttribution=&copy; <a href="https://www.nls.fi">National Land Survey of Finland</a>
formats.html.3067.tileOriginX=-548576
Expand All @@ -41,7 +43,7 @@ getfeatures.limit.default=10
getfeatures.limit.max=10000

# Configure known srids (hakunapi will always add 84 (lon, lat) and 4326 (lat, lon))
srid=3067,4258,3903,10774
srid=3067,4258,3903,10774,3879
# Let hakunapi know that EPSG:4258 is lat,lon order (+ degrees)
srid.4258.latLon=true
srid.4258.degrees=true
Expand All @@ -56,6 +58,9 @@ srid.3903.proj4=+proj=utm +zone=35 +ellps=GRS80 +units=m +vunits=m +no_defs +typ
srid.10774.latLon=false
srid.10774.geometryDimension=XYZ
srid.10774.proj4=+proj=utm +zone=35 +ellps=GRS80 +units=m +vunits=m +no_defs +type=crs
# EPSG:3879 ETRS-GK25 (north,east axis order); no open tile service, so the HTML map draws only the geometries
srid.3879.latLon=true
srid.3879.proj4=+proj=tmerc +lat_0=0 +lon_0=25 +k=1 +x_0=25500000 +y_0=0 +ellps=GRS80 +units=m +no_defs +type=crs

# JSON Schema for additional metadata (titles, descriptions, codelist URIs)
# schema names imply the language tag (overridable with schema.<name>.lang=)
Expand Down Expand Up @@ -129,7 +134,7 @@ collections.addresses.table=simple_addresses
collections.addresses.extent.spatial.crs84=15.0537852,58.6074565,33.9935374,70.2641566

### Supported Coordinate Reference Systems (CRS:84 is always and automatically included)
collections.addresses.srid=3067,4258,3903,10774
collections.addresses.srid=3067,4258,3903,10774,3879


### Required, mapping for primary id property
Expand Down
2 changes: 2 additions & 0 deletions docs/overview/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ This support is implemented by the `hakunapi-html` module and it can be enabled

The HTML content produced includes an interactive Leaflet map by default, with features visualized on a configurable tile layer and displayed in a data table. Maps work with any coordinate system configured via proj4 definitions and per-SRID tile settings, handling both geographic and projected CRS transparently. The HTML templates are customizable and can be extended or replaced for application-specific needs.

The map on the items and item pages is a demo feature. A background tile layer is shown only when an open tile service fits the CRS of the page: OpenStreetMap by default for geographic CRSs and EPSG:3857, or the service configured for the CRS with `formats.html.<srid>.tileUrl` (also `tileAttribution`, `tileOriginX`, `tileOriginY`; `formats.html.tileUrl` applies to all CRSs). For any other CRS, such as EPSG:3067 or ETRS-GKnn without configuration, only the feature geometries are drawn. For example, the National Land Survey of Finland services are configured per CRS: WGS84_Pseudo-Mercator tiles for geographic CRSs and EPSG:3857, ETRS-TM35FIN tiles (with the tile origin) for EPSG:3067, 3903 and 10774, see `docker/cfg/addresses.properties`. The NLS services require an API key.

### GeoPackage

The [GeoPackage (GPKG)](https://www.geopackage.org/) output format (the media type `application/geopackage+vnd.sqlite3`) is supported by the `hakunapi-gpgk` module. When needed it must be enabled using a configuration like `formats=geojson,gpgk`. The implementation supports outputting same feature, feature collection, property and geometry objects (Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon and GeometryCollection) as GeoJSON writer too.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<#-- Background map decision shared by featureCollection.ftl and feature.ftl.
Geographic CRSs and EPSG:3857 are drawn by Leaflet itself and get the configured tile layer (OpenStreetMap by default).
Other projected CRSs get a tile layer only when one is configured for them (OpenStreetMap does not serve them). -->
<#assign bgWebMercator = (srid == 3857)>
<#assign bgLeafletCrs = (sridCode?? && sridCode.degrees) || bgWebMercator>
<#assign bgProj4 = "">
<#if !bgLeafletCrs && sridCode?? && sridCode.proj4??><#assign bgProj4 = sridCode.proj4></#if>
<#assign bgProjected = bgProj4?has_content || bgWebMercator>
<#assign bgTileUrl = "">
<#if settings.tileUrl?? && (bgLeafletCrs || settings.tileUrl != "https://tile.openstreetmap.org/{z}/{x}/{y}.png")><#assign bgTileUrl = settings.tileUrl></#if>
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<#include "backgroundMap.ftl">
<!doctype html>
<html lang="${lang!'en'}">
<head>
Expand All @@ -9,7 +10,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA==" crossorigin=""></script>
<#if sridCode?? && sridCode.proj4??>
<#if bgProj4?has_content>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.7.5/dist/proj4.js" integrity="sha512-MMmVaQGDVI3Wouc5zT5G7k/snN9gPqquIhZsHgIIHVDlRgYTYGxrwu6w482iIhAq8n5R6+pcBgpGgxFFBz7rZA==" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/proj4leaflet@1.0.2/src/proj4leaflet.js" integrity="sha512-NA+0vegFw6AiYXT23H+hk43LxqwpJkkviK/vHTESeu+jUpvWssfQRWWqSR07aUhGSKtfpkdlE8YFrVaHsnb3jw==" crossorigin=""></script>
</#if>
Expand All @@ -18,14 +19,15 @@
</style>
</head>
<body>
<#assign crsUri = (srid == 84)?then("http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/" + srid?c)>
<main>
<div class="container py-4">
<nav class="nav justify-content-between" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a class="d-flex align-items-center text-dark text-decoration-none" href="../../../${langQuery}">Home</a></li>
<li class="breadcrumb-item"><a class="d-flex align-items-center text-dark text-decoration-none" href="../../../collections${langQuery}">Collections</a></li>
<li class="breadcrumb-item"><a class="d-flex align-items-center text-dark text-decoration-none" href="../../../collections/${featureType.name}${langQuery}">${featureType.name}</a></li>
<li class="breadcrumb-item"><a class="d-flex align-items-center text-dark text-decoration-none" href="../../../collections/${featureType.name}/items${langQuery}">Items</a></li>
<li class="breadcrumb-item"><a class="d-flex align-items-center text-dark text-decoration-none" href="../../../collections/${featureType.name}/items${langQuery}${langQuery?has_content?then('&', '?')}crs=${crsUri}">Items</a></li>
<li class="breadcrumb-item active" aria-current="page">${id}</li>
</ol>
<ul class="nav">
Expand Down Expand Up @@ -54,6 +56,13 @@
<h1 class="mb-0">${featureType.title!featureType.name} / ${id}</h1>
<small class="text-secondary">${featureType.name}</small>
<#if featureType.description??><p class="mt-2">${featureType.description}</p></#if>
<label for="crs">Switch CRS:</label>
<select name="crs" id="crs" onchange="var p = new URLSearchParams(window.location.search); p.set('crs', this.options[this.selectedIndex].value); window.location.search = p.toString()">
<#list featureType.geom.srid as availableSRID>
<#assign optionUri = (availableSRID == 84)?then("http://www.opengis.net/def/crs/OGC/1.3/CRS84", "http://www.opengis.net/def/crs/EPSG/0/" + availableSRID?c)>
<option value="${optionUri}"<#if availableSRID == srid> selected</#if>><#if availableSRID == 84>CRS84<#else>EPSG:${availableSRID?c}</#if></option>
</#list>
</select>
</header>

<div id="map" class="border pb-3 mb-3" style="height: 360px;"></div>
Expand Down Expand Up @@ -103,7 +112,7 @@ if (langSelect) {
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"></script>
<script>
<#if sridCode?? && sridCode.proj4??>
<#if bgProj4?has_content>
var crsConfig = {
resolutions: [
8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1, 0.5, 0.25
Expand All @@ -113,15 +122,21 @@ var crsConfig = {
crsConfig.origin = [${settings.tileOriginX}, ${settings.tileOriginY}];
</#if>
var crs = new L.Proj.CRS("EPSG:${srid?c}",
"${sridCode.proj4}",
"${bgProj4}",
crsConfig)
var map = L.map('map', { crs: crs });
<#else>
var map = L.map('map');
<#elseif bgWebMercator>
var crs = L.CRS.EPSG3857;
</#if>
L.tileLayer('${settings.tileUrl}', {
<#if sridCode?? && sridCode.proj4??>minZoom: 0, maxZoom: 15, </#if><#if settings.tileAttribution??>attribution: '${settings.tileAttribution}'</#if>
// Without a tile layer Leaflet has no max zoom, which breaks fitBounds for a single point
var map = L.map('map', { <#if bgProjected>crs: crs, </#if><#if !bgTileUrl?has_content>maxZoom: <#if bgProj4?has_content>15<#else>18</#if></#if> });
<#if bgTileUrl?has_content>
L.tileLayer('${bgTileUrl}', {
<#if bgProj4?has_content>minZoom: 0, maxZoom: 15, </#if><#if settings.tileAttribution??>attribution: '${settings.tileAttribution}'</#if>
}).addTo(map);
<#else>
document.getElementById('map').style.background = '#fff';
map.attributionControl.addAttribution('No background map for this coordinate system');
</#if>

<#if geometry??>
var data = {
Expand All @@ -130,7 +145,7 @@ var data = {
"geometry": ${geometry}
};
var layer = L.geoJSON(data, {
<#if sridCode?? && sridCode.proj4??>
<#if bgProjected>
coordsToLatLng: function(coords) {
var point = L.point(coords[0], coords[1]);
return crs.projection.unproject(point);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<#include "backgroundMap.ftl">
<!doctype html>
<html lang="${lang!'en'}">
<head>
Expand All @@ -11,7 +12,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js" integrity="sha512-XQoYMqMTK8LvdxXYG3nZ448hOEQiglfqkJs1NOQV44cWnUrBc8PkAOcXy20w0vlaXaVUearIOBhiXZ5V3ynxwA==" crossorigin=""></script>
<#if sridCode?? && sridCode.proj4??>
<#if bgProj4?has_content>
<script src="https://cdn.jsdelivr.net/npm/proj4@2.7.5/dist/proj4.js" integrity="sha512-MMmVaQGDVI3Wouc5zT5G7k/snN9gPqquIhZsHgIIHVDlRgYTYGxrwu6w482iIhAq8n5R6+pcBgpGgxFFBz7rZA==" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/proj4leaflet@1.0.2/src/proj4leaflet.js" integrity="sha512-NA+0vegFw6AiYXT23H+hk43LxqwpJkkviK/vHTESeu+jUpvWssfQRWWqSR07aUhGSKtfpkdlE8YFrVaHsnb3jw==" crossorigin=""></script>
</#if>
Expand Down Expand Up @@ -54,7 +55,7 @@
<small class="text-secondary">${featureType.name}</small>
<#if featureType.description??><p class="mt-2">${featureType.description}</p></#if>
<label for="crs">Switch CRS:</label>
<select name="crs" id="crs" onchange="window.location.search = 'crs=' + this.options[this.selectedIndex].value">
<select name="crs" id="crs" onchange="var p = new URLSearchParams(window.location.search); p.set('crs', this.options[this.selectedIndex].value); window.location.search = p.toString()">
<#list featureType.geom.srid as availableSRID>
<#if availableSRID == 84>
<#if srid == 84>
Expand Down Expand Up @@ -154,7 +155,7 @@ if (langSelect) {
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/js/bootstrap.bundle.min.js" integrity="sha384-JEW9xMcG8R+pH31jmWH6WWP0WintQrMb4s7ZOdauHnUtxwoG2vI5DkLtS3qm9Ekf" crossorigin="anonymous"></script>
<script>
<#if sridCode?? && sridCode.proj4??>
<#if bgProj4?has_content>
var crsConfig = {
resolutions: [
8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1, 0.5, 0.25
Expand All @@ -164,15 +165,21 @@ var crsConfig = {
crsConfig.origin = [${settings.tileOriginX}, ${settings.tileOriginY}];
</#if>
var crs = new L.Proj.CRS("EPSG:${srid?c}",
"${sridCode.proj4}",
"${bgProj4}",
crsConfig)
var map = L.map('map', { crs: crs });
<#else>
var map = L.map('map');
<#elseif bgWebMercator>
var crs = L.CRS.EPSG3857;
</#if>
L.tileLayer('${settings.tileUrl}', {
<#if sridCode?? && sridCode.proj4??>minZoom: 0, maxZoom: 15, </#if><#if settings.tileAttribution??>attribution: '${settings.tileAttribution}'</#if>
// Without a tile layer Leaflet has no max zoom, which breaks fitBounds for a single point
var map = L.map('map', { <#if bgProjected>crs: crs, </#if><#if !bgTileUrl?has_content>maxZoom: <#if bgProj4?has_content>15<#else>18</#if></#if> });
<#if bgTileUrl?has_content>
L.tileLayer('${bgTileUrl}', {
<#if bgProj4?has_content>minZoom: 0, maxZoom: 15, </#if><#if settings.tileAttribution??>attribution: '${settings.tileAttribution}'</#if>
}).addTo(map);
<#else>
document.getElementById('map').style.background = '#fff';
map.attributionControl.addAttribution('No background map for this coordinate system');
</#if>

var data = [
<#list features as feature>
Expand Down Expand Up @@ -205,7 +212,7 @@ const singleFeatureLinkQuery = singleFeatureLinkParams.size === 0 ? "" : "?" + s
data.forEach(f => document.getElementById("feature-link-" + f.id).href = "items/" + f.id + singleFeatureLinkQuery);

var layer = L.geoJSON(data, {
<#if sridCode?? && sridCode.proj4??>
<#if bgProjected>
coordsToLatLng: function(coords) {
var point = L.point(coords[0], coords[1]);
return crs.projection.unproject(point);
Expand All @@ -217,10 +224,13 @@ var layer = L.geoJSON(data, {
}).addTo(map);

const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('bbox')) {
const bboxCrs = urlParams.get('bbox-crs');
// The bbox can only frame the map when it is given in the CRS the map is drawn in; otherwise (for example after Switch CRS) frame the returned features
const bboxFitsMap = urlParams.has('bbox') && <#if bgProjected>bboxCrs === 'http://www.opengis.net/def/crs/EPSG/0/${srid?c}'<#else>(!bboxCrs || bboxCrs === 'http://www.opengis.net/def/crs/OGC/1.3/CRS84')</#if>;
if (bboxFitsMap) {
const bbox = urlParams.get('bbox');
const parts = bbox.split(",").map(parseFloat);
<#if sridCode?? && sridCode.proj4??>
<#if bgProjected>
<#if sridCode.latLon>
const x0 = parts[1], y0 = parts[0], x1 = parts[3], y1 = parts[2];
<#else>
Expand Down Expand Up @@ -250,7 +260,7 @@ if (urlParams.has('bbox')) {

function bboxToMapBounds() {
const urlParams = new URLSearchParams(window.location.search);
<#if sridCode?? && sridCode.proj4??>
<#if bgProjected>
const bounds = map.getBounds();
const topL = crs.projection.project(bounds.getNorthWest());
const botR = crs.projection.project(bounds.getSouthEast());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package fi.nls.hakunapi.simple.webapp.jakarta.features;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import java.io.File;

import jakarta.servlet.ServletContextEvent;
import jakarta.ws.rs.core.Application;

import org.glassfish.jersey.test.JerseyTest;
import org.junit.Test;

import fi.nls.hakunapi.simple.webapp.jakarta.HakunaContextListener;
import fi.nls.hakunapi.simple.webapp.jakarta.HakunaTestServletContext;
import fi.nls.hakunapi.simple.webapp.jakarta.SimpleFeaturesApplication;

public class HtmlBackgroundMapTest extends JerseyTest {

private static final String OSM = "L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png'";
private static final String CRS84 = "http://www.opengis.net/def/crs/OGC/1.3/CRS84";
private static final String CRS_PREFIX = "http://www.opengis.net/def/crs/EPSG/0/";

@Override
protected Application configure() {
File file = new File(getClass().getClassLoader().getResource("hakuna_html_map/hakuna.properties").getFile());
System.setProperty("hakuna.config.path", file.getParentFile().getAbsolutePath() + "/");

HakunaTestServletContext sc = new HakunaTestServletContext();
new HakunaContextListener().contextInitialized(new ServletContextEvent(sc));
return new SimpleFeaturesApplication(sc);
}

@Test
public void testItemsPageBackgroundMaps() {
// Web mercator: default OpenStreetMap
String mercator = items("3857");
assertTrue(mercator.contains(OSM));
assertTrue(mercator.contains("var crs = L.CRS.EPSG3857;"));

// Configured service for the CRS replaces the default
String geographic = items("84");
assertTrue(geographic.contains("L.tileLayer('https://example.org/WGS84_Pseudo-Mercator/{z}/{y}/{x}.png'"));
assertFalse(geographic.contains(OSM));

// TM35FIN without a configured service: OpenStreetMap does not serve it, geometries only
String tm35 = items("3067");
assertFalse(tm35.contains("L.tileLayer"));
assertTrue(tm35.contains("L.Proj.CRS(\"EPSG:3067\""));
assertTrue(tm35.contains("No background map for this coordinate system"));
}

@Test
public void testItemPageBackgroundMaps() {
assertTrue(item("3857").contains(OSM));
assertFalse(item("3067").contains("L.tileLayer"));
}

private String items(String srid) {
return html("/collections/fence/items", srid);
}

private String item(String srid) {
return html("/collections/fence/items/1", srid);
}

private String html(String path, String srid) {
jakarta.ws.rs.core.Response r = target(path)
.queryParam("f", "html")
.queryParam("crs", "84".equals(srid) ? CRS84 : CRS_PREFIX + srid)
.request().get();
assertEquals(200, r.getStatus());
return r.readEntity(String.class);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ public void testBreadcrumbsAndLinksCarryLang() {

String feature = html("/collections/fence/items/1", "fi");
assertTrue(feature, feature.contains("?lang=fi\">Home</a>"));
assertTrue(feature, feature.contains("/items?lang=fi\">Items</a>"));
assertTrue(feature, feature.contains("/items?lang=fi&crs=http://www.opengis.net/def/crs/OGC/1.3/CRS84\">Items</a>"));
}

/* Localized content */
Expand Down
Loading
Loading