New viewer [#49] (#549)

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.
This commit is contained in:
Brandon Liu
2025-04-22 11:05:19 +08:00
committed by GitHub
parent c9b7f73f28
commit 731f03d325
34 changed files with 4786 additions and 4032 deletions

View File

@@ -20,7 +20,7 @@ jobs:
node-version: 18.x node-version: 18.x
- run: cd js && npm ci && npm run build - run: cd js && npm ci && npm run build
- run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env - run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env
- run: cd app && npm ci && ./node_modules/.bin/tsc && npm run prettier-check && ./node_modules/.bin/vite build - run: cd app && npm ci && npm run check && npm run build
- run: cd serverless/aws && npm ci && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist - run: cd serverless/aws && npm ci && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist
- run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm ci && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist - run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm ci && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist
- run: cd spec/v3 && cp *.pmtiles ../../app/dist - run: cd spec/v3 && cp *.pmtiles ../../app/dist

1
app/.gitignore vendored
View File

@@ -22,4 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.env

View File

@@ -1 +0,0 @@
{}

View File

@@ -1,4 +1,22 @@
# Install ## Usage
* First go to the `/js` directory in the root of the PMTiles repository and `npm install` ```bash
* `npm run dev` to start the app on port 3000 $ npm install
```
## Available Scripts
In the project directory, you can run:
### `npm run dev`
Runs the app in the development mode.<br>
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!

17
app/archive/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="theme-color" content="#3131DC"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMTiles archive inspector</title>
<link rel="preconnect" href="https://api.protomaps.com"/>
<link rel="preconnect" href="https://protomaps.github.io"/>
<link rel="preconnect" href="https://unpkg.com"/>
<link rel="preconnect" href="https://demo-bucket.protomaps.com"/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/PageArchive.tsx"></script>
</body>
</html>

View File

@@ -1,20 +0,0 @@
{
"javascript": {
"formatter": {
"trailingComma": "es5"
}
},
"formatter": {
"indentStyle": "space"
},
"linter": {
"rules": {
"style": {
"useNamingConvention": {}
},
"nursery": {
"noUnusedImports": {}
}
}
}
}

View File

@@ -1,13 +1,17 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="data:,"> <meta name="theme-color" content="#3131DC"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMTiles Viewer</title> <title>PMTiles viewer</title>
<link rel="preconnect" href="https://api.protomaps.com"/>
<link rel="preconnect" href="https://protomaps.github.io"/>
<link rel="preconnect" href="https://unpkg.com"/>
<link rel="preconnect" href="https://demo-bucket.protomaps.com"/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/MapView.tsx"></script> <script type="module" src="/src/PageMap.tsx"></script>
</body> </body>
</html> </html>

4505
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,52 +2,46 @@
"name": "pmtiles-app", "name": "pmtiles-app",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"tsc": "tsc --watch", "check": "biome check src --javascript-formatter-indent-style=space --json-formatter-indent-style=space",
"prettier": "prettier --write src/*", "format": "biome format --write src --javascript-formatter-indent-style=space --json-formatter-indent-style=space"
"prettier-check": "prettier --check src/*"
}, },
"dependencies": { "dependencies": {
"@mapbox/vector-tile": "^1.3.1", "@alenaksu/json-viewer": "^2.1.2",
"@radix-ui/react-dialog": "^0.1.7", "@mapbox/sphericalmercator": "^2.0.1",
"@radix-ui/react-icons": "^1.1.0", "@mapbox/vector-tile": "^2.0.3",
"@radix-ui/react-label": "^0.1.5", "@protomaps/basemaps": "5.2.0",
"@radix-ui/react-toolbar": "^0.1.5", "d3-axis": "^3.0.0",
"@stitches/react": "^1.2.8", "d3-path": "^3.1.0",
"@textea/json-viewer": "^2.11.2", "d3-scale": "^4.0.2",
"d3-path": "^3.0.1", "d3-scale-chromatic": "^3.1.0",
"d3-scale-chromatic": "^3.0.0", "d3-selection": "^3.0.0",
"fflate": "^0.7.3", "d3-zoom": "^3.0.0",
"maplibre-gl": "5.1.0", "maplibre-gl": "5.3.0",
"pbf": "^3.2.1", "pbf": "^4.0.1",
"protomaps-themes-base": "4.4.0", "pmtiles": "^4.3.0",
"react": "^18.0.0", "solid-js": "^1.9.5"
"react-dom": "^18.0.0",
"react-dropzone": "^14.1.1",
"react-svg-pan-zoom": "^3.11.0",
"react-use": "^17.4.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.5.3", "@biomejs/biome": "^1.9.4",
"@maplibre/maplibre-gl-style-spec": "^23.1.0", "@maplibre/maplibre-gl-style-spec": "^23.1.0",
"@types/d3-path": "^3.0.0", "@tailwindcss/vite": "^4.1.3",
"@types/d3-scale-chromatic": "^3.0.0", "@types/d3-axis": "^3.0.6",
"@types/leaflet": "^1.7.9", "@types/d3-path": "^3.1.1",
"@types/mapbox__vector-tile": "^1.3.0", "@types/d3-scale": "^4.0.9",
"@types/pako": "^1.0.3", "@types/d3-scale-chromatic": "^3.1.0",
"@types/pbf": "^3.0.2", "@types/d3-selection": "^3.0.11",
"@types/react": "^18.0.0", "@types/d3-transition": "^3.0.9",
"@types/react-dom": "^18.0.0", "@types/d3-zoom": "^3.0.8",
"@types/react-svg-pan-zoom": "^3.3.5", "@types/node": "^22.13.1",
"@vitejs/plugin-react": "^1.3.0", "jsdom": "^26.0.0",
"prettier": "^2.8.4", "tailwindcss": "^4.1.3",
"typescript": "^4.6.3", "typescript": "^5.7.2",
"vite": "^6.2.4" "vite": "^6.2.6",
}, "vite-plugin-solid": "^2.11.6"
"overrides": {
"react": "$react",
"react-dom": "$react-dom"
} }
} }

