mirror of
https://github.com/protomaps/PMTiles.git
synced 2026-02-04 10:51:07 +00:00
Merge pull request #145 from kajkal/pmtiles-viewer-ux-enhancements
PMTiles Viewer UX enhancements
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user