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
|
node-version: 18.x
|
||||||
- run: cd js && npm ci && npm run build
|
- run: cd js && npm ci && npm run build
|
||||||
- run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env
|
- run: echo "VITE_GIT_SHA=$(git rev-parse --short HEAD)" >> app/.env
|
||||||
- run: cd app && npm ci && ./node_modules/.bin/tsc && npm run prettier-check && ./node_modules/.bin/vite build
|
- run: cd app && npm ci && npm run check && npm run build
|
||||||
- run: cd serverless/aws && npm ci && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist
|
- run: cd serverless/aws && npm ci && npx tsc && npm run biome-check && npm run build-zip && cp dist/lambda_function.zip ../../app/dist && npm run build-cloudformation-stack && cp dist/cloudformation-stack.yaml ../../app/dist
|
||||||
- run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm ci && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist
|
- run: cd serverless/cloudflare && cp wrangler.toml.example wrangler.toml && npm ci && npx tsc && npm run biome-check && npm run build && cp dist/index.js ../../app/dist
|
||||||
- run: cd spec/v3 && cp *.pmtiles ../../app/dist
|
- run: cd spec/v3 && cp *.pmtiles ../../app/dist
|
||||||
|
|||||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@@ -22,4 +22,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.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`
|
```bash
|
||||||
* `npm run dev` to start the app on port 3000
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Scripts
|
||||||
|
|
||||||
|
In the project directory, you can run:
|
||||||
|
|
||||||
|
### `npm run dev`
|
||||||
|
|
||||||
|
Runs the app in the development mode.<br>
|
||||||
|
Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
|
||||||
|
|
||||||
|
### `npm run build`
|
||||||
|
|
||||||
|
Builds the app for production to the `dist` folder.<br>
|
||||||
|
It correctly bundles Solid in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
|
The build is minified and the filenames include the hashes.<br>
|
||||||
|
Your app is ready to be deployed!
|
||||||
17
app/archive/index.html
Normal file
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">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="data:,">
|
<meta name="theme-color" content="#3131DC"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PMTiles Viewer</title>
|
<title>PMTiles viewer</title>
|
||||||
|
<link rel="preconnect" href="https://api.protomaps.com"/>
|
||||||
|
<link rel="preconnect" href="https://protomaps.github.io"/>
|
||||||
|
<link rel="preconnect" href="https://unpkg.com"/>
|
||||||
|
<link rel="preconnect" href="https://demo-bucket.protomaps.com"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/MapView.tsx"></script>
|
<script type="module" src="/src/PageMap.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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",
|
"name": "pmtiles-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tsc": "tsc --watch",
|
"check": "biome check src --javascript-formatter-indent-style=space --json-formatter-indent-style=space",
|
||||||
"prettier": "prettier --write src/*",
|
"format": "biome format --write src --javascript-formatter-indent-style=space --json-formatter-indent-style=space"
|
||||||
"prettier-check": "prettier --check src/*"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mapbox/vector-tile": "^1.3.1",
|
"@alenaksu/json-viewer": "^2.1.2",
|
||||||
"@radix-ui/react-dialog": "^0.1.7",
|
"@mapbox/sphericalmercator": "^2.0.1",
|
||||||
"@radix-ui/react-icons": "^1.1.0",
|
"@mapbox/vector-tile": "^2.0.3",
|
||||||
"@radix-ui/react-label": "^0.1.5",
|
"@protomaps/basemaps": "5.2.0",
|
||||||
"@radix-ui/react-toolbar": "^0.1.5",
|
"d3-axis": "^3.0.0",
|
||||||
"@stitches/react": "^1.2.8",
|
"d3-path": "^3.1.0",
|
||||||
"@textea/json-viewer": "^2.11.2",
|
"d3-scale": "^4.0.2",
|
||||||
"d3-path": "^3.0.1",
|
"d3-scale-chromatic": "^3.1.0",
|
||||||
"d3-scale-chromatic": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
"fflate": "^0.7.3",
|
"d3-zoom": "^3.0.0",
|
||||||
"maplibre-gl": "5.1.0",
|
"maplibre-gl": "5.3.0",
|
||||||
"pbf": "^3.2.1",
|
"pbf": "^4.0.1",
|
||||||
"protomaps-themes-base": "4.4.0",
|
"pmtiles": "^4.3.0",
|
||||||
"react": "^18.0.0",
|
"solid-js": "^1.9.5"
|
||||||
"react-dom": "^18.0.0",
|
|
||||||
"react-dropzone": "^14.1.1",
|
|
||||||
"react-svg-pan-zoom": "^3.11.0",
|
|
||||||
"react-use": "^17.4.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.5.3",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@maplibre/maplibre-gl-style-spec": "^23.1.0",
|
"@maplibre/maplibre-gl-style-spec": "^23.1.0",
|
||||||
"@types/d3-path": "^3.0.0",
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
"@types/d3-scale-chromatic": "^3.0.0",
|
"@types/d3-axis": "^3.0.6",
|
||||||
"@types/leaflet": "^1.7.9",
|
"@types/d3-path": "^3.1.1",
|
||||||
"@types/mapbox__vector-tile": "^1.3.0",
|
"@types/d3-scale": "^4.0.9",
|
||||||
"@types/pako": "^1.0.3",
|
"@types/d3-scale-chromatic": "^3.1.0",
|
||||||
"@types/pbf": "^3.0.2",
|
"@types/d3-selection": "^3.0.11",
|
||||||
"@types/react": "^18.0.0",
|
"@types/d3-transition": "^3.0.9",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/d3-zoom": "^3.0.8",
|
||||||
"@types/react-svg-pan-zoom": "^3.3.5",
|
"@types/node": "^22.13.1",
|
||||||
"@vitejs/plugin-react": "^1.3.0",
|
"jsdom": "^26.0.0",
|
||||||
"prettier": "^2.8.4",
|
"tailwindcss": "^4.1.3",
|
||||||
"typescript": "^4.6.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.2.4"
|
"vite": "^6.2.6",
|
||||||
},
|
"vite-plugin-solid": "^2.11.6"
|
||||||
"overrides": {
|
|
||||||
"react": "$react",
|
|
||||||
"react-dom": "$react-dom"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/src/FeatureTable.tsx
Normal file
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": {
|
"files": [],
|
||||||
"target": "ESNext",
|
"references": [
|
||||||
"useDefineForClassFields": true,
|
{ "path": "./tsconfig.app.json" },
|
||||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
{ "path": "./tsconfig.node.json" }
|
||||||
"allowJs": false,
|
]
|
||||||
"skipLibCheck": true,
|
|
||||||
"esModuleInterop": false,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
},
|
|
||||||
"include": ["src"],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"module": "esnext",
|
"target": "ES2022",
|
||||||
"moduleResolution": "node"
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { defineConfig } from "vite";
|
import { resolve } from "node:path";
|
||||||
import react from "@vitejs/plugin-react";
|
import { defineConfig } from 'vite'
|
||||||
import { resolve } from "path";
|
import solid from 'vite-plugin-solid'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [solid(), tailwindcss()],
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
mapview: resolve(__dirname, "index.html"),
|
map: resolve(__dirname, "index.html"),
|
||||||
tileinspect: resolve(__dirname, "tileinspect/index.html")
|
archive: resolve(__dirname, "archive/index.html"),
|
||||||
|
tile: resolve(__dirname, "tile/index.html"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user