Files
PMTiles/app/src/PageArchive.tsx
Brandon Liu 26c857ff40 app: viewer improvements [#555] (#557)
* add line labels
* allow loading remote and local tilesets without clearing the current tileset
2025-04-22 14:33:33 +08:00

539 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 {
type Accessor,
For,
type Setter,
Show,
createEffect,
createMemo,
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: Accessor<Tileset> }) {
const tileset = createMemo(() => {
console.log("memo!");
const g = props.genericTileset();
if (g instanceof PMTilesTileset) {
return g 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);
}