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 @@ -21,6 +21,7 @@
- A service no longer needs a `collections` property: with none, it starts as a collectionless service, serving the common conformance classes and whatever API extensions publish.
- New community module `hakunapi-tiles-source-vectortile`: a `VectorTileSource` that renders Mapbox Vector Tiles in-JVM from the configured feature collections, one layer per collection, with no new third-party dependencies. Not part of the reference webapp.
- 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.

## 2.0.0

Expand Down
2 changes: 2 additions & 0 deletions docs/overview/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ Key differences between PostGIS and GeoPackage support in Hakunapi:

[`hakunapi-tiles-source-gpkg`](../../src-community/hakunapi-tiles-source-gpkg/README.md) *(community module)* is a `MapTileSource` and a `VectorTileSource` serving the tiles stored in a GeoPackage tile pyramid, read through `sqlite-jdbc`. It is found with `ServiceLoader`, so it is enabled by being on the classpath, and is not bundled in the reference webapp. It depends on `hakunapi-tiles-core`, `slf4j-api` and `sqlite-jdbc` (already used by `hakunapi-gpkg`).

[`hakunapi-tiles-source-pmtiles`](../../src-community/hakunapi-tiles-source-pmtiles/README.md) *(community module)* is 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. It is found with `ServiceLoader`, so it is enabled by being on the classpath, and is not bundled in the reference webapp. It depends on `hakunapi-tiles-core` and `slf4j-api`; it adds no new third-party dependency.

## Output data formats (stable)

### GeoJSON
Expand Down
5 changes: 5 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@
<artifactId>hakunapi-tiles-source-gpkg</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-tiles-source-pmtiles</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-config-test</artifactId>
Expand Down
21 changes: 21 additions & 0 deletions src-community/hakunapi-tiles-source-pmtiles/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Janne Heikkilä

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
79 changes: 79 additions & 0 deletions src-community/hakunapi-tiles-source-pmtiles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# hakunapi-tiles-source-pmtiles

