Merge pull request #145 from kajkal/pmtiles-viewer-ux-enhancements

PMTiles Viewer UX enhancements
This commit is contained in:
Brandon Liu
2023-03-27 10:46:27 +08:00
committed by GitHub
2 changed files with 215 additions and 63 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { styled, globalStyles } from "./stitches.config"; import { styled, globalStyles } from "./stitches.config";
import { PMTiles } from "../../js/index"; import { PMTiles } from "../../js/index";
import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { GitHubLogoIcon } from "@radix-ui/react-icons";
@@ -14,9 +14,10 @@ const Header = styled("div", {
padding: "0 $2 0 $2", padding: "0 $2 0 $2",
}); });
const Title = styled("span", { const Title = styled("a", {
fontWeight: 500, fontWeight: 500,
cursor: "pointer", color: "unset",
textDecoration: "none",
}); });
const GithubA = styled("a", { const GithubA = styled("a", {
@@ -88,7 +89,8 @@ function App() {
} }
}, [file]); }, [file]);
let clear = () => { let clear = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setFile(undefined); setFile(undefined);
}; };
@@ -99,7 +101,9 @@ function App() {
return ( return (
<div> <div>
<Header> <Header>
<Title onClick={clear}>PMTiles Viewer</Title> <Title href="/" onClick={clear}>
PMTiles Viewer
</Title>
<GithubLink> <GithubLink>
<GithubA href="https://github.com/protomaps/PMTiles" target="_blank"> <GithubA href="https://github.com/protomaps/PMTiles" target="_blank">
<GitHubLogoIcon /> {GIT_SHA} <GitHubLogoIcon /> {GIT_SHA}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { renderToString } from "react-dom/server"; import { renderToString } from "react-dom/server";
import { PMTiles, TileType } from "../../js/index"; import { PMTiles, TileType } from "../../js/index";
import { Protocol } from "../../js/adapters"; import { Protocol } from "../../js/adapters";
@@ -50,30 +50,112 @@ const Options = styled("div", {
zIndex: 9999, zIndex: 9999,
}); });
const CheckboxLabel = styled("label", {
display: "flex",
gap: 6,
cursor: "pointer",
});
const LayersVisibilityList = styled("ul", {
listStyleType: "none",
});
const FeaturesProperties = (props: { features: MapGeoJSONFeature[] }) => { const FeaturesProperties = (props: { features: MapGeoJSONFeature[] }) => {
const fs = props.features.map((f, i) => { return (
let tmp: [string, string][] = []; <PopupContainer>
for (var key in f.properties) { {props.features.map((f, i) => (
tmp.push([key, f.properties[key]]); <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>{value}</td>
</tr>
))}
</table>
</FeatureRow>
))}
</PopupContainer>
);
};
interface LayerVisibility {
id: string;
visible: boolean;
}
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]);
const rows = tmp.map((d, i) => ( if (!props.layers.length) {
<tr key={i}>
<td>{d[0]}</td>
<td>{d[1]}</td>
</tr>
));
return ( return (
<FeatureRow key={i}> <>
<div> <h4>Layers</h4>
<strong>{(f.layer as any)["source-layer"]}</strong> <span>No vector layers found</span>
</div> </>
<table>{rows}</table>
</FeatureRow>
); );
}); }
return <PopupContainer>{fs}</PopupContainer>;
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 }) => (
<li key={id}>
<CheckboxLabel style={{ paddingLeft: 8 }}>
<input
type="checkbox"
checked={visible}
onChange={toggleLayer}
data-layer-id={id}
/>
{id}
</CheckboxLabel>
</li>
))}
</LayersVisibilityList>
</>
);
}; };
const rasterStyle = async (file: PMTiles): Promise<any> => { const rasterStyle = async (file: PMTiles): Promise<any> => {
@@ -128,7 +210,18 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id, "source-layer": layer.id,
paint: { paint: {
"fill-color": schemeSet3[i % 12], "fill-color": schemeSet3[i % 12],
"fill-opacity": 0.2, "fill-opacity": [
"case",
["boolean", ["feature-state", "hover"], false],
0.35,
0.2,
],
"fill-outline-color": [
"case",
["boolean", ["feature-state", "hover"], false],
"hsl(0,100%,90%)",
"rgba(0,0,0,0)",
],
}, },
filter: ["==", ["geometry-type"], "Polygon"], filter: ["==", ["geometry-type"], "Polygon"],
}); });
@@ -139,7 +232,12 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id, "source-layer": layer.id,
paint: { paint: {
"line-color": schemeSet3[i % 12], "line-color": schemeSet3[i % 12],
"line-width": 0.5, "line-width": [
"case",
["boolean", ["feature-state", "hover"], false],
2,
0.5,
],
}, },
filter: ["==", ["geometry-type"], "LineString"], filter: ["==", ["geometry-type"], "LineString"],
}); });
@@ -150,6 +248,12 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id, "source-layer": layer.id,
paint: { paint: {
"circle-color": schemeSet3[i % 12], "circle-color": schemeSet3[i % 12],
"circle-radius": [
"case",
["boolean", ["feature-state", "hover"], false],
6,
5,
],
}, },
filter: ["==", ["geometry-type"], "Point"], filter: ["==", ["geometry-type"], "Point"],
}); });
@@ -166,26 +270,32 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
const bounds = [header.minLon, header.minLat, header.maxLon, header.maxLat]; const bounds = [header.minLon, header.minLat, header.maxLon, header.maxLat];
return { return {
version: 8, style: {
sources: { version: 8,
source: { sources: {
type: "vector", source: {
tiles: ["pmtiles://" + file.source.getKey() + "/{z}/{x}/{y}"], type: "vector",
minzoom: header.minZoom, tiles: ["pmtiles://" + file.source.getKey() + "/{z}/{x}/{y}"],
maxzoom: header.maxZoom, minzoom: header.minZoom,
bounds: bounds, maxzoom: header.maxZoom,
}, bounds: bounds,
basemap: { },
type: "vector", basemap: {
tiles: [ type: "vector",
"https://api.protomaps.com/tiles/v2/{z}/{x}/{y}.pbf?key=1003762824b9687f", tiles: [
], "https://api.protomaps.com/tiles/v2/{z}/{x}/{y}.pbf?key=1003762824b9687f",
maxzoom: 14, ],
bounds: bounds, maxzoom: 14,
bounds: bounds,
},
}, },
glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf",
layers: layers,
}, },
glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf", layersVisibility: vector_layers.map((l: any) => ({
layers: layers, id: l.id,
visible: true,
})),
}; };
}; };
@@ -194,13 +304,15 @@ function MaplibreMap(props: { file: PMTiles }) {
let [hamburgerOpen, setHamburgerOpen] = useState<boolean>(true); let [hamburgerOpen, setHamburgerOpen] = useState<boolean>(true);
let [showAttributes, setShowAttributes] = useState<boolean>(false); let [showAttributes, setShowAttributes] = useState<boolean>(false);
let [showTileBoundaries, setShowTileBoundaries] = useState<boolean>(false); let [showTileBoundaries, setShowTileBoundaries] = useState<boolean>(false);
let [layersVisibility, setLayersVisibility] = useState<LayerVisibility[]>([]);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
const hoveredFeaturesRef = useRef<Set<MapGeoJSONFeature>>(new Set());
// make it accessible in hook // make it accessible in hook
const showAttributesRef = useRef(showAttributes); const showAttributesRef = useRef(showAttributes);
useEffect(() => { useEffect(() => {
showAttributesRef.current = showAttributes; showAttributesRef.current = showAttributes;
}); }, [showAttributes]);
const toggleHamburger = () => { const toggleHamburger = () => {
setHamburgerOpen(!hamburgerOpen); setHamburgerOpen(!hamburgerOpen);
@@ -208,6 +320,9 @@ function MaplibreMap(props: { file: PMTiles }) {
const toggleShowAttributes = () => { const toggleShowAttributes = () => {
setShowAttributes(!showAttributes); setShowAttributes(!showAttributes);
mapRef.current!.getCanvas().style.cursor = !showAttributes
? "crosshair"
: "";
}; };
const toggleShowTileBoundaries = () => { const toggleShowTileBoundaries = () => {
@@ -215,6 +330,19 @@ function MaplibreMap(props: { file: PMTiles }) {
mapRef.current!.showTileBoundaries = !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(() => { useEffect(() => {
let protocol = new Protocol(); let protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile); maplibregl.addProtocol("pmtiles", protocol.tile);
@@ -242,18 +370,31 @@ function MaplibreMap(props: { file: PMTiles }) {
mapRef.current = map; mapRef.current = map;
map.on("mousemove", (e) => { map.on("mousemove", (e) => {
const hoveredFeatures = hoveredFeaturesRef.current;
for (const feature of hoveredFeatures) {
map.setFeatureState(feature, { hover: false });
hoveredFeatures.delete(feature);
}
if (!showAttributesRef.current) { if (!showAttributesRef.current) {
popup.remove(); popup.remove();
return; return;
} }
var bbox = e.point;
var features = map.queryRenderedFeatures(bbox); const { x, y } = e.point;
const r = 2; // radius around the point
var features = map.queryRenderedFeatures([
[x - r, y - r],
[x + r, y + r],
]);
// ignore the basemap // ignore the basemap
features = features.filter((feature) => feature.source === "source"); features = features.filter((feature) => feature.source === "source");
map.getCanvas().style.cursor = features.length ? "pointer" : ""; for (const feature of features) {
map.setFeatureState(feature, { hover: true });
hoveredFeatures.add(feature);
}
let content = renderToString(<FeaturesProperties features={features} />); let content = renderToString(<FeaturesProperties features={features} />);
if (!features.length) { if (!features.length) {
@@ -293,8 +434,9 @@ function MaplibreMap(props: { file: PMTiles }) {
let style = await rasterStyle(props.file); let style = await rasterStyle(props.file);
map.setStyle(style); map.setStyle(style);
} else { } else {
let style = await vectorStyle(props.file); let { style, layersVisibility } = await vectorStyle(props.file);
map.setStyle(style); map.setStyle(style);
setLayersVisibility(layersVisibility);
} }
} }
}; };
@@ -310,21 +452,27 @@ function MaplibreMap(props: { file: PMTiles }) {
<Options> <Options>
<h4>Filter</h4> <h4>Filter</h4>
<h4>Popup</h4> <h4>Popup</h4>
<input <CheckboxLabel>
type="checkbox" <input
id="showAttributes" type="checkbox"
checked={showAttributes} checked={showAttributes}
onChange={toggleShowAttributes} onChange={toggleShowAttributes}
/> />
<label htmlFor="showAttributes">show attributes</label> show attributes
</CheckboxLabel>
<h4>Tiles</h4> <h4>Tiles</h4>
<input <CheckboxLabel>
type="checkbox" <input
id="showTileBoundaries" type="checkbox"
checked={showTileBoundaries} checked={showTileBoundaries}
onChange={toggleShowTileBoundaries} onChange={toggleShowTileBoundaries}
/>
show tile boundaries
</CheckboxLabel>
<LayersVisibilityController
layers={layersVisibility}
onChange={handleLayersVisibilityChange}
/> />
<label htmlFor="showTileBoundaries">show tile boundaries</label>
</Options> </Options>
) : null} ) : null}
</MapContainer> </MapContainer>