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 Protomaps © OpenStreetMap'; 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 ( {props.features.map((f, i) => ( {(f.layer as any)["source-layer"]} ({f.geometry.type}) {Object.entries(f.properties).map(([key, value], i) => ( ))}
{key} {typeof value === "boolean" ? JSON.stringify(value) : value}
))}
); }; 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(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 ( <>

Layers

No vector layers found ); } const toggleAllLayers = () => { const visible = visibleLayersCount !== layers.length; const newLayersVisibility = layers.map((l) => ({ ...l, visible })); onChange(newLayersVisibility); }; const toggleLayer = (event: React.ChangeEvent) => { const layerId = event.target.getAttribute("data-layer-id"); const newLayersVisibility = layers.map((l) => l.id === layerId ? { ...l, visible: !l.visible } : l ); onChange(newLayersVisibility); }; return ( <>

Layers

All layers {props.layers.map(({ id, visible }, idx) => (
  • {id}
  • ))}
    ); }; const rasterStyle = async (file: PMTiles): Promise => { 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(null); const [hamburgerOpen, setHamburgerOpen] = useState(true); const [showAttributes, setShowAttributes] = useState(false); const [showTileBoundaries, setShowTileBoundaries] = useState(false); const [layersVisibility, setLayersVisibility] = useState( [] ); const [popupFrozen, setPopupFrozen] = useState(false); const popupFrozenRef = useRef(); popupFrozenRef.current = popupFrozen; const mapRef = useRef(null); const hoveredFeaturesRef = useRef>(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( ); 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 (
    menu {hamburgerOpen ? (

    Filter

    Popup

    show attributes

    Tiles

    show tile boundaries
    ) : null} ); } export default MaplibreMap;