Community module serving tiles from a
[PMTiles v3](https://github.com/protomaps/PMTiles) archive as OGC API - Tiles
map tiles, under `/collections/{collectionId}/map/tiles`.

PMTiles is a single-file tile archive addressed by byte range, so an archive is
read in place — from a local file, or from a remote HTTPS URL using HTTP range
requests, with no download or extraction step. The root directory is parsed at
startup and leaf directories are cached (LRU, 64 entries); a tile read is then a
range read plus a relay of the stored bytes.

## Configuration

The source registers as type `pmtiles` for raster archives (PNG, JPEG, WebP,
AVIF) and as type `pmtiles-vector` for Mapbox Vector Tile archives. An archive
of the other kind is rejected at startup. Give either a local `path` or a remote
`url`.

```
tiles.layers=basemap
tiles.layers.basemap.type=pmtiles
tiles.layers.basemap.title=Basemap
tiles.layers.basemap.tileMatrixSets=WebMercatorQuad

tiles.layers.basemap.pmtiles.path=/data/basemap.pmtiles
```

Remote archive:

```
tiles.layers.basemap.pmtiles.url=https://example.org/basemap.pmtiles
```

| key | meaning |
| --- | --- |
| `pmtiles.path` | local archive file |
| `pmtiles.url` | remote archive over HTTPS, read with range requests |
| `pmtiles.format` | media type advertised; taken from the archive header when unset |
| `pmtiles.sendfile` | `pmtiles-vector` only; `true` has the container send tile ranges from the archive file; needs `pmtiles.path` |

`pmtiles.sendfile` builds an in-memory index of tile locations at startup.

PMTiles addresses tiles by XYZ zoom level, so every tile matrix set the layer is
served in must use numeric tile matrix ids. `WebMercatorQuad` does.

### Compression

Tile bytes are relayed exactly as stored. When the archive's `tile_compression`
is gzip, brotli or zstd, the tiles are served with `Content-Encoding: gzip`,
`br` or `zstd` rather than being decompressed and re-compressed. An archive
without compression is served without the header.

The archive's own directory and metadata must be stored uncompressed or gzipped
— brotli and zstd `internal_compression` are rejected at startup, since
decompressing them would mean a dependency.

## Usage

```xml
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-tiles-source-pmtiles</artifactId>
</dependency>
```

The source is discovered at runtime via `ServiceLoader`, so it is enough for the
module to be on the classpath. Serving the tiles over HTTP additionally needs
`hakunapi-tiles-servlet-jakarta`.

## Status and support

This is a community module under `src-community/`. It is built and
(automatically) tested together with the Hakunapi core modules, but it is not
covered by the support the National Land Survey of Finland provides for core
modules.

See [SUBMITTERS](SUBMITTERS) for authors and
[CONTRIBUTING.md](../../CONTRIBUTING.md) for how to contribute.
3 changes: 3 additions & 0 deletions src-community/hakunapi-tiles-source-pmtiles/SUBMITTERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Submitted and maintained by:

jampukka
24 changes: 24 additions & 0 deletions src-community/hakunapi-tiles-source-pmtiles/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>src-community</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>hakunapi-tiles-source-pmtiles</artifactId>
<dependencies>
<dependency>
<groupId>fi.nls.hakunapi</groupId>
<artifactId>hakunapi-tiles-core</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package fi.nls.hakunapi.tiles.source.pmtiles;

import fi.nls.hakunapi.tiles.TileLocator;

public class BoxedTileIndex implements TileLocator {
private static final long EMPTY = 0L;

public static final double MIN_DENSITY = 0.5;

private final Zoom[] zooms;

private final long tileDataOffset;
private final byte tileCompression;
private final String mediaType;

private static final class Zoom {
final long xMin;
final long yMin;
final long width;
final long height;
final long[] locations;

Zoom(long xMin, long yMin, long width, long height) {
this.xMin = xMin;
this.yMin = yMin;
this.width = width;
this.height = height;
this.locations = new long[Math.toIntExact(width * height)];
}
}

private BoxedTileIndex(Zoom[] zooms, long tileDataOffset, byte tileCompression,
String mediaType) {
this.zooms = zooms;
this.tileDataOffset = tileDataOffset;
this.tileCompression = tileCompression;
this.mediaType = mediaType;
}

public static BoxedTileIndex of(TileIndex sorted) {
long[] xMin = new long[32];
long[] xMax = new long[32];
long[] yMin = new long[32];
long[] yMax = new long[32];
long[] counts = new long[32];
java.util.Arrays.fill(xMin, Long.MAX_VALUE);
java.util.Arrays.fill(yMin, Long.MAX_VALUE);
java.util.Arrays.fill(xMax, Long.MIN_VALUE);
java.util.Arrays.fill(yMax, Long.MIN_VALUE);

sorted.forEachEntry((tileId, packed) -> {
long[] zxy = TileId.toZXY(tileId);
int z = (int) zxy[0];
long x = zxy[1];
long y = zxy[2];
counts[z]++;
if (x < xMin[z]) xMin[z] = x;
if (x > xMax[z]) xMax[z] = x;
if (y < yMin[z]) yMin[z] = y;
if (y > yMax[z]) yMax[z] = y;
});

Zoom[] zooms = new Zoom[32];
for (int z = 0; z < 32; z++) {
if (counts[z] == 0) {
continue;
}
long w = xMax[z] - xMin[z] + 1;
long h = yMax[z] - yMin[z] + 1;
long slots = w * h;

if (slots > Integer.MAX_VALUE || (double) counts[z] / slots < MIN_DENSITY) {
return null;
}
zooms[z] = new Zoom(xMin[z], yMin[z], w, h);
}

sorted.forEachEntry((tileId, packed) -> {
long[] zxy = TileId.toZXY(tileId);
Zoom zoom = zooms[(int) zxy[0]];
zoom.locations[slot(zoom, zxy[1], zxy[2])] = packed;
});

return new BoxedTileIndex(zooms, sorted.tileDataOffset(), sorted.tileCompression(),
sorted.getMediaType());
}

private static int slot(Zoom zoom, long x, long y) {
return Math.toIntExact((y - zoom.yMin) * zoom.width + (x - zoom.xMin));
}

@Override
public long find(int z, long x, long y) {
if (z < 0 || z >= zooms.length) {
return NOT_FOUND;
}
Zoom zoom = zooms[z];
if (zoom == null) {
return NOT_FOUND;
}
long dx = x - zoom.xMin;
long dy = y - zoom.yMin;

if (Long.compareUnsigned(dx, zoom.width) >= 0
|| Long.compareUnsigned(dy, zoom.height) >= 0) {
return NOT_FOUND;
}
long packed = zoom.locations[(int) (dy * zoom.width + dx)];
if (packed == EMPTY) {
return NOT_FOUND;
}
return TileLocator.pack(tileDataOffset + TileLocator.offset(packed),
TileLocator.length(packed));
}

@Override
public String getContentEncoding() {
return Compression.contentEncoding(tileCompression);
}

@Override
public String getMediaType() {
return mediaType;
}

public long heapBytes() {
long bytes = 0;
for (Zoom zoom : zooms) {
if (zoom != null) {
bytes += (long) zoom.locations.length * Long.BYTES;
}
}
return bytes;
}

public String describe() {
StringBuilder sb = new StringBuilder();
for (int z = 0; z < zooms.length; z++) {
if (zooms[z] == null) {
continue;
}
if (sb.length() > 0) {
sb.append(", ");
}
sb.append('z').append(z).append(' ')
.append(zooms[z].width).append('x').append(zooms[z].height);
}
return sb.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package fi.nls.hakunapi.tiles.source.pmtiles;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.GZIPInputStream;

public final class Compression {
public static final byte UNKNOWN = 0;
public static final byte NONE = 1;
public static final byte GZIP = 2;
public static final byte BROTLI = 3;
public static final byte ZSTD = 4;

private static final int MAX_DECOMPRESSED = 64 << 20;

private Compression() {
}

public static byte[] decode(byte type, byte[] bytes) throws IOException {
switch (type) {
case NONE:
case UNKNOWN:
return bytes;
case GZIP:
return gunzip(bytes);
default:
throw new IOException("Unsupported PMTiles internal compression: " + type
+ " (only none/gzip supported for directories)");
}
}

public static String contentEncoding(byte type) {
switch (type) {
case GZIP:
return "gzip";
case BROTLI:
return "br";
case ZSTD:
return "zstd";
default:
return null;
}
}

private static byte[] gunzip(byte[] bytes) throws IOException {
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.min(bytes.length * 2L, 1 << 20));
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
if (out.size() + n > MAX_DECOMPRESSED) {
throw new IOException("PMTiles gzip data inflates past " + MAX_DECOMPRESSED + " bytes");
}
out.write(buf, 0, n);
}
return out.toByteArray();
}
}
}
Loading
Loading