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 { PMTiles } from "../../js/index";
import { GitHubLogoIcon } from "@radix-ui/react-icons";
@@ -14,9 +14,10 @@ const Header = styled("div", {
padding: "0 $2 0 $2",
});
const Title = styled("span", {
const Title = styled("a", {
fontWeight: 500,
cursor: "pointer",
color: "unset",
textDecoration: "none",
});
const GithubA = styled("a", {
@@ -88,7 +89,8 @@ function App() {
}
}, [file]);
let clear = () => {
let clear = (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
setFile(undefined);
};
@@ -99,7 +101,9 @@ function App() {
return (
<div>
<Header>
<Title onClick={clear}>PMTiles Viewer</Title>
<Title href="/" onClick={clear}>
PMTiles Viewer
</Title>
<GithubLink>
<GithubA href="https://github.com/protomaps/PMTiles" target="_blank">
<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 { PMTiles, TileType } from "../../js/index";
import { Protocol } from "../../js/adapters";
@@ -50,30 +50,112 @@ const Options = styled("div", {
zIndex: 9999,
});
const CheckboxLabel = styled("label", {
display: "flex",
gap: 6,
cursor: "pointer",
});
const LayersVisibilityList = styled("ul", {
listStyleType: "none",
});
const FeaturesProperties = (props: { features: MapGeoJSONFeature[] }) => {
const fs = props.features.map((f, i) => {
let tmp: [string, string][] = [];
for (var key in f.properties) {
tmp.push([key, f.properties[key]]);
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>{value}</td>
</tr>
))}
</table>
</FeatureRow>
))}
</PopupContainer>
);
};
interface LayerVisibility {
id: string;
visible: boolean;
}
const rows = tmp.map((d, i) => (
<tr key={i}>
<td>{d[0]}</td>
<td>{d[1]}</td>
</tr>
));
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 (
<FeatureRow key={i}>
<div>
<strong>{(f.layer as any)["source-layer"]}</strong>
</div>
<table>{rows}</table>
</FeatureRow>
<>
<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>
</>
);
});
return <PopupContainer>{fs}</PopupContainer>;
};
const rasterStyle = async (file: PMTiles): Promise<any> => {
@@ -128,7 +210,18 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id,
paint: {
"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"],
});
@@ -139,7 +232,12 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id,
paint: {
"line-color": schemeSet3[i % 12],
"line-width": 0.5,
"line-width": [
"case",
["boolean", ["feature-state", "hover"], false],
2,
0.5,
],
},
filter: ["==", ["geometry-type"], "LineString"],
});
@@ -150,6 +248,12 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
"source-layer": layer.id,
paint: {
"circle-color": schemeSet3[i % 12],
"circle-radius": [
"case",
["boolean", ["feature-state", "hover"], false],
6,
5,
],
},
filter: ["==", ["geometry-type"], "Point"],
});
@@ -166,6 +270,7 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
const bounds = [header.minLon, header.minLat, header.maxLon, header.maxLat];
return {
style: {
version: 8,
sources: {
source: {
@@ -186,6 +291,11 @@ const vectorStyle = async (file: PMTiles): Promise<any> => {
},
glyphs: "https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf",
layers: layers,
},
layersVisibility: vector_layers.map((l: any) => ({
id: l.id,
visible: true,
})),
};
};
@@ -194,13 +304,15 @@ function MaplibreMap(props: { file: PMTiles }) {
let [hamburgerOpen, setHamburgerOpen] = useState<boolean>(true);
let [showAttributes, setShowAttributes] = useState<boolean>(false);
let [showTileBoundaries, setShowTileBoundaries] = useState<boolean>(false);
let [layersVisibility, setLayersVisibility] = useState<LayerVisibility[]>([]);
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);
@@ -208,6 +320,9 @@ function MaplibreMap(props: { file: PMTiles }) {
const toggleShowAttributes = () => {
setShowAttributes(!showAttributes);
mapRef.current!.getCanvas().style.cursor = !showAttributes
? "crosshair"
: "";
};
const toggleShowTileBoundaries = () => {
@@ -215,6 +330,19 @@ function MaplibreMap(props: { file: PMTiles }) {
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(() => {
let protocol = new Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
@@ -242,18 +370,31 @@ function MaplibreMap(props: { file: PMTiles }) {
mapRef.current = map;
map.on("mousemove", (e) => {
const hoveredFeatures = hoveredFeaturesRef.current;
for (const feature of hoveredFeatures) {
map.setFeatureState(feature, { hover: false });
hoveredFeatures.delete(feature);
}
if (!showAttributesRef.current) {
popup.remove();
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
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} />);
if (!features.length) {
@@ -293,8 +434,9 @@ function MaplibreMap(props: { file: PMTiles }) {
let style = await rasterStyle(props.file);
map.setStyle(style);
} else {
let style = await vectorStyle(props.file);
let { style, layersVisibility } = await vectorStyle(props.file);
map.setStyle(style);
setLayersVisibility(layersVisibility);
}
}
};
@@ -310,21 +452,27 @@ function MaplibreMap(props: { file: PMTiles }) {
<Options>
<h4>Filter</h4>
<h4>Popup</h4>
<CheckboxLabel>
<input
type="checkbox"
id="showAttributes"
checked={showAttributes}
onChange={toggleShowAttributes}
/>
<label htmlFor="showAttributes">show attributes</label>
show attributes
</CheckboxLabel>
<h4>Tiles</h4>
<CheckboxLabel>
<input
type="checkbox"
id="showTileBoundaries"
checked={showTileBoundaries}
onChange={toggleShowTileBoundaries}
/>
<label htmlFor="showTileBoundaries">show tile boundaries</label>
show tile boundaries
</CheckboxLabel>
<LayersVisibilityController
layers={layersVisibility}
onChange={handleLayersVisibilityChange}
/>
</Options>
) : null}
</MapContainer>