47
app/src/FeatureTable.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { For } from "solid-js";
export interface InspectableFeature {
layerName: string;
type: number;
id: number;
properties: { [key: string]: string | number | boolean };
}
const intToGeomType = (n: number) => {
if (n === 1) return "Point";
if (n === 2) return "LineString";
return "Polygon";
};
export const FeatureTable = (props: { features: InspectableFeature[] }) => {
return (
<div class="max-h-120 overflow-y-scroll divide-y app-divide">
<For each={props.features}>
{(f) => (
<div class="p-2">
<div>
{f.layerName} {intToGeomType(f.type)}
</div>
<div class="text-xs font-mono app-text-light">ID {f.id}</div>
<table class="font-mono table-auto border-separate border-spacing-x-2">
<tbody>
<For each={Object.entries(f.properties)}>
{([key, value]) => (
<tr>
<td class="text-right app-text-light">{key}</td>
<td>
{typeof value === "boolean"
? JSON.stringify(value)
: value.toString()}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
)}
</For>
</div>
);
};

259
app/src/Frame.tsx Normal file
View File

@@ -0,0 +1,259 @@
import {
type Accessor,
type JSX,
type Setter,
Show,
createEffect,
createMemo,
createSignal,
} from "solid-js";
import { type Tileset, tilesetFromFile, tilesetFromString } from "./tileset";
import { GIT_SHA } from "./utils";
export const ExampleChooser = (props: {
setTileset: Setter<Tileset | undefined>;
}) => {
const loadSample = (url: string) => {
props.setTileset(tilesetFromString(url));
};
const onChangeFileInput = (
event: Event & { currentTarget: HTMLInputElement },
) => {
const file = event.currentTarget.files?.[0];
if (file) {
props.setTileset(tilesetFromFile(file));
}
};
return (
<div class="h-full flex items-center justify-center p-4">
<div>
<div class="mb-2 app-text-light">Load an example:</div>
<div class="app-border divide-y">
<button
class="block p-2 flex text-left flex-col hover:bg-slate dark:hover:bg-purple w-full cursor-pointer"
type="button"
onClick={() => {
loadSample("https://demo-bucket.protomaps.com/v4.pmtiles");
}}
>
<div>v4.pmtiles</div>
<div class="text-xs app-text-light">
vector basemap, Protomaps daily build channel (OpenStreetMap data)
</div>
</button>
<button
class="block p-2 flex text-left flex-col hover:bg-slate dark:hover:bg-purple w-full cursor-pointer"
type="button"
onClick={() => {
loadSample("https://air.mtn.tw/flowers.pmtiles");
}}
>
<div>flowers.pmtiles</div>
<div class="text-xs app-text-light">
raster overlay, aerial orthomosaic
</div>
</button>
<button
class="block p-2 flex text-left flex-col hover:bg-slate dark:hover:bg-purple app-bg-hover w-full cursor-pointer"
type="button"
onClick={() => {
loadSample(
"https://r2-public.protomaps.com/protomaps-sample-datasets/tilezen.pmtiles",
);
}}
>
<div>tilezen.pmtiles</div>
<div class="text-xs app-text-light">
vector basemap, 2019 Mapzen Tiles (legacy)
</div>
</button>
</div>
<input
class="text-left mt-4 px-4 py-2 btn-primary cursor-pointer rounded w-full"
type="file"
onChange={onChangeFileInput}
/>
<div class="mt-2 app-text-light">Drag and drop a local file here</div>
</div>
</div>
);
};
function LinkTab(props: {
page: string;
tileset: Accessor<Tileset | undefined>;
selected: boolean;
}) {
const fragment = createMemo(() => {
const t = props.tileset();
if (t) {
const stateUrl = t.getStateUrl();
if (stateUrl) return `#url=${stateUrl}`;
}
return "";
});
return (
<a
classList={{
"font-bold": props.selected,
"py-2": true,
"px-4": true,
underline: !props.selected,
}}
href={`/${props.page === "map" ? "" : `${props.page}/`}${fragment()}`}
>
{props.page}
</a>
);
}
export function Frame(props: {
tileset: Accessor<Tileset | undefined>;
setTileset: Setter<Tileset | undefined>;
children: JSX.Element;
page: string;
pmtilesOnly?: boolean;
}) {
const [errorMessage, setErrorMessage] = createSignal<string | undefined>();
const [activeDrag, setActiveDrag] = createSignal<boolean>(false);
const setTilesetHandlingErrors = (url: string) => {
try {
props.setTileset(tilesetFromString(url));
} catch (e) {
if (e instanceof Error) {
setErrorMessage(e.message);
}
}
};
const loadTileset: JSX.EventHandler<HTMLFormElement, Event> = (event) => {
setErrorMessage(undefined);
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const urlValue = formData.get("url");
if (typeof urlValue === "string" && urlValue.length > 0) {
setTilesetHandlingErrors(urlValue);
}
};
createEffect(async () => {
const t = props.tileset();
if (t) {
try {
await t.test();
} catch (e) {
if (e instanceof Error) {
setErrorMessage(e.message);
}
}
}
});
const drop: JSX.EventHandler<HTMLDivElement, DragEvent> = (event) => {
event.preventDefault();
setActiveDrag(false);
if (event.dataTransfer) {
props.setTileset(tilesetFromFile(event.dataTransfer.files[0]));
}
};
const dragover: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
event.preventDefault();
setActiveDrag(true);
return false;
};
let pageTitle = "PMTiles viewer";
if (props.page === "archive") {
pageTitle = "PMTiles archive inspector";
} else if (props.page === "tile") {
pageTitle = "PMTiles tile inspector";
}
return (
<div
class="flex flex-col h-dvh w-full app-bg"
ondragover={dragover}
ondrop={drop}
>
<div class="flex-none flex items-center px-4 md:px-0 pt-4 md:pt-0 flex-col md:flex-row">
<div class="flex items-center flex-grow flex-1">
<h1 class="hidden md:inline text-xl mx-5">{pageTitle}</h1>
<form class="flex flex-1 items-center" onSubmit={loadTileset}>
<span class="relative flex flex-1 items-center app-border">
<input
class="px-2 flex-1"
type="text"
name="url"
placeholder={`${props.pmtilesOnly ? "" : "TileJSON or "}.pmtiles`}
value={props.tileset()?.getStateUrl() || ""}
/>
<Show when={props.tileset()}>
<button
type="button"
class="mr-2 text-sm px-2 btn-secondary cursor-pointer"
onClick={() => props.setTileset(undefined)}
>
clear
</button>
</Show>
</span>
<button class="px-4 ml-2 btn-primary cursor-pointer" type="submit">
load
</button>
<a
href="https://github.com/protomaps/PMTiles"
target="_blank"
rel="noreferrer"
class="hidden md:inline text-xs mx-4"
>
@{GIT_SHA}
</a>
</form>
</div>
<div class="flex">
<LinkTab
page="map"
selected={props.page === "map"}
tileset={props.tileset}
/>
<LinkTab
page="archive"
selected={props.page === "archive"}
tileset={props.tileset}
/>
<LinkTab
page="tile"
selected={props.page === "tile"}
tileset={props.tileset}
/>
</div>
</div>
<Show when={errorMessage()}>
<div class="bg-red-900 px-2 py-3 flex justify-between">
<span>{errorMessage()}</span>
<span>
<button type="button" onClick={() => setErrorMessage(undefined)}>
close
</button>
</span>
</div>
</Show>
<div
classList={{
"flex-1": true,
"overflow-auto": true,
"bg-gray-600": activeDrag(),
}}
>
{props.children}
</div>
</div>
);
}

View File

@@ -1,415 +0,0 @@
import { VectorTile } from "@mapbox/vector-tile";
import { path } from "d3-path";
import { schemeSet3 } from "d3-scale-chromatic";
import Protobuf from "pbf";
import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
import { UncontrolledReactSVGPanZoom } from "react-svg-pan-zoom";
import { useMeasure } from "react-use";
import {
Entry,
Header,
PMTiles,
TileType,
tileIdToZxy,
} from "../../js/src/index";
import { styled } from "./stitches.config";
const TableContainer = styled("div", {
height: "calc(100vh - $4 - $4)",
overflowY: "scroll",
width: "calc(100%/3)",
});
const SvgContainer = styled("div", {
width: "100%",
height: "calc(100vh - $4 - $4)",
});
const Table = styled("table", {
padding: "$2",
});
const Pane = styled("div", {
width: "calc(100%/3*2)",
});
const TableRow = styled(
"tr",
{
cursor: "pointer",
fontFamily: "monospace",
},
{ "&:hover": { color: "red" } }
);
const Split = styled("div", {
display: "flex",
});
const TileRow = (props: {
entry: Entry;
setSelectedEntry: (val: Entry | null) => void;
}) => {
const [z, x, y] = tileIdToZxy(props.entry.tileId);
return (
<TableRow
onClick={() => {
props.setSelectedEntry(props.entry);
}}
>
<td>{props.entry.tileId}</td>
<td>{z}</td>
<td>{x}</td>
<td>{y}</td>
<td>{props.entry.offset}</td>
<td>{props.entry.length}</td>
<td>
{props.entry.runLength === 0
? "directory"
: `tile(${props.entry.runLength})`}
</td>
</TableRow>
);
};
interface Layer {
name: string;
features: Feature[];
}
interface Feature {
layerName: string;
path: string;
type: number;
id: number;
properties: any;
}
const smartCompare = (a: Layer, b: Layer): number => {
if (a.name === "earth") return -4;
if (a.name === "water") return -3;
if (a.name === "natural") return -2;
if (a.name === "landuse") return -1;
if (a.name === "places") return 1;
return 0;
};
const FeatureSvg = (props: {
feature: Feature;
setSelectedFeature: Dispatch<SetStateAction<Feature | null>>;
}) => {
const [highlighted, setHighlighted] = useState(false);
let fill = "none";
let stroke = "";
if (props.feature.type === 3) {
fill = highlighted ? "white" : "currentColor";
} else {
stroke = highlighted ? "white" : "currentColor";
}
const mouseOver = () => {
setHighlighted(true);
};
const mouseOut = () => {
setHighlighted(false);
};
const mouseDown = () => {
props.setSelectedFeature(props.feature);
};
return (
<path
d={props.feature.path}
stroke={stroke}
strokeWidth={10}
fill={fill}
fillOpacity={0.5}
onMouseOver={mouseOver}
onMouseOut={mouseOut}
onMouseDown={mouseDown}
/>
);
};
const LayerSvg = (props: {
layer: Layer;
color: string;
setSelectedFeature: Dispatch<SetStateAction<Feature | null>>;
}) => {
const elems = props.layer.features.map((f, i) => (
<FeatureSvg
key={i}
feature={f}
setSelectedFeature={props.setSelectedFeature}
/>
));
return <g color={props.color}>{elems}</g>;
};
const StyledFeatureProperties = styled("div", {
position: "absolute",
right: 0,
bottom: 0,
backgroundColor: "$black",
padding: "$1",
});
const FeatureProperties = (props: { feature: Feature }) => {
const tmp: [string, string][] = [];
for (const key in props.feature.properties) {
tmp.push([key, props.feature.properties[key]]);
}
const rows = tmp.map((d, i) => (
<tr key={i}>
<td>{d[0]}</td>
<td>{typeof d[1] === "boolean" ? JSON.stringify(d[1]) : d[1]}</td>
</tr>
));
return (
<StyledFeatureProperties>
{props.feature.layerName}
<table>
<tbody>{rows}</tbody>
</table>
</StyledFeatureProperties>
);
};
const VectorPreview = (props: {
file: PMTiles;
entry: Entry;
tileType: TileType;
}) => {
const [layers, setLayers] = useState<Layer[]>([]);
const [maxExtent, setMaxExtent] = useState<number>(0);
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null);
const viewer = useRef<UncontrolledReactSVGPanZoom>(null);
const [ref, { width, height }] = useMeasure<HTMLDivElement>();
useEffect(() => {
viewer.current?.zoomOnViewerCenter(0.1);
}, []);
useEffect(() => {
const fn = async (entry: Entry) => {
const [z, x, y] = tileIdToZxy(entry.tileId);
const resp = await props.file.getZxy(z, x, y);
const tile = new VectorTile(new Protobuf(new Uint8Array(resp!.data)));
const newLayers = [];
let maxExtent = 0;
for (const [name, layer] of Object.entries(tile.layers)) {
if (layer.extent > maxExtent) {
maxExtent = layer.extent;
}
const features: Feature[] = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const p = path();
const geom = feature.loadGeometry();
if (feature.type === 1) {
for (const ring of geom) {
for (const pt of ring) {
p.arc(pt.x, pt.y, 20, 0, 2 * Math.PI);
}
}
} else {
for (const ring of geom) {
p.moveTo(ring[0].x, ring[0].y);
for (let j = 1; j < ring.length; j++) {
p.lineTo(ring[j].x, ring[j].y);
}
if (feature.type === 3) {
p.closePath();
}
}
}
features.push({
path: p.toString(),
type: feature.type,
id: feature.id,
properties: feature.properties,
layerName: name,
});
}
newLayers.push({ features: features, name: name });
}
setMaxExtent(maxExtent);
newLayers.sort(smartCompare);
setLayers(newLayers);
};
if (props.entry) {
fn(props.entry);
}
}, [props.entry]);
const elems = layers.map((l, i) => (
<LayerSvg
key={i}
layer={l}
color={schemeSet3[i % 12]}
setSelectedFeature={setSelectedFeature}
/>
));
return (
<SvgContainer ref={ref}>
<UncontrolledReactSVGPanZoom
ref={viewer}
width={width}
height={height}
detectAutoPan={false}
onClick={(event) =>
console.log("click", event.x, event.y, event.originalEvent)
}
>
<svg viewBox="0 0 4096 4096">{elems}</svg>
</UncontrolledReactSVGPanZoom>
{selectedFeature ? <FeatureProperties feature={selectedFeature} /> : null}
</SvgContainer>
);
};
const RasterPreview = (props: { file: PMTiles; entry: Entry }) => {
const [imgSrc, setImageSrc] = useState<string>("");
useEffect(() => {
const fn = async (entry: Entry) => {
// TODO 0,0,0 is broken
const [z, x, y] = tileIdToZxy(entry.tileId);
const resp = await props.file.getZxy(z, x, y);
const blob = new Blob([resp!.data]);
const imageUrl = window.URL.createObjectURL(blob);
setImageSrc(imageUrl);
};
if (props.entry) {
fn(props.entry);
}
}, [props.entry]);
return <img src={imgSrc} alt="raster tile" />;
};
function getHashString(entry: Entry) {
const [z, x, y] = tileIdToZxy(entry.tileId);
const hash = `${z}/${x}/${y}`;
const hashName = "inspector";
let found = false;
const parts = window.location.hash
.slice(1)
.split("&")
.map((part) => {
const key = part.split("=")[0];
if (key === hashName) {
found = true;
return `${key}=${hash}`;
}
return part;
})
.filter((a) => a);
if (!found) {
parts.push(`${hashName}=${hash}`);
}
return `#${parts.join("&")}`;
}
function Inspector(props: { file: PMTiles }) {
const [entryRows, setEntryRows] = useState<Entry[]>([]);
const [selectedEntry, setSelectedEntryRaw] = useState<Entry | null>(null);
const [header, setHeader] = useState<Header | null>(null);
function setSelectedEntry(val: Entry | null) {
if (val && val.runLength > 0) {
window.history.replaceState(window.history.state, "", getHashString(val));
}
setSelectedEntryRaw(val);
}
useEffect(() => {
const fn = async () => {
const header = await props.file.getHeader();
setHeader(header);
if (header.specVersion < 3) {
setEntryRows([]);
} else if (selectedEntry !== null && selectedEntry.runLength === 0) {
const entries = await props.file.cache.getDirectory(
props.file.source,
header.leafDirectoryOffset + selectedEntry.offset,
selectedEntry.length,
header
);
setEntryRows(entries);
} else if (selectedEntry === null) {
const entries = await props.file.cache.getDirectory(
props.file.source,
header.rootDirectoryOffset,
header.rootDirectoryLength,
header
);
setEntryRows(entries);
}
};
fn();
}, [props.file, selectedEntry]);
const rows = entryRows.map((e, i) => (
<TileRow key={i} entry={e} setSelectedEntry={setSelectedEntry} />
));
let tilePreview = <div />;
if (selectedEntry && header?.tileType) {
if (selectedEntry.runLength === 0) {
// do nothing
} else if (header.tileType === TileType.Mvt) {
tilePreview = (
<VectorPreview
file={props.file}
entry={selectedEntry}
tileType={header.tileType}
/>
);
} else {
tilePreview = <RasterPreview file={props.file} entry={selectedEntry} />;
}
}
let warning = "";
if (header && header.specVersion < 3) {
warning = "Directory listing supported only for PMTiles v3 archives.";
}
return (
<Split>
<TableContainer>
{warning}
<Table>
<thead>
<tr>
<th>tileid</th>
<th>z</th>
<th>x</th>
<th>y</th>
<th>offset</th>
<th>length</th>
<th>type</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</Table>
</TableContainer>
<Pane>{tilePreview}</Pane>
</Split>
);
}
export default Inspector;

137
app/src/LayersPanel.tsx Normal file
View File

