kopia lustrzana https://github.com/onthegomap/planetiler
359 wiersze
12 KiB
Java
359 wiersze
12 KiB
Java
package com.onthegomap.planetiler.pmtiles;
|
|
|
|
import com.carrotsearch.hppc.ByteArrayList;
|
|
import com.carrotsearch.hppc.LongLongHashMap;
|
|
import com.onthegomap.planetiler.archive.TileArchiveMetadata;
|
|
import com.onthegomap.planetiler.archive.TileEncodingResult;
|
|
import com.onthegomap.planetiler.archive.WriteableTileArchive;
|
|
import com.onthegomap.planetiler.collection.Hppc;
|
|
import com.onthegomap.planetiler.config.PlanetilerConfig;
|
|
import com.onthegomap.planetiler.geo.GeoUtils;
|
|
import com.onthegomap.planetiler.geo.TileCoord;
|
|
import com.onthegomap.planetiler.geo.TileOrder;
|
|
import com.onthegomap.planetiler.util.FileUtils;
|
|
import com.onthegomap.planetiler.util.Format;
|
|
import com.onthegomap.planetiler.util.Gzip;
|
|
import com.onthegomap.planetiler.util.SeekableInMemoryByteChannel;
|
|
import java.io.IOException;
|
|
import java.io.UncheckedIOException;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.channels.FileChannel;
|
|
import java.nio.channels.SeekableByteChannel;
|
|
import java.nio.file.Path;
|
|
import java.nio.file.StandardOpenOption;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
import java.util.List;
|
|
import java.util.Objects;
|
|
import java.util.OptionalLong;
|
|
import java.util.TreeMap;
|
|
import java.util.function.LongSupplier;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
/**
|
|
* PMTiles is a single-file tile archive format designed for efficient access on cloud storage.
|
|
*
|
|
* @see <a href="https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md">PMTiles Specification</a>
|
|
*/
|
|
public final class WriteablePmtiles implements WriteableTileArchive {
|
|
|
|
static final int INIT_SECTION = 16384;
|
|
private static final Logger LOGGER = LoggerFactory.getLogger(WriteablePmtiles.class);
|
|
final LongLongHashMap hashToOffset = Hppc.newLongLongHashMap();
|
|
final ArrayList<Pmtiles.Entry> entries = new ArrayList<>();
|
|
private final SeekableByteChannel out;
|
|
private long currentOffset = 0;
|
|
private long numUnhashedTiles = 0;
|
|
private long numAddressedTiles = 0;
|
|
private boolean isClustered = true;
|
|
|
|
private final LongSupplier bytesWritten;
|
|
|
|
private WriteablePmtiles(SeekableByteChannel channel, LongSupplier bytesWritten) throws IOException {
|
|
this.out = channel;
|
|
out.write(ByteBuffer.allocate(INIT_SECTION));
|
|
this.bytesWritten = bytesWritten;
|
|
}
|
|
|
|
private static Directories makeDirectoriesWithLeaves(List<Pmtiles.Entry> subEntries, int leafSize, int attemptNum) {
|
|
LOGGER.info("Building directories with {} entries per leaf, attempt {}...", leafSize, attemptNum);
|
|
ArrayList<Pmtiles.Entry> rootEntries = new ArrayList<>();
|
|
ByteArrayList leavesOutputStream = new ByteArrayList();
|
|
int leavesLength = 0;
|
|
int numLeaves = 0;
|
|
|
|
for (int i = 0; i < subEntries.size(); i += leafSize) {
|
|
numLeaves++;
|
|
int end = i + leafSize;
|
|
if (i + leafSize > subEntries.size()) {
|
|
end = subEntries.size();
|
|
}
|
|
byte[] leafBytes = Pmtiles.directoryToBytes(subEntries, i, end);
|
|
leafBytes = Gzip.gzip(leafBytes);
|
|
rootEntries.add(new Pmtiles.Entry(subEntries.get(i).tileId(), leavesLength, leafBytes.length, 0));
|
|
leavesOutputStream.add(leafBytes);
|
|
leavesLength += leafBytes.length;
|
|
}
|
|
|
|
byte[] rootBytes = Pmtiles.directoryToBytes(rootEntries);
|
|
rootBytes = Gzip.gzip(rootBytes);
|
|
|
|
LOGGER.info("Built directories with {} leaves, {}B root directory", rootEntries.size(), rootBytes.length);
|
|
|
|
return new Directories(rootBytes, leavesOutputStream.toArray(), numLeaves, leafSize, attemptNum);
|
|
}
|
|
|
|
/**
|
|
* Serialize all entries into bytes, choosing the # of leaf directories to ensure the header+root fits in 16 KB.
|
|
*
|
|
* @param entries a sorted ObjectArrayList of all entries in the tileset.
|
|
* @return byte arrays of the root and all leaf directories, and the # of leaves.
|
|
*/
|
|
static Directories makeDirectories(List<Pmtiles.Entry> entries) {
|
|
int maxEntriesRootOnly = 16384;
|
|
int attemptNum = 1;
|
|
if (entries.size() < maxEntriesRootOnly) {
|
|
byte[] testBytes = Pmtiles.directoryToBytes(entries, 0, entries.size());
|
|
testBytes = Gzip.gzip(testBytes);
|
|
|
|
if (testBytes.length < INIT_SECTION - Pmtiles.HEADER_LEN) {
|
|
return new Directories(testBytes, new byte[0], 0, 0, attemptNum);
|
|
}
|
|
}
|
|
|
|
double estimatedLeafSize = entries.size() / 3_500d;
|
|
int leafSize = (int) Math.max(estimatedLeafSize, 4096);
|
|
|
|
while (true) {
|
|
Directories temp = makeDirectoriesWithLeaves(entries, leafSize, attemptNum++);
|
|
if (temp.root.length < INIT_SECTION - Pmtiles.HEADER_LEN) {
|
|
return temp;
|
|
}
|
|
leafSize *= 1.2;
|
|
}
|
|
}
|
|
|
|
public static WriteablePmtiles newWriteToFile(Path path) throws IOException {
|
|
return new WriteablePmtiles(
|
|
FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE),
|
|
() -> FileUtils.size(path)
|
|
);
|
|
}
|
|
|
|
public static WriteablePmtiles newWriteToMemory(SeekableInMemoryByteChannel bytes) throws IOException {
|
|
return new WriteablePmtiles(bytes, () -> 0);
|
|
}
|
|
|
|
@Override
|
|
public boolean deduplicates() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public TileOrder tileOrder() {
|
|
return TileOrder.HILBERT;
|
|
}
|
|
|
|
@Override
|
|
public void finish(TileArchiveMetadata tileArchiveMetadata) {
|
|
if (!isClustered) {
|
|
LOGGER.info("Tile data was not written in order, sorting entries...");
|
|
Collections.sort(entries);
|
|
LOGGER.info("Done sorting.");
|
|
}
|
|
try {
|
|
Directories directories = makeDirectories(entries);
|
|
// use treemap to ensure consistent ouput between runs
|
|
var otherMetadata = new TreeMap<>(tileArchiveMetadata.toMap());
|
|
|
|
// exclude keys included in top-level header
|
|
otherMetadata.remove(TileArchiveMetadata.CENTER_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.ZOOM_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.BOUNDS_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.FORMAT_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.MINZOOM_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.MAXZOOM_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.VECTOR_LAYERS_KEY);
|
|
otherMetadata.remove(TileArchiveMetadata.COMPRESSION_KEY);
|
|
|
|
byte[] jsonBytes =
|
|
new Pmtiles.JsonMetadata(tileArchiveMetadata.vectorLayers(), otherMetadata).toBytes();
|
|
jsonBytes = Gzip.gzip(jsonBytes);
|
|
|
|
String formatString = tileArchiveMetadata.format();
|
|
var outputFormat =
|
|
TileArchiveMetadata.MVT_FORMAT.equals(formatString) ? Pmtiles.TileType.MVT : Pmtiles.TileType.UNKNOWN;
|
|
|
|
var bounds = tileArchiveMetadata.bounds() == null ? GeoUtils.WORLD_LAT_LON_BOUNDS : tileArchiveMetadata.bounds();
|
|
var center = tileArchiveMetadata.center() == null ? bounds.centre() : tileArchiveMetadata.center();
|
|
int zoom = (int) Math.ceil(tileArchiveMetadata.zoom() == null ? GeoUtils.getZoomFromLonLatBounds(bounds) :
|
|
tileArchiveMetadata.zoom());
|
|
int minzoom = tileArchiveMetadata.minzoom() == null ? 0 : tileArchiveMetadata.minzoom();
|
|
int maxzoom =
|
|
tileArchiveMetadata.maxzoom() == null ? PlanetilerConfig.MAX_MAXZOOM : tileArchiveMetadata.maxzoom();
|
|
|
|
|
|
Pmtiles.Compression tileCompression = switch (tileArchiveMetadata.tileCompression()) {
|
|
case GZIP -> Pmtiles.Compression.GZIP;
|
|
case NONE -> Pmtiles.Compression.NONE;
|
|
default -> Pmtiles.Compression.UNKNOWN;
|
|
};
|
|
|
|
Pmtiles.Header header = new Pmtiles.Header(
|
|
(byte) 3,
|
|
Pmtiles.HEADER_LEN,
|
|
directories.root.length,
|
|
INIT_SECTION + currentOffset,
|
|
jsonBytes.length,
|
|
INIT_SECTION + currentOffset + jsonBytes.length,
|
|
directories.leaves.length,
|
|
INIT_SECTION,
|
|
currentOffset,
|
|
numAddressedTiles,
|
|
entries.size(),
|
|
hashToOffset.size() + numUnhashedTiles,
|
|
isClustered,
|
|
Pmtiles.Compression.GZIP,
|
|
tileCompression,
|
|
outputFormat,
|
|
(byte) minzoom,
|
|
(byte) maxzoom,
|
|
(int) (bounds.getMinX() * 10_000_000),
|
|
(int) (bounds.getMinY() * 10_000_000),
|
|
(int) (bounds.getMaxX() * 10_000_000),
|
|
(int) (bounds.getMaxY() * 10_000_000),
|
|
(byte) zoom,
|
|
(int) (center.x * 10_000_000),
|
|
(int) (center.y * 10_000_000)
|
|
);
|
|
|
|
LOGGER.info("Writing metadata and leaf directories...");
|
|
|
|
out.write(ByteBuffer.wrap(jsonBytes));
|
|
out.write(ByteBuffer.wrap(directories.leaves));
|
|
|
|
LOGGER.info("Writing header...");
|
|
out.position(0);
|
|
out.write(ByteBuffer.wrap(header.toBytes()));
|
|
out.write(ByteBuffer.wrap(directories.root));
|
|
|
|
Format format = Format.defaultInstance();
|
|
|
|
if (LOGGER.isInfoEnabled()) {
|
|
LOGGER.info("# addressed tiles: {}", numAddressedTiles);
|
|
LOGGER.info("# of tile entries: {}", entries.size());
|
|
LOGGER.info("# of tile contents: {}", (hashToOffset.size() + numUnhashedTiles));
|
|
LOGGER.info("Root directory: {}B", format.storage(directories.root.length, false));
|
|
|
|
LOGGER.info("# leaves: {}", directories.numLeaves);
|
|
if (directories.numLeaves > 0) {
|
|
LOGGER.info("Leaf directories: {}B", format.storage(directories.leaves.length, false));
|
|
LOGGER
|
|
.info("Avg leaf size: {}B", format.storage(directories.leaves.length / directories.numLeaves, false));
|
|
}
|
|
|
|
LOGGER
|
|
.info("Total dir bytes: {}B", format.storage(directories.root.length + directories.leaves.length, false));
|
|
double tot = (double) directories.root.length + directories.leaves.length;
|
|
LOGGER.info("Average bytes per addressed tile: {}", tot / numAddressedTiles);
|
|
}
|
|
} catch (IOException e) {
|
|
LOGGER.error(e.getMessage());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public long bytesWritten() {
|
|
return bytesWritten.getAsLong();
|
|
}
|
|
|
|
@Override
|
|
public void close() throws IOException {
|
|
out.close();
|
|
}
|
|
|
|
public WriteableTileArchive.TileWriter newTileWriter() {
|
|
return new DeduplicatingTileWriter();
|
|
}
|
|
|
|
public record Directories(byte[] root, byte[] leaves, int numLeaves, int leafSize, int numAttempts) {
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
return o instanceof Directories that &&
|
|
numLeaves == that.numLeaves &&
|
|
Arrays.equals(root, that.root) &&
|
|
Arrays.equals(leaves, that.leaves) &&
|
|
leafSize == that.leafSize &&
|
|
numAttempts == that.numAttempts;
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
int result = Objects.hash(numLeaves, leafSize, numAttempts);
|
|
result = 31 * result + Arrays.hashCode(root);
|
|
result = 31 * result + Arrays.hashCode(leaves);
|
|
return result;
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return "Directories{" +
|
|
"root=" + Arrays.toString(root) +
|
|
", leaves=" + Arrays.toString(leaves) +
|
|
", numLeaves=" + numLeaves +
|
|
", leafSize=" + leafSize +
|
|
", numAttempts=" + numAttempts +
|
|
'}';
|
|
}
|
|
}
|
|
|
|
private class DeduplicatingTileWriter implements TileWriter {
|
|
Pmtiles.Entry lastEntry = null;
|
|
|
|
@Override
|
|
public void write(TileEncodingResult encodingResult) {
|
|
numAddressedTiles++;
|
|
boolean writeTileData;
|
|
long offset;
|
|
OptionalLong tileDataHashOpt = encodingResult.tileDataHash();
|
|
var data = encodingResult.tileData();
|
|
if (data == null) {
|
|
return;
|
|
}
|
|
TileCoord coord = encodingResult.coord();
|
|
|
|
long tileId = coord.hilbertEncoded();
|
|
|
|
if (!entries.isEmpty()) {
|
|
if (tileId < lastEntry.tileId()) {
|
|
isClustered = false;
|
|
} else if (tileId == lastEntry.tileId()) {
|
|
LOGGER.error("Duplicate tile detected in writer");
|
|
}
|
|
}
|
|
|
|
if (tileDataHashOpt.isPresent()) {
|
|
long tileDataHash = tileDataHashOpt.getAsLong();
|
|
if (hashToOffset.containsKey(tileDataHash)) {
|
|
offset = hashToOffset.get(tileDataHash);
|
|
writeTileData = false;
|
|
if (lastEntry != null && lastEntry.tileId() + lastEntry.runLength() == tileId &&
|
|
lastEntry.offset() == offset) {
|
|
lastEntry.runLength++;
|
|
return;
|
|
}
|
|
} else {
|
|
hashToOffset.put(tileDataHash, currentOffset);
|
|
offset = currentOffset;
|
|
writeTileData = true;
|
|
}
|
|
} else {
|
|
numUnhashedTiles++;
|
|
offset = currentOffset;
|
|
writeTileData = true;
|
|
}
|
|
|
|
var newEntry = new Pmtiles.Entry(tileId, offset, data.length, 1);
|
|
entries.add(newEntry);
|
|
lastEntry = newEntry;
|
|
|
|
if (writeTileData) {
|
|
try {
|
|
out.write(ByteBuffer.wrap(data));
|
|
} catch (IOException e) {
|
|
throw new UncheckedIOException(e);
|
|
}
|
|
currentOffset += data.length;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void close() {
|
|
// no cleanup needed.
|
|
}
|
|
}
|
|
}
|