mirror of
https://github.com/protomaps/PMTiles.git
synced 2026-02-04 02:41:09 +00:00
app: Rewrite of PMTiles viewer (pmtiles.io) [#49, #551, #555, #556] * Rewrite the app using SolidJS / Tailwind. * Make the map view mobile friendly. * Add an archive inspector for viewing the low level structure of PMTiles. * Add a tile inspector for viewing a single tile in isolation. * Support TileJSON. * Support raster archives.
535 lines
16 KiB
TypeScript
535 lines
16 KiB
TypeScript
/* @refresh reload */
|
|
import "maplibre-gl/dist/maplibre-gl.css";
|
|
import "./index.css";
|
|
import { SphericalMercator } from "@mapbox/sphericalmercator";
|
|
import { layers, namedFlavor } from "@protomaps/basemaps";
|
|
import {
|
|
AttributionControl,
|
|
type GeoJSONSource,
|
|
Map as MaplibreMap,
|
|
getRTLTextPluginStatus,
|
|
setRTLTextPlugin,
|
|
} from "maplibre-gl";
|
|
import { Compression, type Entry, tileIdToZxy, tileTypeExt } from "pmtiles";
|
|
import {
|
|
For,
|
|
type Setter,
|
|
Show,
|
|
createEffect,
|
|
createResource,
|
|
createSignal,
|
|
onMount,
|
|
} from "solid-js";
|
|
import { render } from "solid-js/web";
|
|
import { ExampleChooser, Frame } from "./Frame";
|
|
import { PMTilesTileset, type Tileset, tilesetFromString } from "./tileset";
|
|
import { createHash, formatBytes, parseHash, tileInspectUrl } from "./utils";
|
|
|
|
const compressionToString = (t: Compression) => {
|
|
if (t === Compression.Unknown) return "unknown";
|
|
if (t === Compression.None) return "none";
|
|
if (t === Compression.Gzip) return "gzip";
|
|
if (t === Compression.Brotli) return "brotli";
|
|
if (t === Compression.Zstd) return "zstd";
|
|
return "out of spec";
|
|
};
|
|
|
|
function MapView(props: {
|
|
entries: Entry[] | undefined;
|
|
hoveredTile?: number;
|
|
}) {
|
|
let mapContainer: HTMLDivElement | undefined;
|
|
|
|
const sp = new SphericalMercator();
|
|
let map: MaplibreMap;
|
|
|
|
createEffect(() => {
|
|
const features = [];
|
|
const featuresLines = [];
|
|
|
|
if (props.entries) {
|
|
for (const e of props.entries) {
|
|
if (e.runLength === 1) {
|
|
const [z, x, y] = tileIdToZxy(e.tileId);
|
|
const bbox = sp.bbox(x, y, z);
|
|
features.push({
|
|
type: "Feature" as const,
|
|
properties: {},
|
|
geometry: {
|
|
type: "Polygon" as const,
|
|
coordinates: [
|
|
[
|
|
[bbox[0], bbox[1]],
|
|
[bbox[2], bbox[1]],
|
|
[bbox[2], bbox[3]],
|
|
[bbox[0], bbox[3]],
|
|
[bbox[0], bbox[1]],
|
|
],
|
|
],
|
|
},
|
|
});
|
|
} else {
|
|
const coordinates = [];
|
|
for (let i = e.tileId; i < e.tileId + e.runLength; i++) {
|
|
const [z, x, y] = tileIdToZxy(i);
|
|
const bbox = sp.bbox(x, y, z);
|
|
const midX = (bbox[0] + bbox[2]) / 2;
|
|
const midY = (bbox[1] + bbox[3]) / 2;
|
|
coordinates.push([midX, midY]);
|
|
}
|
|
featuresLines.push({
|
|
type: "Feature" as const,
|
|
properties: {},
|
|
geometry: { type: "LineString" as const, coordinates: coordinates },
|
|
});
|
|
}
|
|
}
|
|
(map.getSource("archive") as GeoJSONSource).setData({
|
|
type: "FeatureCollection" as const,
|
|
features: features,
|
|
});
|
|
(map.getSource("runs") as GeoJSONSource).setData({
|
|
type: "FeatureCollection" as const,
|
|
features: featuresLines,
|
|
});
|
|
}
|
|
});
|
|
|
|
createEffect(() => {
|
|
if (props.hoveredTile) {
|
|
const [z, x, y] = tileIdToZxy(props.hoveredTile);
|
|
const bbox = sp.bbox(x, y, z);
|
|
(map.getSource("hoveredTile") as GeoJSONSource).setData({
|
|
type: "Polygon",
|
|
coordinates: [
|
|
[
|
|
[bbox[0], bbox[1]],
|
|
[bbox[2], bbox[1]],
|
|
[bbox[2], bbox[3]],
|
|
[bbox[0], bbox[3]],
|
|
[bbox[0], bbox[1]],
|
|
],
|
|
],
|
|
});
|
|
map.flyTo({
|
|
center: [(bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2],
|
|
zoom: Math.max(z - 4, 0),
|
|
});
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
if (!mapContainer) {
|
|
console.error("Could not mount map element");
|
|
return;
|
|
}
|
|
|
|
if (getRTLTextPluginStatus() === "unavailable") {
|
|
setRTLTextPlugin(
|
|
"https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js",
|
|
true,
|
|
);
|
|
}
|
|
|
|
let flavor = "white";
|
|
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
|
flavor = "black";
|
|
}
|
|
|
|
map = new MaplibreMap({
|
|
container: mapContainer,
|
|
attributionControl: false,
|
|
style: {
|
|
version: 8,
|
|
glyphs:
|
|
"https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
|
|
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${flavor}`,
|
|
sources: {
|
|
basemap: {
|
|
type: "vector",
|
|
tiles: [
|
|
"https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=1003762824b9687f",
|
|
],
|
|
attribution:
|
|
"© <a href='https://openstreetmap.org/copyright'>OpenStreetMap</a>",
|
|
maxzoom: 15,
|
|
},
|
|
archive: {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
buffer: 16,
|
|
tolerance: 0,
|
|
},
|
|
runs: {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
buffer: 16,
|
|
tolerance: 0,
|
|
},
|
|
hoveredTile: {
|
|
type: "geojson",
|
|
data: { type: "FeatureCollection", features: [] },
|
|
buffer: 16,
|
|
tolerance: 0,
|
|
},
|
|
},
|
|
layers: [
|
|
...layers("basemap", namedFlavor(flavor), { lang: "en" }),
|
|
{
|
|
id: "archive",
|
|
source: "archive",
|
|
type: "line",
|
|
paint: {
|
|
"line-color": "#3131DC",
|
|
"line-opacity": 0.8,
|
|
"line-width": 2,
|
|
},
|
|
},
|
|
{
|
|
id: "runs",
|
|
source: "runs",
|
|
type: "line",
|
|
paint: {
|
|
"line-color": "#ffffff",
|
|
"line-opacity": 0.3,
|
|
},
|
|
},
|
|
{
|
|
id: "hoveredTile",
|
|
source: "hoveredTile",
|
|
type: "fill",
|
|
paint: {
|
|
"fill-color": "white",
|
|
"fill-opacity": 0.3,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
map.addControl(new AttributionControl({ compact: false }), "bottom-right");
|
|
|
|
map.on("style.load", () => {
|
|
map.setProjection({
|
|
type: "globe",
|
|
});
|
|
map.resize();
|
|
});
|
|
});
|
|
|
|
return (
|
|
<div class="flex-1 flex flex-col">
|
|
<div ref={mapContainer} class="h-full flex-1" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DirectoryTable(props: {
|
|
entries: Entry[];
|
|
stateUrl: string | undefined;
|
|
tileContents?: number;
|
|
addressedTiles?: number;
|
|
totalEntries?: number;
|
|
setHoveredTile: Setter<number | undefined>;
|
|
setOpenedLeaf: Setter<number | undefined>;
|
|
}) {
|
|
const [idx, setIdx] = createSignal<number>(0);
|
|
|
|
const canNavigate = (targetIdx: number) => {
|
|
return targetIdx >= 0 && targetIdx < props.entries.length;
|
|
};
|
|
|
|
return (
|
|
<div class="flex-1 overflow-hidden">
|
|
<div class="app-well md:px-4 md:py-2 flex">
|
|
<span class="flex-1">
|
|
entries {idx()}-{idx() + 999} of {props.entries.length}
|
|
</span>
|
|
<button
|
|
classList={{
|
|
"mx-2": true,
|
|
underline: canNavigate(idx() - 1000),
|
|
"app-text-light": !canNavigate(idx() - 1000),
|
|
"cursor-pointer": true,
|
|
}}
|
|
type="button"
|
|
onClick={() => {
|
|
setIdx(idx() - 1000);
|
|
}}
|
|
disabled={!canNavigate(idx() - 1000)}
|
|
>
|
|
prev
|
|
</button>
|
|
<button
|
|
classList={{
|
|
"mx-2": true,
|
|
underline: canNavigate(idx() + 1000),
|
|
"app-text-light": !canNavigate(idx() + 1000),
|
|
"cursor-pointer": true,
|
|
}}
|
|
type="button"
|
|
onClick={() => {
|
|
setIdx(idx() + 1000);
|
|
}}
|
|
disabled={!canNavigate(idx() + 1000)}
|
|
>
|
|
next
|
|
</button>
|
|
</div>
|
|
<div class="h-full overflow-y-scroll">
|
|
<table class="h-full text-right table-auto border-separate border-spacing-1 w-full pr-4">
|
|
<thead>
|
|
<tr>
|
|
<th>tileID</th>
|
|
<th>z</th>
|
|
<th>x</th>
|
|
<th>y</th>
|
|
<th>offset</th>
|
|
<th>length</th>
|
|
<th>runlength</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<For each={props.entries.slice(idx(), idx() + 1000)}>
|
|
{(e) => (
|
|
<tr
|
|
class="hover:bg-purple"
|
|
onMouseMove={() => props.setHoveredTile(e.tileId)}
|
|
>
|
|
<td>{e.tileId}</td>
|
|
<td class="app-text-light">{tileIdToZxy(e.tileId)[0]}</td>
|
|
<td class="app-text-light">{tileIdToZxy(e.tileId)[1]}</td>
|
|
<td class="app-text-light">{tileIdToZxy(e.tileId)[2]}</td>
|
|
<td>
|
|
<Show
|
|
when={e.runLength === 0}
|
|
fallback={
|
|
<a
|
|
classList={{
|
|
underline: true,
|
|
}}
|
|
href={tileInspectUrl(
|
|
props.stateUrl,
|
|
tileIdToZxy(e.tileId),
|
|
)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{e.offset}
|
|
</a>
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
class="underline cursor-pointer"
|
|
onClick={() => props.setOpenedLeaf(e.tileId)}
|
|
>
|
|
{e.offset}
|
|
</button>
|
|
</Show>
|
|
</td>
|
|
<td>{e.length}</td>
|
|
<td>
|
|
<Show when={e.runLength === 0} fallback={e.runLength}>
|
|
0 (leaf)
|
|
</Show>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArchiveView(props: { genericTileset: Tileset }) {
|
|
const tileset = () => {
|
|
if (props.genericTileset instanceof PMTilesTileset) {
|
|
return props.genericTileset as PMTilesTileset;
|
|
}
|
|
alert("This isn't a PMTiles archive!");
|
|
throw "This isn't a PMTiles tileset";
|
|
};
|
|
|
|
const [header] = createResource(tileset(), async (t) => {
|
|
return await t.archive.getHeader();
|
|
});
|
|
|
|
const [rootEntries] = createResource(header, async (h) => {
|
|
return await tileset().archive.cache.getDirectory(
|
|
tileset().archive.source,
|
|
h.rootDirectoryOffset,
|
|
h.rootDirectoryLength,
|
|
h,
|
|
);
|
|
});
|
|
|
|
const [openedLeaf, setOpenedLeaf] = createSignal<number | undefined>();
|
|
const [hoveredTile, setHoveredTile] = createSignal<number | undefined>();
|
|
|
|
const [leafEntries] = createResource(openedLeaf, async (o) => {
|
|
const h = header();
|
|
const root = rootEntries();
|
|
|
|
if (!root) return;
|
|
if (!h) return;
|
|
|
|
const found = root.find((e) => e.tileId === o);
|
|
if (!found) return;
|
|
|
|
return await tileset().archive.cache.getDirectory(
|
|
tileset().archive.source,
|
|
h.leafDirectoryOffset + found.offset,
|
|
found.length,
|
|
h,
|
|
);
|
|
});
|
|
|
|
return (
|
|
<div class="flex-1 flex h-full w-full font-mono text-xs md:text-sm">
|
|
<div
|
|
classList={{
|
|
"w-1/3": leafEntries() !== undefined,
|
|
"w-1/2": leafEntries() === undefined,
|
|
flex: true,
|
|
"flex-col": true,
|
|
"h-full": true,
|
|
"flex-1": true,
|
|
}}
|
|
>
|
|
<Show when={header()}>
|
|
{(h) => (
|
|
<div class="flex-none overflow-x-scroll">
|
|
<table class="text-right table-auto border-separate border-spacing-1 p-4">
|
|
<thead>
|
|
<tr>
|
|
<th>Layout (bytes)</th>
|
|
<th>offset</th>
|
|
<th>length</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Root Dir</td>
|
|
<td>{h().rootDirectoryOffset}</td>
|
|
<td>{formatBytes(h().rootDirectoryLength)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Metadata</td>
|
|
<td>{h().jsonMetadataOffset}</td>
|
|
<td>{formatBytes(h().jsonMetadataLength)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Leaf Dirs</td>
|
|
<td>{h().leafDirectoryOffset}</td>
|
|
<td>{formatBytes(h().leafDirectoryLength || 0)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Tile Data</td>
|
|
<td>{h().tileDataOffset}</td>
|
|
<td>{formatBytes(h().tileDataLength || 0)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
|
|
<DirectoryTable
|
|
entries={rootEntries() || []}
|
|
stateUrl={props.genericTileset.getStateUrl()}
|
|
setHoveredTile={setHoveredTile}
|
|
setOpenedLeaf={setOpenedLeaf}
|
|
/>
|
|
</div>
|
|
<Show when={leafEntries()}>
|
|
{(l) => (
|
|
<div class="flex w-1/3 h-full flex-1 overflow-hidden">
|
|
<div class="w-full flex flex-1 overflow-hidden">
|
|
<DirectoryTable
|
|
entries={l()}
|
|
stateUrl={props.genericTileset.getStateUrl()}
|
|
setHoveredTile={setHoveredTile}
|
|
setOpenedLeaf={setOpenedLeaf}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<div
|
|
classList={{
|
|
flex: true,
|
|
"w-1/3": leafEntries() !== undefined,
|
|
"w-1/2": leafEntries() === undefined,
|
|
"flex-col": true,
|
|
}}
|
|
>
|
|
<Show when={header()}>
|
|
{(h) => (
|
|
<div class="p-2">
|
|
<div>clustered: {h().clustered ? "true" : "false"}</div>
|
|
<div>total addressed tiles: {h().numAddressedTiles}</div>
|
|
<div>total tile entries: {h().numTileEntries}</div>
|
|
<div>total contents: {h().numTileContents}</div>
|
|
<div>
|
|
internal compression:{" "}
|
|
{compressionToString(h().internalCompression)}
|
|
</div>
|
|
<div>
|
|
tile compression: {compressionToString(h().tileCompression)}
|
|
</div>
|
|
<div>tile type: {tileTypeExt(h().tileType)}</div>
|
|
<div>min zoom: {h().minZoom}</div>
|
|
<div>max zoom: {h().maxZoom}</div>
|
|
<div>center zoom: {h().centerZoom}</div>
|
|
<div>
|
|
bounds: {h().minLon} {h().minLat} {h().maxLon} {h().maxLat}
|
|
</div>
|
|
<div>
|
|
center: {h().centerLon} {h().centerLat}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Show>
|
|
<MapView
|
|
entries={leafEntries() || rootEntries()}
|
|
hoveredTile={hoveredTile()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PageArchive() {
|
|
const hash = parseHash(location.hash);
|
|
const [tileset, setTileset] = createSignal<Tileset | undefined>(
|
|
hash.url ? tilesetFromString(decodeURIComponent(hash.url)) : undefined,
|
|
);
|
|
|
|
createEffect(() => {
|
|
const t = tileset();
|
|
const stateUrl = t?.getStateUrl();
|
|
location.hash = createHash(location.hash, {
|
|
url: stateUrl ? encodeURIComponent(stateUrl) : undefined,
|
|
});
|
|
});
|
|
|
|
return (
|
|
<Frame tileset={tileset} setTileset={setTileset} page="archive" pmtilesOnly>
|
|
<Show
|
|
when={tileset()}
|
|
fallback={<ExampleChooser setTileset={setTileset} />}
|
|
>
|
|
{(t) => <ArchiveView genericTileset={t()} />}
|
|
</Show>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
const root = document.getElementById("root");
|
|
|
|
if (root) {
|
|
render(() => <PageArchive />, root);
|
|
}
|