@@ -0,0 +1,137 @@
import {
type Accessor,
For,
type Setter,
Show,
createEffect,
createMemo,
createSignal,
} from "solid-js";
import { colorForIdx } from "./utils";
export interface LayerVisibility {
id: string;
visible: boolean;
}
export function LayersPanel(props: {
layerVisibility: Accessor<LayerVisibility[]>;
setLayerVisibility: Setter<LayerVisibility[]>;
layerFeatureCounts?: Record<string, number>;
basemapOption?: boolean;
basemap?: Accessor<boolean>;
setBasemap?: Setter<boolean>;
}) {
let checkallRef: HTMLInputElement | undefined;
const [expanded, setExpanded] = createSignal<boolean>(true);
const onChange = (id: string) => {
const newLayerVisibility = props
.layerVisibility()
.map((l: LayerVisibility) =>
l.id === id ? { ...l, visible: !l.visible } : l,
);
props.setLayerVisibility(newLayerVisibility);
};
const allChecked = createMemo(() => {
const visibleLayersCount = props
.layerVisibility()
.filter((l: LayerVisibility) => l.visible).length;
return visibleLayersCount === props.layerVisibility().length;
});
const toggleAll = () => {
props.setLayerVisibility(
props
.layerVisibility()
.map((l: LayerVisibility) => ({ ...l, visible: !allChecked() })),
);
};
createEffect(() => {
const visibleLayersCount = props
.layerVisibility()
.filter((l) => l.visible).length;
const indeterminate =
visibleLayersCount > 0 &&
visibleLayersCount !== props.layerVisibility().length;
if (checkallRef) {
checkallRef.indeterminate = indeterminate;
}
});
return (
<div class="app-bg rounded app-border flex flex-col overflow-y-scroll max-h-100">
<button
type="button"
classList={{
"app-well": true,
"rounded-t": expanded(),
rounded: !expanded(),
"cursor-pointer": true,
"min-w-8": true,
}}
onClick={() => setExpanded(!expanded())}
>
{expanded() ? "-" : "+"}
</button>
<Show when={expanded()}>
<div class="px-2 md:px-4 pb-2 md:pb-4">
<Show when={props.basemapOption}>
<div>
<input
type="checkbox"
id="background"
checked={props.basemap?.()}
onChange={() => props.setBasemap?.(!props.basemap?.())}
/>
<label class="ml-2 text-sm" for="background">
Background
</label>
</div>
</Show>
<div>
<input
type="checkbox"
id="checkall"
ref={checkallRef}
checked={allChecked()}
onChange={toggleAll}
/>
<label class="ml-2 text-sm" for="checkall">
All Layers
</label>
</div>
<For each={props.layerVisibility()}>
{(l, i) => (
<div class="ml-2">
<input
type="checkbox"
id={`check_${l.id}`}
checked={l.visible}
onChange={() => onChange(l.id)}
/>
<label class="ml-2 text-sm" for={`check_${l.id}`}>
<span
class="inline-block mr-2 w-[10px] h-[10px]"
style={{ "background-color": colorForIdx(i()) }}
/>
{l.id}
<Show when={props.layerFeatureCounts}>
{(layerFeatureCounts) => (
<span class="ml-1">
({layerFeatureCounts()[l.id] || 0})
</span>
)}
</Show>
</label>
</div>
)}
</For>
</div>
</Show>
</div>
);
}

View File

@@ -1,114 +0,0 @@
import { useState } from "react";
import { PMTiles } from "../../js/src/index";
import { styled } from "./stitches.config";
import MaplibreMap from "./MaplibreMap";
import Metadata from "./Metadata";
import { MagnifyingGlassIcon } from "@radix-ui/react-icons";
import * as ToolbarPrimitive from "@radix-ui/react-toolbar";
const StyledToolbar = styled(ToolbarPrimitive.Root, {
display: "flex",
height: "$4",
width: "100%",
boxSizing: "border-box",
minWidth: "max-content",
backgroundColor: "white",
boxShadow: `0 2px 10px "black"`,
});
const itemStyles = {
all: "unset",
flex: "0 0 auto",
color: "$black",
display: "inline-flex",
padding: "0 $1 0 $1",
fontSize: "$2",
alignItems: "center",
"&:hover": { backgroundColor: "$hover", color: "$white" },
"&:focus": { position: "relative", boxShadow: "0 0 0 2px blue" },
};
const StyledLink = styled(
ToolbarPrimitive.Link,
{
...itemStyles,
backgroundColor: "transparent",
color: "black",
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
},
{
"&:hover": {
backgroundColor: "$hover",
color: "$white",
cursor: "pointer",
},
}
);
const StyledToggleGroup = styled(ToolbarPrimitive.ToggleGroup, {
display: "inline-flex",
borderRadius: 4,
});
const StyledToggleItem = styled(ToolbarPrimitive.ToggleItem, {
...itemStyles,
boxShadow: 0,
backgroundColor: "white",
marginLeft: 2,
cursor: "pointer",
"&:first-child": { marginLeft: 0 },
"&[data-state=on]": { backgroundColor: "$primary", color: "$primaryText" },
});
const Toolbar = StyledToolbar;
const ToolbarLink = StyledLink;
const ToolbarToggleGroup = StyledToggleGroup;
const ToolbarToggleItem = StyledToggleItem;
function Loader(props: { file: PMTiles; mapHashPassed: boolean }) {
const [tab, setTab] = useState("maplibre");
let view: any;
if (tab === "maplibre") {
view = (
<MaplibreMap file={props.file} mapHashPassed={props.mapHashPassed} />
);
} else {
view = <Metadata file={props.file} />;
}
return (
<>
<Toolbar aria-label="Formatting options">
<ToolbarToggleGroup
type="single"
defaultValue="center"
aria-label="Text alignment"
value={tab}
onValueChange={setTab}
>
<ToolbarToggleItem value="maplibre" aria-label="Right aligned">
Map View
</ToolbarToggleItem>
<ToolbarToggleItem value="metadata" aria-label="Left aligned">
Metadata
</ToolbarToggleItem>
</ToolbarToggleGroup>
<ToolbarLink href={`tileinspect/?url=${props.file.source.getKey()}`}>
🔎 Tile Inspector
</ToolbarLink>
<ToolbarLink css={{ marginRight: 10 }}>
{props.file.source.getKey()}
</ToolbarLink>
</Toolbar>
{view}
</>
);
}
export default Loader;

View File

@@ -1,12 +0,0 @@
import React from "react";
import reactDom from "react-dom/client";
import MapViewComponent from "./MapViewComponent";
const root = document.getElementById("root");
if (root) {
reactDom.createRoot(root).render(
<React.StrictMode>
<MapViewComponent />
</React.StrictMode>
);
}

View File

@@ -1,139 +0,0 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import React, { useState, useEffect } from "react";
import { PMTiles } from "../../js/src/index";
import { globalStyles, styled } from "./stitches.config";
import Loader from "./Loader";
import Start from "./Start";
const Header = styled("div", {
height: "$4",
display: "flex",
alignItems: "center",
padding: "0 $2 0 $2",
});
const Title = styled("a", {
fontWeight: 500,
color: "unset",
textDecoration: "none",
});
const GithubA = styled("a", {
color: "white",
textDecoration: "none",
fontSize: "$1",
});
const GithubLink = styled("span", {
marginLeft: "auto",
});
const StyledOverlay = styled(DialogPrimitive.Overlay, {
backgroundColor: "black",
position: "fixed",
inset: 0,
opacity: "40%",
zIndex: 3,
});
const StyledContent = styled(DialogPrimitive.Content, {
backgroundColor: "black",
color: "#ef4444",
padding: "$1",
borderRadius: 6,
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "90vw",
zIndex: 4,
"&:focus": { outline: "none" },
});
const GIT_SHA = (import.meta.env.VITE_GIT_SHA || "").substr(0, 8);
function MapViewComponent() {
globalStyles();
const [errorDisplay, setErrorDisplay] = useState<string | undefined>();
const [file, setFile] = useState<PMTiles | undefined>();
const [mapHashPassed, setMapHashPassed] = useState<boolean>(false);
// initial load
useEffect(() => {
const loadUrl = new URLSearchParams(location.search).get("url");
if (loadUrl) {
const initialValue = new PMTiles(loadUrl);
setFile(initialValue);
}
if (location.hash.includes("map")) {
setMapHashPassed(true);
}
}, []);
useEffect(() => {
if (file) {
file.getHeader().catch((e) => {
setErrorDisplay(e.message);
});
}
}, [file]);
// maintaining URL state
useEffect(() => {
const url = new URL(window.location.href);
if (file?.source.getKey().startsWith("http")) {
url.searchParams.set("url", file.source.getKey());
history.pushState(null, "", url.toString());
} else {
url.searchParams.delete("url");
history.pushState(null, "", url.toString());
}
}, [file]);
const clear = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setFile(undefined);
};
const closeModal = () => {
setErrorDisplay(undefined);
};
return (
<div>
<Header>
<Title href="/PMTiles/" onClick={clear}>
PMTiles Viewer
</Title>
<GithubLink>
<GithubA href="https://github.com/protomaps/PMTiles" target="_blank">
<GitHubLogoIcon /> {GIT_SHA}
</GithubA>
</GithubLink>
</Header>
{file ? (
<Loader file={file} mapHashPassed={mapHashPassed} />
) : (
<Start setFile={setFile} />
)}
<DialogPrimitive.Root open={errorDisplay !== undefined}>
<DialogPrimitive.Portal>
<StyledOverlay />
<StyledContent
onEscapeKeyDown={closeModal}
onPointerDownOutside={closeModal}
>
<div>{file ? file.source.getKey() : null}</div>
<div>{errorDisplay}</div>
<DialogPrimitive.Close />
</StyledContent>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
</div>
);
}
export default MapViewComponent;

View File

