mirror of
https://github.com/protomaps/PMTiles.git
synced 2026-02-04 02:41:09 +00:00
app: Rewrite of PMTiles viewer (pmtiles.io) [#49, #551, #555, #556] * Rewrite the app using SolidJS / Tailwind. * Make the map view mobile friendly. * Add an archive inspector for viewing the low level structure of PMTiles. * Add a tile inspector for viewing a single tile in isolation. * Support TileJSON. * Support raster archives.
This commit is contained in:
2
.github/workflows/actions.yml
vendored
2
.github/workflows/actions.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
node-version: 18.x
|
||||
- run: cd js && npm ci && npm run build
|
||||
- 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/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
|
||||
|
||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@@ -22,4 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1,4 +1,22 @@
|
||||
# Install
|
||||
## Usage
|
||||
|
||||
* First go to the `/js` directory in the root of the PMTiles repository and `npm install`
|
||||
* `npm run dev` to start the app on port 3000
|
||||
```bash
|
||||
$ 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
17
app/archive/index.html
Normal 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>
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"useNamingConvention": {}
|
||||
},
|
||||
"nursery": {
|
||||
"noUnusedImports": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/MapView.tsx"></script>
|
||||
<script type="module" src="/src/PageMap.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4523
app/package-lock.json
generated
4523
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,52 +2,46 @@
|
||||
"name": "pmtiles-app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tsc": "tsc --watch",
|
||||
"prettier": "prettier --write src/*",
|
||||
"prettier-check": "prettier --check src/*"
|
||||
"check": "biome check src --javascript-formatter-indent-style=space --json-formatter-indent-style=space",
|
||||
"format": "biome format --write src --javascript-formatter-indent-style=space --json-formatter-indent-style=space"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
"@radix-ui/react-dialog": "^0.1.7",
|
||||
"@radix-ui/react-icons": "^1.1.0",
|
||||
"@radix-ui/react-label": "^0.1.5",
|
||||
"@radix-ui/react-toolbar": "^0.1.5",
|
||||
"@stitches/react": "^1.2.8",
|
||||
"@textea/json-viewer": "^2.11.2",
|
||||
"d3-path": "^3.0.1",
|
||||
"d3-scale-chromatic": "^3.0.0",
|
||||
"fflate": "^0.7.3",
|
||||
"maplibre-gl": "5.1.0",
|
||||
"pbf": "^3.2.1",
|
||||
"protomaps-themes-base": "4.4.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-dropzone": "^14.1.1",
|
||||
"react-svg-pan-zoom": "^3.11.0",
|
||||
"react-use": "^17.4.0"
|
||||
"@alenaksu/json-viewer": "^2.1.2",
|
||||
"@mapbox/sphericalmercator": "^2.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.3",
|
||||
"@protomaps/basemaps": "5.2.0",
|
||||
"d3-axis": "^3.0.0",
|
||||
"d3-path": "^3.1.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale-chromatic": "^3.1.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"maplibre-gl": "5.3.0",
|
||||
"pbf": "^4.0.1",
|
||||
"pmtiles": "^4.3.0",
|
||||
"solid-js": "^1.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.5.3",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@maplibre/maplibre-gl-style-spec": "^23.1.0",
|
||||
"@types/d3-path": "^3.0.0",
|
||||
"@types/d3-scale-chromatic": "^3.0.0",
|
||||
"@types/leaflet": "^1.7.9",
|
||||
"@types/mapbox__vector-tile": "^1.3.0",
|
||||
"@types/pako": "^1.0.3",
|
||||
"@types/pbf": "^3.0.2",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@types/react-svg-pan-zoom": "^3.3.5",
|
||||
"@vitejs/plugin-react": "^1.3.0",
|
||||
"prettier": "^2.8.4",
|
||||
"typescript": "^4.6.3",
|
||||
"vite": "^6.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "$react",
|
||||
"react-dom": "$react-dom"
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/d3-axis": "^3.0.6",
|
||||
"@types/d3-path": "^3.1.1",
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"@types/d3-scale-chromatic": "^3.1.0",
|
||||
"@types/d3-selection": "^3.0.11",
|
||||
"@types/d3-transition": "^3.0.9",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"@types/node": "^22.13.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"tailwindcss": "^4.1.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.2.6",
|
||||
"vite-plugin-solid": "^2.11.6"
|
||||
}
|
||||
}
|
||||
|
||||
47
app/src/FeatureTable.tsx
Normal file
47
app/src/FeatureTable.tsx
Normal 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
259
app/src/Frame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
137
app/src/LayersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
534
app/src/PageArchive.tsx
Normal 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
539
app/src/PageMap.tsx
Normal 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
536
app/src/PageTile.tsx
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
118
app/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
206
app/src/tileset.ts
Normal 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
70
app/src/utils.ts
Normal 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
17
app/tile/index.html
Normal 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>
|
||||
@@ -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
27
app/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"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" }]
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node"
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { resolve } from "path";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from 'vite'
|
||||
import solid from 'vite-plugin-solid'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [solid(), tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
mapview: resolve(__dirname, "index.html"),
|
||||
tileinspect: resolve(__dirname, "tileinspect/index.html")
|
||||
map: resolve(__dirname, "index.html"),
|
||||
archive: resolve(__dirname, "archive/index.html"),
|
||||
tile: resolve(__dirname, "tile/index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user