improvements to inspector app

This commit is contained in:
Brandon Liu
2022-06-13 16:48:58 +08:00
parent beef7a3ab7
commit 9962b4e344
8 changed files with 241 additions and 138 deletions

View File

@@ -1,12 +1,31 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { styled, globalStyles } from "./stitches.config"; import { styled, globalStyles } from "./stitches.config";
import { PMTiles } from "../../js"; import { PMTiles } from "../../js";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Start from "./Start"; import Start from "./Start";
import Loader from "./Loader"; import Loader from "./Loader";
const Header = styled("div", { const Header = styled("div", {
height: "$4", height: "$4",
display: "flex",
alignItems: "center",
padding: "0 $2 0 $2",
});
const Title = styled("span", {
fontWeight: 500,
cursor: "pointer",
});
const GithubA = styled("a", {
color: "white",
textDecoration: "none",
fontSize: "$1",
});
const GithubLink = styled("span", {
marginLeft: "auto",
}); });
const GIT_SHA = (import.meta.env.VITE_GIT_SHA || "").substr(0, 8); const GIT_SHA = (import.meta.env.VITE_GIT_SHA || "").substr(0, 8);
@@ -37,8 +56,12 @@ function App() {
return ( return (
<div> <div>
<Header> <Header>
<span onClick={clear}>pmtiles viewer</span> | github | toggle |{" "} <Title onClick={clear}>PMTiles Viewer</Title>
{GIT_SHA} <GithubLink>
<GithubA href="https://github.com/protomaps/PMTiles" target="_blank">
<GitHubLogoIcon /> {GIT_SHA}
</GithubA>
</GithubLink>
</Header> </Header>
{file ? <Loader file={file} /> : <Start setFile={setFile} />} {file ? <Loader file={file} /> : <Start setFile={setFile} />}
</div> </div>

View File

@@ -8,16 +8,24 @@ import { VectorTile, VectorTileFeature } from "@mapbox/vector-tile";
import { path } from "d3-path"; import { path } from "d3-path";
import { schemeSet3 } from "d3-scale-chromatic"; import { schemeSet3 } from "d3-scale-chromatic";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { introspectTileType, TileType } from "./Loader";
const TableContainer = styled("div", { const TableContainer = styled("div", {
height: "calc(100vh - $4)", height: "calc(100vh - $4 - $4)",
overflowY: "scroll", overflowY: "scroll",
width: "50%", width: "50%",
padding: "$2",
}); });
const Pane = styled("div", { const Pane = styled("div", {
width: "50%", width: "50%",
backgroundColor: "black", backgroundColor: "black",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundSize: "20px 20px",
backgroundImage:
"linear-gradient(to right, #222 1px, transparent 1px),linear-gradient(to bottom, #222 1px, transparent 1px);",
}); });
const TableRow = styled( const TableRow = styled(
@@ -58,6 +66,7 @@ interface Layer {
} }
interface Feature { interface Feature {
layerName: string;
path: string; path: string;
type: number; type: number;
id: number; id: number;
@@ -148,7 +157,14 @@ const FeatureProperties = (props: { feature: Feature }) => {
</tr> </tr>
)); ));
return <StyledFeatureProperties>{rows}</StyledFeatureProperties>; return (
<StyledFeatureProperties>
{props.feature.layerName}
<table>
<tbody>{rows}</tbody>
</table>
</StyledFeatureProperties>
);
}; };
const VectorPreview = (props: { file: PMTiles; entry: Entry }) => { const VectorPreview = (props: { file: PMTiles; entry: Entry }) => {
@@ -193,6 +209,7 @@ const VectorPreview = (props: { file: PMTiles; entry: Entry }) => {
type: feature.type, type: feature.type,
id: feature.id, id: feature.id,
properties: feature.properties, properties: feature.properties,
layerName: name,
}); });
} }
newLayers.push({ features: features, name: name }); newLayers.push({ features: features, name: name });
@@ -248,11 +265,14 @@ const RasterPreview = (props: { file: PMTiles; entry: Entry }) => {
function Inspector(props: { file: PMTiles }) { function Inspector(props: { file: PMTiles }) {
let [entryRows, setEntryRows] = useState<Entry[]>([]); let [entryRows, setEntryRows] = useState<Entry[]>([]);
let [selectedEntry, setSelectedEntry] = useState<Entry | null>(null); let [selectedEntry, setSelectedEntry] = useState<Entry | null>(null);
let [tileType, setTileType] = useState<TileType>(TileType.UNKNOWN);
useEffect(() => { useEffect(() => {
let fn = async () => { let fn = async () => {
let entries = await props.file.root_entries(); let entries = await props.file.root_entries();
let tileType = await introspectTileType(props.file);
setEntryRows(entries); setEntryRows(entries);
setTileType(tileType);
}; };
fn(); fn();
@@ -263,9 +283,12 @@ function Inspector(props: { file: PMTiles }) {
)); ));
let tilePreview = <div></div>; let tilePreview = <div></div>;
if (selectedEntry) { if (selectedEntry && tileType) {
if (tileType === TileType.MVT) {
tilePreview = <VectorPreview file={props.file} entry={selectedEntry} />; tilePreview = <VectorPreview file={props.file} entry={selectedEntry} />;
} else { } else {
tilePreview = <RasterPreview file={props.file} entry={selectedEntry} />;
}
} }
return ( return (

View File

@@ -1,26 +1,62 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { PMTiles, leafletLayer } from "../../js"; import { PMTiles, leafletLayer as rasterLeafletLayer } from "../../js";
import { leafletLayer as vectorLeafletLayer } from "protomaps";
import { styled } from "./stitches.config"; import { styled } from "./stitches.config";
import { introspectTileType, TileType } from "./Loader";
import L from "leaflet"; import L from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
const MapContainer = styled("div", { const MapContainer = styled("div", {
height: "calc(100vh - $4)", height: "calc(100vh - $4 - $4)",
}); });
function LeafletMap(props: { file: PMTiles; tileType: string | null }) { function LeafletMap(props: { file: PMTiles }) {
var map: L.Map;
var currentLayer: L.Layer;
useEffect(() => { useEffect(() => {
const map = L.map("map").setView([0, 0], 0); map = L.map("map").setView([0, 0], 0);
leafletLayer(props.file, {
attribution:
'© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',
}).addTo(map);
return () => { return () => {
map.remove(); map.remove();
}; };
}, []); }, []);
useEffect(() => {
if (currentLayer) currentLayer.remove();
let initStyle = async () => {
if (map) {
let tileType = await introspectTileType(props.file);
if (tileType === TileType.PNG || tileType == TileType.JPG) {
currentLayer = rasterLeafletLayer(props.file, {
attribution:
'© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors',
});
currentLayer.addTo(map);
} else {
console.error("leaflet vector preview not yet implemented");
// let metadata = await props.file.metadata();
// let rules: PaintRule[] = [];
// if (metadata.json) {
// let root = JSON.parse(metadata.json);
// if (root.tilestats) {
// for (let layer of root.tilestats.layers) {
// if (layer.geometry === "Polygon") {
// } else if (layer.geometry === "LineString") {
// } else {
// }
// }
// }
// }
// currentLayer = vectorLeafletLayer(props.file, {paintRules:rules,labelRules:[]})
}
}
};
initStyle();
}, []);
return <MapContainer id="map"></MapContainer>; return <MapContainer id="map"></MapContainer>;
} }

View File

@@ -10,13 +10,20 @@ import { MagnifyingGlassIcon, ImageIcon } from "@radix-ui/react-icons";
import * as ToolbarPrimitive from "@radix-ui/react-toolbar"; import * as ToolbarPrimitive from "@radix-ui/react-toolbar";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
export enum TileType {
UNKNOWN = 1,
PNG,
JPG,
MVT,
MVT_GZ,
}
const StyledToolbar = styled(ToolbarPrimitive.Root, { const StyledToolbar = styled(ToolbarPrimitive.Root, {
display: "flex", display: "flex",
padding: 10, height: "$4",
width: "100%", width: "100%",
boxSizing: "border-box", boxSizing: "border-box",
minWidth: "max-content", minWidth: "max-content",
borderRadius: 6,
backgroundColor: "white", backgroundColor: "white",
boxShadow: `0 2px 10px "black"`, boxShadow: `0 2px 10px "black"`,
}); });
@@ -24,31 +31,15 @@ const StyledToolbar = styled(ToolbarPrimitive.Root, {
const itemStyles = { const itemStyles = {
all: "unset", all: "unset",
flex: "0 0 auto", flex: "0 0 auto",
color: "black", color: "$black",
height: 25,
padding: "0 5px",
borderRadius: 4,
display: "inline-flex", display: "inline-flex",
fontSize: 13, padding: "0 $1 0 $1",
lineHeight: 1, fontSize: "$2",
alignItems: "center", alignItems: "center",
justifyContent: "center", "&:hover": { backgroundColor: "$hover", color: "$white" },
"&:hover": { backgroundColor: "$hover", color: "blue" },
"&:focus": { position: "relative", boxShadow: `0 0 0 2px blue` }, "&:focus": { position: "relative", boxShadow: `0 0 0 2px blue` },
}; };
const StyledButton = styled(
ToolbarPrimitive.Button,
{
...itemStyles,
paddingLeft: 10,
paddingRight: 10,
color: "white",
backgroundColor: "blue",
},
{ "&:hover": { color: "white", backgroundColor: "red" } }
);
const StyledLink = styled( const StyledLink = styled(
ToolbarPrimitive.Link, ToolbarPrimitive.Link,
{ {
@@ -62,12 +53,6 @@ const StyledLink = styled(
{ "&:hover": { backgroundColor: "transparent", cursor: "pointer" } } { "&:hover": { backgroundColor: "transparent", cursor: "pointer" } }
); );
const StyledSeparator = styled(ToolbarPrimitive.Separator, {
width: 1,
backgroundColor: "black",
margin: "0 10px",
});
const StyledToggleGroup = styled(ToolbarPrimitive.ToggleGroup, { const StyledToggleGroup = styled(ToolbarPrimitive.ToggleGroup, {
display: "inline-flex", display: "inline-flex",
borderRadius: 4, borderRadius: 4,
@@ -79,7 +64,7 @@ const StyledToggleItem = styled(ToolbarPrimitive.ToggleItem, {
backgroundColor: "white", backgroundColor: "white",
marginLeft: 2, marginLeft: 2,
"&:first-child": { marginLeft: 0 }, "&:first-child": { marginLeft: 0 },
"&[data-state=on]": { backgroundColor: "red", color: "blue" }, "&[data-state=on]": { backgroundColor: "$primary", color: "white" },
}); });
const StyledOverlay = styled(DialogPrimitive.Overlay, { const StyledOverlay = styled(DialogPrimitive.Overlay, {
@@ -91,40 +76,52 @@ const StyledOverlay = styled(DialogPrimitive.Overlay, {
}); });
const StyledContent = styled(DialogPrimitive.Content, { const StyledContent = styled(DialogPrimitive.Content, {
backgroundColor: "white", backgroundColor: "$black",
borderRadius: 6, borderRadius: 6,
boxShadow:
"hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
position: "fixed", position: "fixed",
top: "50%", top: "50%",
left: "50%", left: "50%",
transform: "translate(-50%, -50%)", transform: "translate(-50%, -50%)",
width: "90vw", width: "90vw",
maxWidth: "450px", maxWidth: "80vh",
maxHeight: "85vh",
padding: 25,
zIndex: 4, zIndex: 4,
"&:focus": { outline: "none" }, "&:focus": { outline: "none" },
}); });
export const introspectTileType = async (file: PMTiles): Promise<TileType> => {
let magic = await file.source.getBytes(512000, 4);
let b0 = magic.getUint8(0);
let b1 = magic.getUint8(1);
let b2 = magic.getUint8(2);
let b3 = magic.getUint8(3);
if (b0 == 0x89 && b1 == 0x50 && b2 == 0x4e && b3 == 0x47) {
return TileType.PNG;
} else if (b0 == 0xff && b1 == 0xd8 && b2 == 0xff && b3 == 0xe0) {
return TileType.JPG;
} else if (b0 == 0x1f && b1 == 0x8b) {
return TileType.MVT_GZ;
} else {
return TileType.MVT;
}
};
const Toolbar = StyledToolbar; const Toolbar = StyledToolbar;
const ToolbarButton = StyledButton;
const ToolbarSeparator = StyledSeparator;
const ToolbarLink = StyledLink; const ToolbarLink = StyledLink;
const ToolbarToggleGroup = StyledToggleGroup; const ToolbarToggleGroup = StyledToggleGroup;
const ToolbarToggleItem = StyledToggleItem; const ToolbarToggleItem = StyledToggleItem;
function Loader(props: { file: PMTiles }) { function Loader(props: { file: PMTiles }) {
let [tab, setTab] = useState("inspector"); let [tab, setTab] = useState("inspector");
let [tileType, setTileType] = useState<string | null>(null); let [tileType, setTileType] = useState<TileType>(TileType.UNKNOWN);
let [metadata, setMetadata] = useState<[string, string][]>([]); let [metadata, setMetadata] = useState<[string, string][]>([]);
let [modalOpen, setModalOpen] = useState<boolean>(false); let [modalOpen, setModalOpen] = useState<boolean>(false);
let view; let view;
if (tab === "leaflet") { if (tab === "leaflet") {
view = <LeafletMap file={props.file} tileType={tileType} />; view = <LeafletMap file={props.file} />;
} else if (tab === "maplibre") { } else if (tab === "maplibre") {
view = <MaplibreMap file={props.file} tileType={tileType} />; view = <MaplibreMap file={props.file} />;
} else { } else {
view = <Inspector file={props.file} />; view = <Inspector file={props.file} />;
} }
@@ -138,22 +135,6 @@ function Loader(props: { file: PMTiles }) {
tmp.push([key, m[key]]); tmp.push([key, m[key]]);
} }
setMetadata(tmp); setMetadata(tmp);
let magic = await props.file.source.getBytes(512000, 4);
let b0 = magic.getUint8(0);
let b1 = magic.getUint8(1);
let b2 = magic.getUint8(2);
let b3 = magic.getUint8(3);
if (b0 == 0x89 && b1 == 0x50 && b2 == 0x4e && b3 == 0x47) {
setTileType("png");
} else if (b0 == 0xff && b1 == 0xd8 && b2 == 0xff && b3 == 0xe0) {
setTileType("jpg");
} else if (b0 == 0x1f && b1 == 0x8b) {
setTileType("mvt.gz");
} else {
setTileType("mvt");
}
}; };
fetchData(); fetchData();
}, [props.file]); }, [props.file]);
@@ -189,20 +170,13 @@ function Loader(props: { file: PMTiles }) {
MapLibre MapLibre
</ToolbarToggleItem> </ToolbarToggleItem>
</ToolbarToggleGroup> </ToolbarToggleGroup>
<ToolbarSeparator />
<ToolbarLink href="#" target="_blank" css={{ marginRight: 10 }}> <ToolbarLink href="#" target="_blank" css={{ marginRight: 10 }}>
{props.file.source.getKey()} {props.file.source.getKey()}
</ToolbarLink> </ToolbarLink>
<ToolbarButton
css={{ marginLeft: "auto" }}
onClick={() => setModalOpen(true)}
>
Metadata
</ToolbarButton>
</Toolbar>
<DialogPrimitive.Root open={modalOpen}> <DialogPrimitive.Root open={modalOpen}>
<DialogPrimitive.Trigger /> <DialogPrimitive.Trigger onClick={() => setModalOpen(true)}>
Metadata
</DialogPrimitive.Trigger>
<DialogPrimitive.Portal> <DialogPrimitive.Portal>
<StyledOverlay /> <StyledOverlay />
<StyledContent <StyledContent
@@ -224,6 +198,8 @@ function Loader(props: { file: PMTiles }) {
</StyledContent> </StyledContent>
</DialogPrimitive.Portal> </DialogPrimitive.Portal>
</DialogPrimitive.Root> </DialogPrimitive.Root>
</Toolbar>
{view} {view}
</> </>
); );

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { PMTiles, ProtocolCache } from "../../js"; import { PMTiles, ProtocolCache } from "../../js";
import { styled } from "./stitches.config"; import { styled } from "./stitches.config";
import { introspectTileType, TileType } from "./Loader";
import maplibregl from "maplibre-gl"; import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
const MapContainer = styled("div", { const MapContainer = styled("div", {
height: "calc(100vh - $4)", height: "calc(100vh - $4 - $4)",
}); });
const rasterStyle = (file: PMTiles) => { const rasterStyle = (file: PMTiles) => {
@@ -28,53 +29,81 @@ const rasterStyle = (file: PMTiles) => {
}; };
}; };
const vectorStyle = (file: PMTiles) => { const vectorStyle = async (file: PMTiles): Promise<any> => {
let metadata = await file.metadata();
let layers: any[] = [];
if (metadata.json) {
let root = JSON.parse(metadata.json);
if (root.tilestats) {
for (let layer of root.tilestats.layers) {
if (layer.geometry === "Polygon") {
layers.push({
id: layer.layer + "_fill",
type: "fill",
source: "source",
"source-layer": layer.layer,
paint: {
"fill-color": "white",
},
});
} else if (layer.geometry === "LineString") {
layers.push({
id: layer.layer + "_stroke",
type: "line",
source: "source",
"source-layer": layer.layer,
paint: {
"line-color": "steelblue",
"line-width": 0.5,
},
});
} else {
layers.push({
id: layer.layer + "_point",
type: "circle",
source: "source",
"source-layer": layer.layer,
paint: {
"circle-color": "red",
},
});
}
}
}
}
return { return {
version: 8, version: 8,
sources: { sources: {
source: { source: {
type: "vector", type: "vector",
tiles: ["pmtiles://" + file.source.getKey() + "/{z}/{x}/{y}"], tiles: ["pmtiles://" + file.source.getKey() + "/{z}/{x}/{y}"],
maxzoom: 7, maxzoom: 10,
}, },
}, },
layers: [ layers: layers,
{
id: "zcta_fill",
type: "fill",
source: "source",
"source-layer": "zcta",
paint: {
"fill-color": "white",
},
},
{
id: "zcta_stroke",
type: "line",
source: "source",
"source-layer": "zcta",
paint: {
"line-color": "steelblue",
"line-width": 0.5,
},
},
],
}; };
}; };
function MaplibreMap(props: { file: PMTiles; tileType: string | null }) { function MaplibreMap(props: { file: PMTiles }) {
let mapRef = useRef<HTMLDivElement>(null); let mapContainerRef = useRef<HTMLDivElement>(null);
let map: maplibregl.Map;
useEffect(() => { useEffect(() => {
let cache = new ProtocolCache(); let cache = new ProtocolCache();
maplibregl.addProtocol("pmtiles", cache.protocol); maplibregl.addProtocol("pmtiles", cache.protocol);
cache.add(props.file);
const map = new maplibregl.Map({ map = new maplibregl.Map({
container: "map", container: mapContainerRef.current!,
zoom: 0, zoom: 0,
center: [0, 0], center: [0, 0],
style: rasterStyle(props.file) as any, style: {
}); // TODO maplibre types (not any) version: 8,
sources: {},
layers: [],
},
});
map.addControl(new maplibregl.NavigationControl({})); map.addControl(new maplibregl.NavigationControl({}));
map.on("load", map.resize); map.on("load", map.resize);
@@ -83,7 +112,24 @@ function MaplibreMap(props: { file: PMTiles; tileType: string | null }) {
}; };
}, []); }, []);
return <MapContainer id="map" ref={mapRef}></MapContainer>; useEffect(() => {
let initStyle = async () => {
if (map) {
let tileType = await introspectTileType(props.file);
let style: any; // TODO maplibre types (not any)
if (tileType === TileType.PNG || tileType == TileType.JPG) {
map.setStyle(rasterStyle(props.file) as any);
} else {
let style = await vectorStyle(props.file);
map.setStyle(style);
}
}
};
initStyle();
}, []);
return <MapContainer ref={mapContainerRef}></MapContainer>;
} }
export default MaplibreMap; export default MaplibreMap;

View File

@@ -129,7 +129,7 @@ function Start(props: {
}; };
const onSubmit = () => { const onSubmit = () => {
// props.setFile(new PMTiles("abcd")); props.setFile(new PMTiles(remoteUrl));
}; };
return ( return (

View File

@@ -5,8 +5,8 @@ export const { styled } = createStitches({
colors: { colors: {
black: "rgba(0, 0, 0)", black: "rgba(0, 0, 0)",
white: "rgba(236, 237, 238)", white: "rgba(236, 237, 238)",
hover: "#666", hover: "#7180B9",
selected: "#444", primary: "#3423A6",
}, },
fonts: { fonts: {
sans: "Inter, sans-serif", sans: "Inter, sans-serif",
@@ -55,7 +55,6 @@ export const globalStyles = globalCss({
padding: 0, padding: 0,
border: 0, border: 0,
fontFamily: "Inter, sans-serif", fontFamily: "Inter, sans-serif",
xborder: "1px solid gold",
}, },
body: { backgroundColor: "$black", color: "$white" }, body: { backgroundColor: "$black", color: "$white" },
"@import": ["url('https://rsms.me/inter/inter.css')"], "@import": ["url('https://rsms.me/inter/inter.css')"],

View File

@@ -536,7 +536,7 @@ export class ProtocolCache {
instance!.source instance!.source
.getBytes(val.offset, val.length) .getBytes(val.offset, val.length)
.then((arr) => { .then((arr) => {
callback(null, arr, null, null); callback(null, new Uint8Array(arr.buffer), null, null);
}) })
.catch((e) => { .catch((e) => {
callback(new Error("Canceled"), null, null, null); callback(new Error("Canceled"), null, null, null);