@@ -1,544 +0,0 @@
import {
LayerSpecification,
StyleSpecification,
} from "@maplibre/maplibre-gl-style-spec";
import { schemeSet3 } from "d3-scale-chromatic";
import maplibregl from "maplibre-gl";
import { MapGeoJSONFeature } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import baseTheme from "protomaps-themes-base";
import React, { useState, useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Protocol } from "../../js/src/adapters";
import { PMTiles, TileType } from "../../js/src/index";
import { styled } from "./stitches.config";
const BASEMAP_THEME = "black";
const INITIAL_ZOOM = 0;
const INITIAL_LNG = 0;
const INITIAL_LAT = 0;
const BASEMAP_URL =
"https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=1003762824b9687f";
const BASEMAP_ATTRIBUTION =
'Basemap <a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>';
maplibregl.setRTLTextPlugin(
"https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js",
true
);
const MapContainer = styled("div", {
height: "calc(100vh - $4 - $4)",
});
const PopupContainer = styled("div", {
color: "black",
maxHeight: "400px",
overflowY: "scroll",
});
const FeatureRow = styled("div", {
marginBottom: "0.5em",
"&:not(:last-of-type)": {
borderBottom: "1px solid black",
},
});
const Hamburger = styled("div", {
position: "absolute",
top: "10px",
right: "10px",
width: 40,
height: 40,
backgroundColor: "#444",
cursor: "pointer",
zIndex: 9999,
});
const Options = styled("div", {
position: "absolute",
backgroundColor: "#444",
top: "50px",
right: "10px",
padding: "$1",
zIndex: 9999,
});
const CheckboxLabel = styled("label", {
display: "flex",
gap: 6,
cursor: "pointer",
});
const LayersVisibilityList = styled("ul", {
listStyleType: "none",
});
const FeaturesProperties = (props: { features: MapGeoJSONFeature[] }) => {
return (
<PopupContainer>
{props.features.map((f, i) => (
<FeatureRow key={i}>
<span>
<strong>{(f.layer as any)["source-layer"]}</strong>
<span style={{ fontSize: "0.8em" }}> ({f.geometry.type})</span>
</span>
<table>
{Object.entries(f.properties).map(([key, value], i) => (
<tr key={i}>
<td>{key}</td>
<td>
{typeof value === "boolean" ? JSON.stringify(value) : value}
</td>
</tr>
))}
</table>
</FeatureRow>
))}
</PopupContainer>
);
};
interface LayerVisibility {
id: string;
visible: boolean;
}
interface Metadata {
name?: string;
type?: string;
tilestats?: unknown;
vector_layers: LayerSpecification[];
}
const LayersVisibilityController = (props: {
layers: LayerVisibility[];
onChange: (layers: LayerVisibility[]) => void;
}) => {
const { layers, onChange } = props;
const allLayersCheckboxRef = useRef<HTMLInputElement>(null);
const visibleLayersCount = layers.filter((l) => l.visible).length;
const indeterminate =
visibleLayersCount > 0 && visibleLayersCount !== layers.length;
useEffect(() => {
if (allLayersCheckboxRef.current) {
allLayersCheckboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);
if (!props.layers.length) {
return (
<>
<h4>Layers</h4>
<span>No vector layers found</span>
</>
);
}
const toggleAllLayers = () => {
const visible = visibleLayersCount !== layers.length;
const newLayersVisibility = layers.map((l) => ({ ...l, visible }));
onChange(newLayersVisibility);
};
const toggleLayer = (event: React.ChangeEvent<HTMLInputElement>) => {
const layerId = event.target.getAttribute("data-layer-id");
const newLayersVisibility = layers.map((l) =>
l.id === layerId ? { ...l, visible: !l.visible } : l
);
onChange(newLayersVisibility);
};
return (
<>
<h4>Layers</h4>
<CheckboxLabel>
<input
ref={allLayersCheckboxRef}
type="checkbox"
checked={visibleLayersCount === layers.length}
onChange={toggleAllLayers}
/>
<em>All layers</em>
</CheckboxLabel>
<LayersVisibilityList>
{props.layers.map(({ id, visible }, idx) => (
<li key={id}>
<CheckboxLabel style={{ paddingLeft: 8 }}>
<input
type="checkbox"
checked={visible}
onChange={toggleLayer}
data-layer-id={id}
/>
<span
style={{
width: ".8rem",
height: ".8rem",
backgroundColor: schemeSet3[idx % 12],
}}
/>
{id}
</CheckboxLabel>
</li>
))}
</LayersVisibilityList>
</>
);
};
const rasterStyle = async (file: PMTiles): Promise<StyleSpecification> => {
const header = await file.getHeader();
const metadata = (await file.getMetadata()) as Metadata;
let layers: LayerSpecification[] = [];
if (metadata.type !== "baselayer") {
layers = baseTheme("basemap", BASEMAP_THEME, "en");
}
layers.push({
id: "raster",
type: "raster",
source: "source",
});
return {
version: 8,
sources: {
source: {
type: "raster",
tiles: [`pmtiles://${file.source.getKey()}/{z}/{x}/{y}`],
minzoom: header.minZoom,
maxzoom: header.maxZoom,
},
basemap: {
type: "vector",
tiles: [BASEMAP_URL],
maxzoom: 15,
attribution: BASEMAP_ATTRIBUTION,
},
},
glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf",
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v3/${BASEMAP_THEME}`,
layers: layers,
};
};
const vectorStyle = async (
file: PMTiles
): Promise<{
style: StyleSpecification;
layersVisibility: LayerVisibility[];
}> => {
const header = await file.getHeader();
const metadata = (await file.getMetadata()) as Metadata;
let layers: LayerSpecification[] = [];
let baseOpacity = 0.35;
if (metadata.type !== "baselayer") {
layers = baseTheme("basemap", BASEMAP_THEME, "en");
baseOpacity = 0.9;
}
const tilestats = metadata.tilestats;
const vectorLayers = metadata.vector_layers;
if (vectorLayers) {
for (const [i, layer] of vectorLayers.entries()) {
layers.push({
id: `${layer.id}_fill`,
type: "fill",
source: "source",
"source-layer": layer.id,
paint: {
"fill-color": schemeSet3[i % 12],
"fill-opacity": [
"case",
["boolean", ["feature-state", "hover"], false],
baseOpacity,
baseOpacity - 0.15,
],
"fill-outline-color": [
"case",
["boolean", ["feature-state", "hover"], false],
"hsl(0,100%,90%)",
"rgba(0,0,0,0.2)",
],
},
filter: ["==", ["geometry-type"], "Polygon"],
});
layers.push({
id: `${layer.id}_stroke`,
type: "line",
source: "source",
"source-layer": layer.id,
paint: {
"line-color": schemeSet3[i % 12],
"line-width": [
"case",
["boolean", ["feature-state", "hover"], false],
2,
0.5,
],
},
filter: ["==", ["geometry-type"], "LineString"],
});
layers.push({
id: `${layer.id}_point`,
type: "circle",
source: "source",
"source-layer": layer.id,
paint: {
"circle-color": schemeSet3[i % 12],
"circle-radius": [
"case",
["boolean", ["feature-state", "hover"], false],
6,
5,
],
},
filter: ["==", ["geometry-type"], "Point"],
});
}
}
const bounds: [number, number, number, number] = [
header.minLon,
header.minLat,
header.maxLon,
header.maxLat,
];
return {
style: {
version: 8,
sources: {
source: {
type: "vector",
tiles: [`pmtiles://${file.source.getKey()}/{z}/{x}/{y}`],
minzoom: header.minZoom,
maxzoom: header.maxZoom,
bounds: bounds,
},
basemap: {
type: "vector",
tiles: [BASEMAP_URL],
maxzoom: 15,
bounds: bounds,
attribution: BASEMAP_ATTRIBUTION,
},
},
glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf",
layers: layers,
},
layersVisibility: vectorLayers.map((l: LayerSpecification) => ({
id: l.id,
visible: true,
})),
};
};
function MaplibreMap(props: { file: PMTiles; mapHashPassed: boolean }) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const [hamburgerOpen, setHamburgerOpen] = useState<boolean>(true);
const [showAttributes, setShowAttributes] = useState<boolean>(false);
const [showTileBoundaries, setShowTileBoundaries] = useState<boolean>(false);
const [layersVisibility, setLayersVisibility] = useState<LayerVisibility[]>(
[]
);
const [popupFrozen, setPopupFrozen] = useState<boolean>(false);
const popupFrozenRef = useRef<boolean>();
popupFrozenRef.current = popupFrozen;
const mapRef = useRef<maplibregl.Map | null>(null);
const hoveredFeaturesRef = useRef<Set<MapGeoJSONFeature>>(new Set());
// make it accessible in hook
const showAttributesRef = useRef(showAttributes);
useEffect(() => {
showAttributesRef.current = showAttributes;
}, [showAttributes]);
const toggleHamburger = () => {
setHamburgerOpen(!hamburgerOpen);
};
const toggleShowAttributes = () => {
setShowAttributes(!showAttributes);
mapRef.current!.getCanvas().style.cursor = !showAttributes
? "crosshair"
: "";
};
const toggleShowTileBoundaries = () => {
setShowTileBoundaries(!showTileBoundaries);
mapRef.current!.showTileBoundaries = !showTileBoundaries;
};
const handleLayersVisibilityChange = (
layersVisibility: LayerVisibility[]
) => {
setLayersVisibility(layersVisibility);
const map = mapRef.current!;
for (const { id, visible } of layersVisibility) {
const visibility = visible ? "visible" : "none";
map.setLayoutProperty(`${id}_fill`, "visibility", visibility);
map.setLayoutProperty(`${id}_stroke`, "visibility", visibility);
map.setLayoutProperty(`${id}_point`, "visibility", visibility);
}
};
useEffect(() => {
const protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
protocol.add(props.file); // this is necessary for non-HTTP sources
const map = new maplibregl.Map({
container: mapContainerRef.current!,
hash: "map",
zoom: INITIAL_ZOOM,
center: [INITIAL_LNG, INITIAL_LAT],
style: {
version: 8,
sources: {},
layers: [],
},
});
map.addControl(new maplibregl.NavigationControl({}), "bottom-left");
map.on("load", map.resize);
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
maxWidth: "none",
});
mapRef.current = map;
map.on("mousemove", (e) => {
if (popupFrozenRef.current) {
return;
}
const hoveredFeatures = hoveredFeaturesRef.current;
for (const feature of hoveredFeatures) {
map.setFeatureState(feature, { hover: false });
hoveredFeatures.delete(feature);
}
if (!showAttributesRef.current) {
popup.remove();
return;
}
const { x, y } = e.point;
const r = 2; // radius around the point
let features = map.queryRenderedFeatures([
[x - r, y - r],
[x + r, y + r],
]);
// ignore the basemap
features = features.filter((feature) => feature.source === "source");
for (const feature of features) {
map.setFeatureState(feature, { hover: true });
hoveredFeatures.add(feature);
}
const content = renderToString(
<FeaturesProperties features={features} />
);
if (!features.length) {
popup.remove();
} else {
popup.setHTML(content);
popup.setLngLat(e.lngLat);
popup.addTo(map);
}
});
map.on("click", (e) => {
popupFrozen
? popup.removeClassName("frozen")
: popup.addClassName("frozen");
setPopupFrozen((p) => !p);
});
return () => {
map.remove();
};
}, []);
useEffect(() => {
const initStyle = async () => {
if (mapRef.current) {
const map = mapRef.current;
const header = await props.file.getHeader();
if (!props.mapHashPassed) {
// the map hash was not passed, so auto-detect the initial viewport based on metadata
map.fitBounds(
[
[header.minLon, header.minLat],
[header.maxLon, header.maxLat],
],
{ animate: false }
);
}
let style: StyleSpecification;
if (
header.tileType === TileType.Png ||
header.tileType === TileType.Webp ||
header.tileType === TileType.Jpeg
) {
const style = await rasterStyle(props.file);
map.setStyle(style);
} else {
const { style, layersVisibility } = await vectorStyle(props.file);
map.setStyle(style);
setLayersVisibility(layersVisibility);
}
}
};
initStyle();
}, []);
return (
<MapContainer ref={mapContainerRef}>
<div ref={mapContainerRef} />
<Hamburger onClick={toggleHamburger}>menu</Hamburger>
{hamburgerOpen ? (
<Options>
<h4>Filter</h4>
<h4>Popup</h4>
<CheckboxLabel>
<input
type="checkbox"
checked={showAttributes}
onChange={toggleShowAttributes}
/>
show attributes
</CheckboxLabel>
<h4>Tiles</h4>
<CheckboxLabel>
<input
type="checkbox"
checked={showTileBoundaries}
onChange={toggleShowTileBoundaries}
/>
show tile boundaries
</CheckboxLabel>
<LayersVisibilityController
layers={layersVisibility}
onChange={handleLayersVisibilityChange}
/>
</Options>
) : null}
</MapContainer>
);
}
export default MaplibreMap;

View File

