Files
PMTiles/app/src/MaplibreMap.tsx
2024-05-30 11:51:30 +08:00

543 lines
14 KiB
TypeScript

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/adapters";
import { PMTiles, TileType } from "../../js/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/v3/{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);
}
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);
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) => {
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;