@@ -1,73 +0,0 @@
import { JsonViewer } from "@textea/json-viewer";
import { useEffect, useState } from "react";
import { Header, PMTiles } from "../../js/src/index";
import { styled } from "./stitches.config";
const Padded = styled("div", {
padding: "2rem",
});
const Heading = styled("div", {
paddingBottom: "2rem",
fontFamily: "monospace",
});
function Metadata(props: { file: PMTiles }) {
const [metadata, setMetadata] = useState<unknown>();
const [header, setHeader] = useState<Header | null>(null);
useEffect(() => {
const pmtiles = props.file;
const fetchData = async () => {
setMetadata(await pmtiles.getMetadata());
setHeader(await pmtiles.getHeader());
};
fetchData();
}, [props.file]);
return (
<Padded>
{header ? (
<Heading>
<div>
root directory: offset={header.rootDirectoryOffset} len=
{header.rootDirectoryLength}
</div>
<div>
metadata: offset={header.jsonMetadataOffset} len=
{header.jsonMetadataLength}
</div>
<div>
leaf directories: offset={header.leafDirectoryOffset} len=
{header.leafDirectoryLength}
</div>
<div>
tile data: offset={header.tileDataOffset} len=
{header.tileDataLength}
</div>
<div>num addressed tiles: {header.numAddressedTiles}</div>
<div>num tile entries: {header.numTileEntries}</div>
<div>num tile contents: {header.numTileContents}</div>
<div>clustered: {header.clustered ? "true" : "false"}</div>
<div>internal compression: {header.internalCompression}</div>
<div>tile compression: {header.tileCompression}</div>
<div>tile type: {header.tileType}</div>
<div>min zoom: {header.minZoom}</div>
<div>max zoom: {header.maxZoom}</div>
<div>
min lon, min lat, max lon, max lat: {header.minLon}, {header.minLat}
, {header.maxLon}, {header.maxLat}
</div>
<div>center zoom: {header.centerZoom}</div>
<div>
center lon, center lat: {header.centerLon}, {header.centerLat}
</div>
</Heading>
) : null}
<JsonViewer value={metadata} theme="dark" defaultInspectDepth={1} />
</Padded>
);
}
export default Metadata;

534
app/src/PageArchive.tsx Normal file
View File

@@ -0,0 +1,534 @@
/* @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);
}

539
app/src/PageMap.tsx Normal file
View File

@@ -0,0 +1,539 @@
/* @refresh reload */
import "maplibre-gl/dist/maplibre-gl.css";
import "./index.css";
import { layers, namedFlavor } from "@protomaps/basemaps";
import {
AttributionControl,
type MapGeoJSONFeature,
Map as MaplibreMap,
NavigationControl,
Popup,
addProtocol,
getRTLTextPluginStatus,
setRTLTextPlugin,
} from "maplibre-gl";
import {
type Accessor,
type Setter,
Show,
createEffect,
createMemo,
createResource,
createSignal,
onMount,
} from "solid-js";
import { render } from "solid-js/web";
import "@alenaksu/json-viewer";
import { SphericalMercator } from "@mapbox/sphericalmercator";
import { Protocol } from "pmtiles";
import { FeatureTable } from "./FeatureTable";
import { ExampleChooser, Frame } from "./Frame";
import { type LayerVisibility, LayersPanel } from "./LayersPanel";
import { type Tileset, tilesetFromString } from "./tileset";
import { colorForIdx, createHash, parseHash, tileInspectUrl } from "./utils";
declare module "solid-js" {
namespace JSX {
interface IntrinsicElements {
"json-viewer": unknown;
}
}
}
function MapView(props: {
tileset: Tileset;
showMetadata: Accessor<boolean>;
setShowMetadata: Setter<boolean>;
showTileBoundaries: Accessor<boolean>;
setShowTileBoundaries: Setter<boolean>;
inspectFeatures: Accessor<boolean>;
setInspectFeatures: Setter<boolean>;
mapHashPassed: boolean;
}) {
let mapContainer: HTMLDivElement | undefined;
let hiddenRef: HTMLDivElement | undefined;
const [zoom, setZoom] = createSignal<number>(0);
const [layerVisibility, setLayerVisibility] = createSignal<LayerVisibility[]>(
[],
);
const [hoveredFeatures, setHoveredFeatures] = createSignal<
MapGeoJSONFeature[]
>([]);
const [basemap, setBasemap] = createSignal<boolean>(false);
const [frozen, setFrozen] = createSignal<boolean>(false);
const inspectableFeatures = createMemo(() => {
return hoveredFeatures().map((h) => {
return {
layerName: h.sourceLayer || "unknown",
id: (h.id as number) || 0,
properties: h.properties,
type: h._vectorTileFeature.type,
};
});
});
const popup = new Popup({
closeButton: false,
closeOnClick: false,
maxWidth: "none",
});
const protocol = new Protocol({ metadata: true });
addProtocol("pmtiles", protocol.tile);
let map: MaplibreMap;
createEffect(() => {
const visibility = basemap() ? "visible" : "none";
if (map) {
for (const layer of map.getStyle().layers) {
if ("source" in layer && layer.source === "basemap") {
map.setLayoutProperty(layer.id, "visibility", visibility);
}
}
}
});
const roundZoom = () => {
map.zoomTo(Math.round(map.getZoom()));
};
onMount(async () => {
if (!mapContainer) {
console.error("Could not mount map element");
return;
}
const archiveForProtocol = props.tileset.archiveForProtocol();
if (archiveForProtocol) {
protocol.add(archiveForProtocol);
}
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({
hash: "map",
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",
],
maxzoom: 15,
attribution:
"Background © <a href='https://openstreetmap.org/copyright'>OpenStreetMap</a>",
},
},
layers: layers("basemap", namedFlavor(flavor), { lang: "en" }).map(
(l) => {
if (!("layout" in l)) {
l.layout = {};
}
if (l.layout) l.layout.visibility = "none";
return l;
},
),
},
});
createEffect(() => {
map.showTileBoundaries = props.showTileBoundaries();
});
createEffect(() => {
if (props.inspectFeatures()) {
setFrozen(false);
} else {
for (const hoveredFeature of hoveredFeatures()) {
map.setFeatureState(hoveredFeature, { hover: false });
}
popup.remove();
}
});
map.addControl(new NavigationControl({}), "top-left");
map.addControl(new AttributionControl({ compact: false }), "bottom-right");
if (!props.mapHashPassed) {
fitToBounds();
}
setZoom(map.getZoom());
map.on("zoom", (e) => {
setZoom(e.target.getZoom());
});
map.on("mousemove", async (e) => {
if (frozen()) return;
if (!props.inspectFeatures()) {
return;
}
for (const hoveredFeature of hoveredFeatures()) {
map.setFeatureState(hoveredFeature, { hover: false });
}
const { x, y } = e.point;
const r = 2; // radius around the point
let features = map.queryRenderedFeatures([
[x - r, y - r],
[x + r, y + r],
]);
features = features.filter((feature) => feature.source === "tileset");
for (const feature of features) {
map.setFeatureState(feature, { hover: true });
}
setHoveredFeatures(features);
const currentZoom = zoom();
const sp = new SphericalMercator();
const maxZoom = await props.tileset.getMaxZoom();
const z = Math.max(0, Math.min(maxZoom, Math.floor(currentZoom)));
const result = sp.px([e.lngLat.lng, e.lngLat.lat], z);
const tileX = Math.floor(result[0] / 256);
const tileY = Math.floor(result[1] / 256);
if (hiddenRef) {
hiddenRef.innerHTML = "";
render(
() => (
<div>
<FeatureTable features={inspectableFeatures()} />
<a
class="block text-xs btn-primary mt-2 text-center"
target="_blank"
rel="noreferrer"
href={tileInspectUrl(props.tileset.getStateUrl(), [
z,
tileX,
tileY,
])}
>
Tile {z}/{tileX}/{tileY}
</a>
</div>
),
hiddenRef,
);
popup.setHTML(hiddenRef.innerHTML);
popup.setLngLat(e.lngLat);
popup.addTo(map);
}
});
map.on("click", () => {
setFrozen(!frozen());
});
map.on("load", async () => {
if (await props.tileset.isOverlay()) {
setBasemap(true);
}
if (await props.tileset.isVector()) {
map.addSource("tileset", {
type: "vector",
url: props.tileset.getMaplibreSourceUrl(),
});
const vectorLayers = await props.tileset.getVectorLayers();
setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true })));
for (const [i, vectorLayer] of vectorLayers.entries()) {
map.addLayer({
id: `tileset_fill_${vectorLayer}`,
type: "fill",
source: "tileset",
"source-layer": vectorLayer,
paint: {
"fill-color": colorForIdx(i),
"fill-opacity": [
"case",
["boolean", ["feature-state", "hover"], false],
0.25,
0.1,
],
},
filter: ["==", ["geometry-type"], "Polygon"],
});
map.addLayer({
id: `tileset_line_${vectorLayer}`,
type: "line",
source: "tileset",
"source-layer": vectorLayer,
paint: {
"line-color": colorForIdx(i),
"line-width": [
"case",
["boolean", ["feature-state", "hover"], false],
2,
0.5,
],
},
filter: ["==", ["geometry-type"], "LineString"],
});
map.addLayer({
id: `tileset_circle_${vectorLayer}`,
type: "circle",
source: "tileset",
"source-layer": vectorLayer,
paint: {
"circle-color": colorForIdx(i),
"circle-radius": 3,
"circle-stroke-color": "white",
"circle-stroke-width": [
"case",
["boolean", ["feature-state", "hover"], false],
3,
0,
],
},
filter: ["==", ["geometry-type"], "Point"],
});
}
for (const [i, vectorLayer] of vectorLayers.entries()) {
map.addLayer({
id: `tileset_point_label_${vectorLayer}`,
type: "symbol",
source: "tileset",
"source-layer": vectorLayer,
layout: {
"text-field": ["get", "name"],
"text-font": ["Noto Sans Regular"],
"text-size": 10,
"text-offset": [0, -1],
},
paint: {
"text-color": colorForIdx(i),
"text-halo-color": flavor,
"text-halo-width": 2,
},
filter: ["==", ["geometry-type"], "Point"],
});
}
} else {
map.addSource("tileset", {
type: "raster",
url: props.tileset.getMaplibreSourceUrl(),
});
map.addLayer({
source: "tileset",
id: "tileset_raster",
type: "raster",
});
}
map.resize();
});
});
const fitToBounds = async () => {
const bounds = await props.tileset.getBounds();
map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]],
],
{ animate: false },
);
};
createEffect(() => {
const setVisibility = (layerName: string, visibility: string) => {
if (map.getLayer(layerName)) {
map.setLayoutProperty(layerName, "visibility", visibility);
}
};
for (const { id, visible } of layerVisibility()) {
const visibility = visible ? "visible" : "none";
setVisibility(`tileset_fill_${id}`, visibility);
setVisibility(`tileset_line_${id}`, visibility);
setVisibility(`tileset_circle_${id}`, visibility);
setVisibility(`tileset_point_label_${id}`, visibility);
}
});
return (
<div class="flex flex-col md:flex-row w-full h-full">
<div class="flex-1 flex flex-col">
<div class="flex-none p-4 flex justify-between text-xs md:text-base space-x-2">
<button
class="px-4 btn-primary cursor-pointer"
type="button"
onClick={fitToBounds}
>
fit to bounds
</button>
<span class="app-border rounded px-2 flex items-center">
<input
class="mr-1"
id="inspectFeatures"
checked={props.inspectFeatures()}
type="checkbox"
onChange={() => {
props.setInspectFeatures(!props.inspectFeatures());
}}
/>
<label for="inspectFeatures">Inspect features</label>
</span>
<span class="app-border rounded px-2 flex items-center">
<input
class="mr-1"
id="showTileBoundaries"
checked={props.showTileBoundaries()}
type="checkbox"
onChange={() => {
props.setShowTileBoundaries(!props.showTileBoundaries());
}}
/>
<label for="showTileBoundaries">Show tile bounds</label>
</span>
<button
class="px-4 py-1 btn-secondary cursor-pointer"
onClick={() => {
props.setShowMetadata(!props.showMetadata());
}}
type="button"
>
view metadata
</button>
</div>
<div class="relative flex-1 h-full">
<div
ref={mapContainer}
classList={{
"h-full": true,
"flex-1": true,
inspectFeatures: props.inspectFeatures(),
frozen: frozen(),
}}
/>
<div class="hidden" ref={hiddenRef} />
<div class="absolute right-2 top-2 z-0">
<LayersPanel
layerVisibility={layerVisibility}
setLayerVisibility={setLayerVisibility}
basemapOption
basemap={basemap}
setBasemap={setBasemap}
/>
</div>
<div class="absolute left-2 bottom-2">
<button
type="button"
class="flex items-center rounded border app-bg app-border cursor-pointer"
onClick={roundZoom}
>
<span class="app-well px-1 rounded-l">Z</span>
<span class="px-2 text-base rounded-r-md rounded-r">
{zoom().toFixed(2)}
</span>
</button>
</div>
</div>
</div>
<Show when={props.showMetadata()}>
<div class="md:w-1/2 z-[999] app-bg">
<JsonView tileset={props.tileset} />
</div>
</Show>
</div>
);
}
const JsonView = (props: { tileset: Tileset }) => {
const [data] = createResource(async () => {
return await props.tileset.getMetadata();
});
return <json-viewer data={data()} />;
};
function PageMap() {
let hash = parseHash(location.hash);
// the previous version of the PMTiles viewer
// used query params ?url= instead of #url=
// this makes it backward compatible so old-style links still work.
const href = new URL(window.location.href);
const queryParamUrl = href.searchParams.get("url");
if (queryParamUrl) {
href.searchParams.delete("url");
history.pushState(null, "", href.toString());
location.hash = createHash(location.hash, {
url: queryParamUrl,
map: hash.map,
});
hash = parseHash(location.hash);
}
const mapHashPassed = hash.map !== undefined;
const [tileset, setTileset] = createSignal<Tileset | undefined>(
hash.url ? tilesetFromString(decodeURIComponent(hash.url)) : undefined,
);
const [showMetadata, setShowMetadata] = createSignal<boolean>(
hash.showMetadata === "true" || false,
);
const [showTileBoundaries, setShowTileBoundaries] = createSignal<boolean>(
hash.showTileBoundaries === "true",
);
const [inspectFeatures, setInspectFeatures] = createSignal<boolean>(
hash.inspectFeatures === "true",
);
createEffect(() => {
const t = tileset();
const stateUrl = t?.getStateUrl();
location.hash = createHash(location.hash, {
url: stateUrl ? encodeURIComponent(stateUrl) : undefined,
showMetadata: showMetadata() ? "true" : undefined,
showTileBoundaries: showTileBoundaries() ? "true" : undefined,
inspectFeatures: inspectFeatures() ? "true" : undefined,
});
});
return (
<Frame tileset={tileset} setTileset={setTileset} page="map">
<Show
when={tileset()}
fallback={<ExampleChooser setTileset={setTileset} />}
>
{(t) => (
<MapView
tileset={t()}
showMetadata={showMetadata}
setShowMetadata={setShowMetadata}
showTileBoundaries={showTileBoundaries}
setShowTileBoundaries={setShowTileBoundaries}
inspectFeatures={inspectFeatures}
setInspectFeatures={setInspectFeatures}
mapHashPassed={mapHashPassed}
/>
)}
</Show>
</Frame>
);
}
const root = document.getElementById("root");
if (root) {
render(() => <PageMap />, root);
}

536
app/src/PageTile.tsx Normal file
View File

@@ -0,0 +1,536 @@
/* @refresh reload */
import "./index.css";
import { VectorTile } from "@mapbox/vector-tile";
import { axisBottom, axisRight } from "d3-axis";
import { path } from "d3-path";
import { scaleLinear } from "d3-scale";
import { type Selection, create, select } from "d3-selection";
import {
type ZoomBehavior,
type ZoomTransform,
zoom as d3zoom,
zoomIdentity,
} from "d3-zoom";
import Protobuf from "pbf";
import {
type Accessor,
type JSX,
type Setter,
Show,
createEffect,
createResource,
createSignal,
onMount,
} from "solid-js";
import { render } from "solid-js/web";
import { FeatureTable, type InspectableFeature } from "./FeatureTable";
import { ExampleChooser, Frame } from "./Frame";
import { type LayerVisibility, LayersPanel } from "./LayersPanel";
import { type Tileset, tilesetFromString } from "./tileset";
import { colorForIdx, createHash, parseHash, zxyFromHash } from "./utils";
interface Layer {
name: string;
features: Feature[];
}
interface Feature {
path: string;
type: number;
id: number | undefined;
properties: unknown;
color: string;
layerName: string;
}
function parseTile(data: ArrayBuffer, vectorLayers: string[]): Layer[] {
const tile = new VectorTile(new Protobuf(new Uint8Array(data)));
const layers = [];
let maxExtent = 0;
for (const [name, layer] of Object.entries(tile.layers)) {
if (layer.extent > maxExtent) {
maxExtent = layer.extent;
}
const features: Feature[] = [];
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
const p = path();
const geom = feature.loadGeometry();
if (feature.type === 1) {
for (const ring of geom) {
for (const pt of ring) {
p.rect(pt.x - 15, pt.y - 15, 30, 30);
}
}
} else {
for (const ring of geom) {
p.moveTo(ring[0].x, ring[0].y);
for (let j = 1; j < ring.length; j++) {
p.lineTo(ring[j].x, ring[j].y);
}
if (feature.type === 3) {
p.closePath();
}
}
}
features.push({
path: p.toString(),
type: feature.type,
id: feature.id,
properties: feature.properties,
layerName: name,
color: colorForIdx(vectorLayers.indexOf(name)),
});
}
layers.push({ name: name, features: features });
}
return layers;
}
function layerFeatureCounts(
parsedTile?: Layer[] | ArrayBuffer,
): Record<string, number> {
const result: Record<string, number> = {};
if (!parsedTile) return result;
if (parsedTile instanceof ArrayBuffer) return result;
for (const layer of parsedTile) {
result[layer.name] = layer.features.length;
}
return result;
}
function ZoomableTile(props: {
zxy: Accessor<[number, number, number]>;
tileset: Tileset;
}) {
let containerRef: HTMLDivElement | undefined;
let svg: Selection<SVGSVGElement, unknown, null, undefined>;
let zoom: ZoomBehavior<SVGSVGElement, unknown>;
let view: Selection<SVGGElement, unknown, null, undefined>;
const [layerVisibility, setLayerVisibility] = createSignal<LayerVisibility[]>(
[],
);
const [inspectableFeature, setInspectableFeature] = createSignal<
InspectableFeature | undefined
>();
const [frozen, setFrozen] = createSignal<boolean>(false);
onMount(() => {
if (!containerRef) {
return;
}
const height = containerRef.clientHeight;
const width = containerRef.clientWidth;
// const width = 800;
// const height = 300;
const x = scaleLinear()
.domain([-1000, 4096 + 1000])
.range([-1000, 4096 + 1000]);
const y = scaleLinear()
.domain([-1000, 4096 + 1000])
.range([-1000, 4096 + 1000]);
const xAxis = axisBottom(x)
.ticks(((width + 2) / (height + 2)) * 10)
.tickSize(height)
.tickPadding(8 - height);
const yAxis = axisRight(y)
.ticks(((width + 2) / (height + 2)) * 10)
.tickSize(width)
.tickPadding(8 - width);
svg = create("svg")
.attr("width", width)
.attr("height", height) as Selection<
SVGSVGElement,
unknown,
null,
undefined
>;
view = svg.append("g");
view
.append("rect")
.attr("width", 4096)
.attr("height", 4096)
.attr("x", 0)
.attr("y", 0)
.attr("fill", "none")
.attr("strokeWidth", "1")
.attr("stroke", "blue");
const gX = svg.append("g").attr("class", "axis axis--x").call(xAxis);
const gY = svg.append("g").attr("class", "axis axis--y").call(yAxis);
function zoomed({ transform }: { transform: ZoomTransform }) {
view.attr("transform", transform.toString());
gX.call(xAxis.scale(transform.rescaleX(x)));
gY.call(yAxis.scale(transform.rescaleY(y)));
}
function filter(event: MouseEvent | WheelEvent) {
event.preventDefault();
return (!event.ctrlKey || event.type === "wheel") && !event.button;
}
zoom = d3zoom<SVGSVGElement, unknown>()
.scaleExtent([0.01, 20])
.translateExtent([
[-1000, -1000],
[4096 + 1000, 4096 + 1000],
])
.filter(filter)
.on("zoom", zoomed);
Object.assign(svg.call(zoom).node() as SVGSVGElement, {});
svg.call(
zoom.transform,
zoomIdentity
.translate(width / 2, height / 2)
.scale((height / 4096) * 0.75)
.translate(-4096 / 2, -4096 / 2),
);
const resizeObserver = new ResizeObserver(() => {
svg.attr("width", containerRef.clientWidth);
svg.attr("height", containerRef.clientHeight);
});
resizeObserver.observe(containerRef);
const node = svg.node();
if (node) {
containerRef.appendChild(node);
}
});
const [parsedTile] = createResource(props.zxy, async (zxy) => {
const tileset = props.tileset;
if (await tileset.isVector()) {
const data = await tileset.getZxy(zxy[0], zxy[1], zxy[2]);
if (!data) return;
const vectorLayers = await props.tileset.getVectorLayers();
return parseTile(data, vectorLayers);
}
return await tileset.getZxy(zxy[0], zxy[1], zxy[2]);
});
onMount(async () => {
if (await props.tileset.isVector()) {
const vectorLayers = await props.tileset.getVectorLayers();
setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true })));
}
});
createEffect(async () => {
view.selectAll("*").remove();
const tile = parsedTile();
if (!tile) return;
if (Array.isArray(tile)) {
const visibility = layerVisibility();
const layersToShow = tile.filter((l) => {
return visibility.find((v) => v.id === l.name && v.visible);
});
const layer = view.selectAll("g").data(layersToShow).join("g");
layer
.selectAll("path")
.data((d) => d.features)
.join("path")
.attr("d", (f) => f.path)
.style("opacity", 0.3)
.attr("fill", (d) => (d.type === 3 || d.type === 1 ? d.color : "none"))
.attr("stroke", (d) => (d.type === 2 ? d.color : "none"))
.attr("stroke-width", 6)
.on("mouseover", function (_e, d) {
if (frozen()) return;
if (d.type === 2) {
select(this).attr("stroke", "white");
} else {
select(this).attr("fill", "white");
}
setInspectableFeature({
layerName: d.layerName,
properties: d.properties,
type: d.type,
id: d.id,
} as InspectableFeature);
})
.on("mouseout", function (_e, d) {
if (frozen()) return;
if (d.type === 2) {
select(this).attr("stroke", d.color);
} else {
select(this).attr("fill", d.color);
}
})
.on("mousedown", () => {
setFrozen(!frozen());
});
} else {
const blob = new Blob([tile], { type: "image/webp" });
const objectUrl = URL.createObjectURL(blob);
const img = view.append("image");
img.attr("href", objectUrl).attr("width", 4096).attr("height", 4096);
}
});
return (
<div class="h-full w-full relative">
<div class="absolute top-2 right-2">
<LayersPanel
layerVisibility={layerVisibility}
setLayerVisibility={setLayerVisibility}
layerFeatureCounts={layerFeatureCounts(parsedTile())}
/>
</div>
<Show when={inspectableFeature()}>
{(f) => (
<div class="absolute bottom-2 right-2">
<div
classList={{
"app-bg": true,
"app-well": frozen(),
rounded: true,
"p-2": true,
"app-border": true,
}}
>
<FeatureTable features={[f()]} />
</div>
</div>
)}
</Show>
<div ref={containerRef} class="h-full cursor-crosshair" />
</div>
);
}
function TileView(props: {
tileset: Tileset;
zxy: Accessor<[number, number, number] | undefined>;
setZxy: Setter<[number, number, number] | undefined>;
}) {
const [neighborsOpen, setNeighborsOpen] = createSignal<boolean>(false);
const [childrenOpen, setChildrenOpen] = createSignal<boolean>(false);
const targetTile = (
z: number,
x: number,
y: number,
): [number, number, number] | undefined => {
const current = props.zxy();
if (!current) return;
if (z === 0) return [current[0], current[1] + x, current[2] + y];
if (z === 1)
return [current[0] + 1, current[1] * 2 + x, current[2] * 2 + y];
if (z === -1)
return [
current[0] - 1,
Math.floor(current[1] / 2),
Math.floor(current[2] / 2),
];
};
const navigate = (z: number, x: number, y: number) => {
const t = targetTile(z, x, y);
if (t) props.setZxy(t);
};
const canNavigate = (z: number, x: number, y: number) => {
const t = targetTile(z, x, y);
if (t) {
if (t[0] < 0 || t[1] < 0 || t[2] < 0) return false;
const max = 2 ** t[0] - 1;
return t[1] <= max && t[2] <= max;
}
return false;
};
const loadZxy: JSX.EventHandler<HTMLFormElement, Event> = (e) => {
e.preventDefault();
const form = e.currentTarget;
const z = form.elements.namedItem("z") as HTMLInputElement;
const x = form.elements.namedItem("x") as HTMLInputElement;
const y = form.elements.namedItem("y") as HTMLInputElement;
props.setZxy([+z.value, +x.value, +y.value]);
};
const NavTile = (props: { navTo: [number, number, number] }) => {
return (
<button
type="button"
onClick={() => navigate(...props.navTo)}
classList={{
border: canNavigate(...props.navTo),
"hover:bg-purple": canNavigate(...props.navTo),
"cursor-pointer": canNavigate(...props.navTo),
}}
disabled={!canNavigate(...props.navTo)}
/>
);
};
const cleanValue = (
zxy: [number, number, number] | undefined,
idx: number,
) => {
if (!zxy) return "";
return zxy[idx];
};
return (
<div class="flex flex-col h-full w-full">
<div class="p-2 space-y-2 md:space-y-0 md:space-x-2 flex flex-col md:flex-row justify-start">
<form
class="flex flex-row justify-between md:space-x-4"
onSubmit={loadZxy}
>
<label for="z">Z</label>
<input
id="z"
type="number"
class="app-border w-20"
value={cleanValue(props.zxy(), 0)}
/>
<label for="x">X</label>
<input
id="x"
type="number"
class="app-border w-20"
value={cleanValue(props.zxy(), 1)}
/>
<label for="y">Y</label>
<input
id="y"
type="number"
class="app-border w-20"
value={cleanValue(props.zxy(), 2)}
/>
<button
type="submit"
class="btn-primary rounded px-4 md:mr-8 pointer-cursor"
>
load
</button>
</form>
<div class="flex flex-row justify-between space-x-4">
<span class="relative">
<button
type="button"
class="rounded btn-secondary px-4 cursor-pointer"
onClick={() => setNeighborsOpen(!neighborsOpen())}
>
neighbors
</button>
<Show when={neighborsOpen()}>
<div class="absolute top-8 left-0 z-[999] w-full flex justify-center">
<div class="grid grid-cols-3 grid-rows-3 gap-1 w-16 h-16">
<NavTile navTo={[0, -1, -1]} />
<NavTile navTo={[0, 0, -1]} />
<NavTile navTo={[0, 1, -1]} />
<NavTile navTo={[0, -1, 0]} />
<div />
<NavTile navTo={[0, 1, 0]} />
<NavTile navTo={[0, -1, 1]} />
<NavTile navTo={[0, 0, 1]} />
<NavTile navTo={[0, 1, 1]} />
</div>
</div>
</Show>
</span>
<span class="relative">
<button
type="button"
class="rounded btn-secondary px-4 cursor-pointer"
onClick={() => setChildrenOpen(!childrenOpen())}
>
children
</button>
<Show when={childrenOpen()}>
<div class="w-full absolute top-8 left-0 flex justify-center">
<div class="grid grid-cols-2 grid-rows-2 gap-1 w-16 h-16 z-[999]">
<NavTile navTo={[1, 0, 0]} />
<NavTile navTo={[1, 1, 0]} />
<NavTile navTo={[1, 0, 1]} />
<NavTile navTo={[1, 1, 1]} />
</div>
</div>
</Show>
</span>
<span class="relative">
<button
type="button"
classList={{
rounded: true,
"btn-secondary": canNavigate(-1, 0, 0),
"px-4": true,
}}
onClick={() => navigate(-1, 0, 0)}
disabled={!canNavigate(-1, 0, 0)}
>
parent
</button>
</span>
</div>
</div>
<Show
when={props.zxy()}
fallback={<div class="p-4">Enter a tile z/x/y</div>}
>
{(z) => (
<div class="flex flex-1 w-full h-full overflow-hidden">
<ZoomableTile zxy={z} tileset={props.tileset} />
</div>
)}
</Show>
</div>
);
}
function PageTile() {
const hash = parseHash(location.hash);
const [tileset, setTileset] = createSignal<Tileset | undefined>(
hash.url ? tilesetFromString(decodeURIComponent(hash.url)) : undefined,
);
const [zxy, setZxy] = createSignal<[number, number, number] | undefined>(
hash.zxy ? zxyFromHash(hash.zxy) : undefined,
);
createEffect(() => {
const t = tileset();
const zxyVal = zxy();
const stateUrl = t?.getStateUrl();
location.hash = createHash(location.hash, {
url: stateUrl ? encodeURIComponent(stateUrl) : undefined,
zxy: zxyVal ? zxyVal.join("/") : undefined,
});
});
return (
<Frame tileset={tileset} setTileset={setTileset} page="tile">
<Show
when={tileset()}
fallback={<ExampleChooser setTileset={setTileset} />}
>
{(t) => <TileView tileset={t()} zxy={zxy} setZxy={setZxy} />}
</Show>
</Frame>
);
}
const root = document.getElementById("root");
if (root) {
render(() => <PageTile />, root);
}

View File

@@ -1,165 +0,0 @@
import { Dispatch, SetStateAction, useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { FileSource, PMTiles } from "../../js/src/index";
import { styled } from "./stitches.config";
import * as LabelPrimitive from "@radix-ui/react-label";
const Input = styled("input", {
all: "unset",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: "$3",
fontFamily: "$sans",
"&:focus": { boxShadow: "0 0 0 1px black" },
width: "100%",
border: "1px solid $white",
padding: "$1",
boxSizing: "border-box",
margin: "0 0 $2 0",
borderRadius: "$2",
});
const Label = styled(LabelPrimitive.Root, {
display: "block",
fontSize: "$3",
fontWeight: 300,
userSelect: "none",
padding: "0 0 $1 0",
});
const Header = styled("h1", {
paddingBottom: "$1",
fontWeight: 500,
});
const Container = styled("div", {
maxWidth: 780,
marginLeft: "auto",
marginRight: "auto",
padding: "$3",
});
const Button = styled("button", {
padding: "$1 $2",
marginBottom: "$1",
borderRadius: "$2",
cursor: "pointer",
variants: {
color: {
violet: {
backgroundColor: "blueviolet",
color: "white",
"&:hover": {
backgroundColor: "darkviolet",
},
},
gray: {
backgroundColor: "gainsboro",
"&:hover": {
backgroundColor: "lightgray",
},
},
},
},
});
const Dropzone = styled("div", {
padding: "$2",
border: "1px dashed $white",
textAlign: "center",
margin: "0 0 $2 0",
});
const Example = styled("div", {
padding: "$1",
"&:not(:last-child)": {
borderBottom: "1px solid $white",
},
"&:hover": {
backgroundColor: "$hover",
},
variants: {
selected: {
true: {
backgroundColor: "red",
},
},
},
});
const ExampleList = styled("div", {
borderRadius: "$1",
border: "1px solid $white",
margin: "0 0 $2 0",
});
const EXAMPLE_FILES = [
"https://demo-bucket.protomaps.com/v4.pmtiles",
"https://data.source.coop/protomaps/openstreetmap/v4.pmtiles",
"https://r2-public.protomaps.com/protomaps-sample-datasets/tilezen.pmtiles",
"https://r2-public.protomaps.com/protomaps-sample-datasets/cb_2018_us_zcta510_500k.pmtiles",
"https://pmtiles.io/usgs-mt-whitney-8-15-webp-512.pmtiles",
];
function Start(props: {
setFile: Dispatch<SetStateAction<PMTiles | undefined>>;
}) {
const onDrop = useCallback((acceptedFiles: File[]) => {
props.setFile(new PMTiles(new FileSource(acceptedFiles[0])));
}, []);
const { acceptedFiles, getRootProps, getInputProps } = useDropzone({
onDrop,
});
const [remoteUrl, setRemoteUrl] = useState<string>("");
const [selectedExample, setSelectedExample] = useState<number | null>(1);
const onRemoteUrlChangeHandler = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setRemoteUrl(event.target.value);
};
const loadExample = (i: number) => {
return () => {
props.setFile(new PMTiles(EXAMPLE_FILES[i]));
};
};
const onSubmit = () => {
props.setFile(new PMTiles(remoteUrl));
};
return (
<Container>
<Header>PMTiles Viewer</Header>
<Label htmlFor="remoteUrl">Specify a remote URL</Label>
<Input
id="remoteUrl"
placeholder="https://example.com/my_archive.pmtiles"
onChange={onRemoteUrlChangeHandler}
/>
<Button color="gray" onClick={onSubmit} disabled={!remoteUrl.trim()}>
Load URL
</Button>
<Label htmlFor="localFile">Select a local file</Label>
<Dropzone {...getRootProps()}>
<input {...getInputProps()} />
<p>Drag + drop a file here, or click to select</p>
</Dropzone>
<Label>Load an example</Label>
<ExampleList>
{EXAMPLE_FILES.map((e, i) => (
<Example key={i} onClick={loadExample(i)}>
{e}
</Example>
))}
</ExampleList>
</Container>
);
}
export default Start;

View File

@@ -1,12 +0,0 @@
import React from "react";
import reactDom from "react-dom/client";
import TileInspectComponent from "./TileInspectComponent";
const root = document.getElementById("root");
if (root) {
reactDom.createRoot(root).render(
<React.StrictMode>
<TileInspectComponent />
</React.StrictMode>
);
}

View File

@@ -1,38 +0,0 @@
import React, { useState, useEffect } from "react";
import { PMTiles } from "../../js/src/index";
import { globalStyles, styled } from "./stitches.config";
import Inspector from "./Inspector";
import Start from "./Start";
function TileInspectComponent() {
globalStyles();
const [file, setFile] = useState<PMTiles | undefined>();
// initial load
useEffect(() => {
const loadUrl = new URLSearchParams(location.search).get("url");
if (loadUrl) {
const initialValue = new PMTiles(loadUrl);
setFile(initialValue);
}
}, []);
// maintaining URL state
useEffect(() => {
const url = new URL(window.location.href);
if (file?.source.getKey().startsWith("http")) {
url.searchParams.set("url", file.source.getKey());
history.pushState(null, "", url.toString());
} else {
url.searchParams.delete("url");
history.pushState(null, "", url.toString());
}
}, [file]);
return (
<div>{file ? <Inspector file={file} /> : <Start setFile={setFile} />}</div>
);
}
export default TileInspectComponent;

118
app/src/index.css Normal file
View File

@@ -0,0 +1,118 @@
@import "tailwindcss";
.axis .domain {
display: none;
}
json-viewer {
overflow-x: scroll;
padding: 4px;
}
@theme {
--color-purple: #3131dc;
--color-slate: #e7e7f9;
}
@layer components {
.app-bg {
@apply bg-gray-100 border-gray-400 divide-gray-400;
}
.app-border {
@apply border border-gray-300 divide-gray-300;
}
.app-divide {
@apply divide-gray-300;
}
.app-text-light {
@apply text-gray-500;
}
.app-well {
@apply bg-gray-200 hover:bg-gray-300;
}
.btn-primary {
@apply rounded bg-purple text-white shadow-sm hover:bg-black focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-purple;
}
.btn-secondary {
@apply rounded bg-gray-700 text-white shadow-sm hover:bg-black focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-purple;
}
@media (prefers-color-scheme: dark) {
.app-bg {
@apply bg-gray-900 text-white;
}
.app-border {
@apply border border-gray-600 divide-gray-600;
}
.app-divide {
@apply divide-gray-600;
}
.app-text-light {
@apply text-gray-400;
}
.app-well {
@apply bg-gray-700 hover:bg-gray-600;
}
}
}
.axis {
color: #666;
}
.axis line {
stroke-opacity: 0.3;
shape-rendering: crispEdges;
}
.inspectFeatures:not(.frozen) canvas {
cursor: crosshair;
}
.maplibregl-popup .maplibregl-popup-content {
pointer-events: none;
}
.frozen {
.maplibregl-popup .maplibregl-popup-content {
pointer-events: auto;
}
}
@media (prefers-color-scheme: dark) {
.maplibregl-ctrl.maplibregl-ctrl-group {
filter: invert(1);
}
details.maplibregl-ctrl.maplibregl-ctrl-attrib {
background-color: black;
color: #999;
}
details.maplibregl-ctrl.maplibregl-ctrl-attrib a {
color: #999;
}
div.maplibregl-popup-content {
background-color: black;
}
div.maplibregl-popup-anchor-right div.maplibregl-popup-tip {
border-left-color: black;
}
div.maplibregl-popup-anchor-left div.maplibregl-popup-tip {
border-right-color: black;
}
div.maplibregl-popup-anchor-bottom div.maplibregl-popup-tip,
div.maplibregl-popup-anchor-bottom-right div.maplibregl-popup-tip,
div.maplibregl-popup-anchor-bottom-left div.maplibregl-popup-tip {
border-top-color: black;
}
div.maplibregl-popup-anchor-top div.maplibregl-popup-tip,
div.maplibregl-popup-anchor-top-right div.maplibregl-popup-tip,
div.maplibregl-popup-anchor-top-left div.maplibregl-popup-tip {
border-bottom-color: black;
}
}

View File

@@ -1,70 +0,0 @@
import { createStitches, globalCss } from "@stitches/react";
export const { styled } = createStitches({
theme: {
colors: {
black: "rgba(0, 0, 0)",
white: "rgba(236, 237, 238)",
hover: "#7180B9",
primary: "#3423A6",
primaryText: "white",
},
fonts: {
sans: "Inter, sans-serif",
},
fontSizes: {
1: "12px",
2: "14px",
3: "16px",
4: "20px",
5: "24px",
6: "32px",
},
space: {
1: "10px",
2: "20px",
3: "40px",
},
sizes: {
1: "4px",
2: "8px",
3: "16px",
4: "32px",
5: "64px",
6: "128px",
},
radii: {
1: "2px",
2: "4px",
3: "8px",
round: "9999px",
},
fontWeights: {},
lineHeights: {},
letterSpacings: {},
borderWidths: {},
borderStyles: {},
shadows: {},
zIndices: {},
transitions: {},
},
});
export const globalStyles = globalCss({
"*": {
margin: 0,
padding: 0,
border: 0,
},
body: {
backgroundColor: "$black",
color: "$white",
fontFamily: "sans-serif",
},
".maplibregl-popup .maplibregl-popup-content": {
pointerEvents: "none",
},
".maplibregl-popup.frozen .maplibregl-popup-content": {
pointerEvents: "auto",
},
});

206
app/src/tileset.ts Normal file
View File

@@ -0,0 +1,206 @@
// a TileJSON or a .pmtiles archive, local or remote
// gets metadata, tiles, etc
import { FileSource, PMTiles, TileType } from "pmtiles";
interface VectorLayer {
id: string;
}
interface Metadata {
type?: string;
vector_layers: VectorLayer[];
}
export interface Tileset {
getZxy(z: number, x: number, y: number): Promise<ArrayBuffer | undefined>;
getMetadata(): Promise<Metadata>;
getStateUrl(): string | undefined;
getMaplibreSourceUrl(): string;
getBounds(): Promise<[number, number, number, number]>;
getMaxZoom(): Promise<number>;
getVectorLayers(): Promise<string[]>;
isOverlay(): Promise<boolean>;
isVector(): Promise<boolean>;
test(): Promise<void>;
archiveForProtocol(): PMTiles | undefined;
}
export class PMTilesTileset {
archive: PMTiles;
constructor(p: PMTiles) {
this.archive = p;
}
async getZxy(z: number, x: number, y: number) {
const resp = await this.archive.getZxy(z, x, y);
if (resp) return resp.data;
}
async getBounds(): Promise<[number, number, number, number]> {
const h = await this.getHeader();
return [h.minLon, h.minLat, h.maxLon, h.maxLat];
}
async getMaxZoom(): Promise<number> {
const h = await this.getHeader();
return h.maxZoom;
}
async isVector() {
const h = await this.getHeader();
return h.tileType === TileType.Mvt;
}
async getHeader() {
return await this.archive.getHeader();
}
async test() {
await this.archive.getHeader();
}
async getMetadata() {
return (await this.archive.getMetadata()) as Metadata;
}
async isOverlay() {
const m = await this.getMetadata();
return m.type === "overlay";
}
async getVectorLayers() {
const m = await this.getMetadata();
return m.vector_layers.map((l) => l.id);
}
}
class RemotePMTilesTileset extends PMTilesTileset implements Tileset {
url: string;
constructor(url: string) {
super(new PMTiles(url));
this.url = url;
}
getStateUrl() {
return this.url;
}
getMaplibreSourceUrl() {
return `pmtiles://${this.url}`;
}
archiveForProtocol() {
return undefined;
}
}
class LocalPMTilesTileset extends PMTilesTileset implements Tileset {
name: string;
constructor(file: File) {
super(new PMTiles(new FileSource(file)));
this.name = file.name;
}
// the local file cannot be persisted in the URL.
getStateUrl() {
return undefined;
}
getMaplibreSourceUrl() {
return `pmtiles://${this.name}`;
}
archiveForProtocol() {
return this.archive;
}
}
class TileJSONTileset implements Tileset {
url: string;
constructor(url: string) {
this.url = url;
}
archiveForProtocol() {
return undefined;
}
async test() {
await fetch(this.url);
}
async getBounds() {
const resp = await fetch(this.url);
const j = await resp.json();
return j.bounds as [number, number, number, number];
}
async getMaxZoom() {
const resp = await fetch(this.url);
const j = await resp.json();
return j.maxzoom;
}
getMaplibreSourceUrl() {
return this.url;
}
async isOverlay() {
return true;
}
async isVector() {
const resp = await fetch(this.url);
const j = await resp.json();
const template = j.tiles[0];
const pathname = new URL(template).pathname;
return pathname.endsWith(".pbf") || pathname.endsWith(".mvt");
}
getStateUrl() {
return this.url;
}
async getZxy(z: number, x: number, y: number) {
const resp = await fetch(this.url);
const j = await resp.json();
const template = j.tiles[0];
const tileURL = template
.replace("{z}", z)
.replace("{x}", x)
.replace("{y}", y);
const tileResp = await fetch(tileURL);
return await tileResp.arrayBuffer();
}
async getMetadata() {
const resp = await fetch(this.url);
return await resp.json();
}
async getVectorLayers() {
const metadata = await this.getMetadata();
return metadata.vector_layers.map((l: VectorLayer) => l.id);
}
}
// from a input box or a URL param state.
export const tilesetFromString = (url: string): Tileset => {
const parsed = new URL(url);
if (parsed.pathname.endsWith(".json")) {
return new TileJSONTileset(url);
}
return new RemotePMTilesTileset(url);
};
export const tilesetFromFile = (file: File): Tileset => {
return new LocalPMTilesTileset(file);
};

70
app/src/utils.ts Normal file
View File

@@ -0,0 +1,70 @@
import { schemeSet3 } from "d3-scale-chromatic";
export const GIT_SHA = (import.meta.env.VITE_GIT_SHA || "dev").substr(0, 8);
export function colorForIdx(idx: number) {
return schemeSet3[idx % 12];
}
// Get the hash contents as a map.
export function parseHash(hash: string): Record<string, string> {
const retval: Record<string, string> = {};
for (const pair of hash.replace("#", "").split("&")) {
const parts = pair.split("=");
retval[parts[0]] = parts[1];
}
return retval;
}
// Given the current hash and a record of string->strings, creates the new hash
export function createHash(
currentHash: string,
newHash: Record<string, string | undefined>,
): string {
const current = parseHash(currentHash);
const combined = { ...current, ...newHash };
return `#${Object.entries(combined)
.filter(([_, value]) => {
return value !== undefined;
})
.map(([key, value]) => {
return `${key}=${value}`;
})
.join("&")}`;
}
export function zxyFromHash(s: string): [number, number, number] | undefined {
const split = s.split("/");
if (split.length !== 3) return undefined;
return split.map((n) => +n) as [number, number, number];
}
export function tileInspectUrl(
stateUrl: string | undefined,
zxy: [number, number, number],
): string {
const hashParams = [`zxy=${zxy[0]}/${zxy[1]}/${zxy[2]}`];
if (stateUrl) {
hashParams.push(`url=${stateUrl}`);
}
return `/tile/#${hashParams.join("&")}`;
}
export function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = [
"Bytes",
"KiB",
"MiB",
"GiB",
"TiB",
"PiB",
"EiB",
"ZiB",
"YiB",
];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}

17
app/tile/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="theme-color" content="#3131DC"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMTiles tile inspector</title>
<link rel="preconnect" href="https://api.protomaps.com"/>
<link rel="preconnect" href="https://protomaps.github.io"/>
<link rel="preconnect" href="https://unpkg.com"/>
<link rel="preconnect" href="https://demo-bucket.protomaps.com"/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/PageTile.tsx"></script>
</body>
</html>

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tile Inspect</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/TileInspect.tsx"></script>
</body>
</html>

27
app/tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,21 +1,7 @@
{ {
"compilerOptions": { "files": [],
"target": "ESNext", "references": [
"useDefineForClassFields": true, { "path": "./tsconfig.app.json" },
"lib": ["DOM", "DOM.Iterable", "ESNext"], { "path": "./tsconfig.node.json" }
"allowJs": false, ]
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -1,8 +1,24 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "esnext", "target": "ES2022",
"moduleResolution": "node" "lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }

View File

@@ -1,15 +1,17 @@
import { defineConfig } from "vite"; import { resolve } from "node:path";
import react from "@vitejs/plugin-react"; import { defineConfig } from 'vite'
import { resolve } from "path"; import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [solid(), tailwindcss()],
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
mapview: resolve(__dirname, "index.html"), map: resolve(__dirname, "index.html"),
tileinspect: resolve(__dirname, "tileinspect/index.html") archive: resolve(__dirname, "archive/index.html"),
tile: resolve(__dirname, "tile/index.html"),
}, },
}, },
}, },
}); })