/* @refresh reload */ import "maplibre-gl/dist/maplibre-gl.css"; import "./index.css"; import { layers, namedFlavor } from "@protomaps/basemaps"; import { AttributionControl, type MapGeoJSONFeature, Map as MaplibreMap, NavigationControl, Popup, addProtocol, getRTLTextPluginStatus, setRTLTextPlugin, } from "maplibre-gl"; import { type Accessor, type Setter, Show, createEffect, createMemo, createResource, createSignal, onMount, } from "solid-js"; import { render } from "solid-js/web"; import "@alenaksu/json-viewer"; import { SphericalMercator } from "@mapbox/sphericalmercator"; import { Protocol } from "pmtiles"; import { FeatureTable } from "./FeatureTable"; import { ExampleChooser, Frame } from "./Frame"; import { type LayerVisibility, LayersPanel } from "./LayersPanel"; import { type Tileset, tilesetFromString } from "./tileset"; import { colorForIdx, createHash, parseHash, tileInspectUrl } from "./utils"; declare module "solid-js" { namespace JSX { interface IntrinsicElements { "json-viewer": unknown; } } } function MapView(props: { tileset: Accessor; showMetadata: Accessor; setShowMetadata: Setter; showTileBoundaries: Accessor; setShowTileBoundaries: Setter; inspectFeatures: Accessor; setInspectFeatures: Setter; mapHashPassed: boolean; }) { let mapContainer: HTMLDivElement | undefined; let hiddenRef: HTMLDivElement | undefined; const [zoom, setZoom] = createSignal(0); const [layerVisibility, setLayerVisibility] = createSignal( [], ); const [hoveredFeatures, setHoveredFeatures] = createSignal< MapGeoJSONFeature[] >([]); const [basemap, setBasemap] = createSignal(false); const [frozen, setFrozen] = createSignal(false); const inspectableFeatures = createMemo(() => { return hoveredFeatures().map((h) => { return { layerName: h.sourceLayer || "unknown", id: h.id ? (h.id as number) : undefined, properties: h.properties, type: h._vectorTileFeature.type, }; }); }); const popup = new Popup({ closeButton: false, closeOnClick: false, maxWidth: "none", }); const protocol = new Protocol({ metadata: true }); addProtocol("pmtiles", protocol.tile); let map: MaplibreMap; let initialLoad = true; const roundZoom = () => { map.zoomTo(Math.round(map.getZoom())); }; const fitToBounds = async () => { const bounds = await props.tileset().getBounds(); map.fitBounds( [ [bounds[0], bounds[1]], [bounds[2], bounds[3]], ], { animate: false }, ); }; const removeTileset = () => { for (const layer of map.getStyle().layers) { if ("source" in layer && layer.source === "tileset") { map.removeLayer(layer.id); } } if ("tileset" in map.getStyle().sources) { map.removeSource("tileset"); } }; const addTileset = async (tileset: Tileset) => { const archiveForProtocol = tileset.archiveForProtocol(); if (archiveForProtocol) { protocol.add(archiveForProtocol); } let fillOpacity = 0.2; let fillHighlightOpacity = 0.4; if (await tileset.isOverlay()) { setBasemap(true); fillOpacity = 0.6; fillHighlightOpacity = 0.8; } if (await tileset.isVector()) { map.addSource("tileset", { type: "vector", url: tileset.getMaplibreSourceUrl(), }); const vectorLayers = await tileset.getVectorLayers(); setLayerVisibility(vectorLayers.map((v) => ({ id: v, visible: true }))); for (const [i, vectorLayer] of vectorLayers.entries()) { map.addLayer({ id: `tileset_fill_${vectorLayer}`, type: "fill", source: "tileset", "source-layer": vectorLayer, paint: { "fill-color": colorForIdx(i), "fill-opacity": [ "case", ["boolean", ["feature-state", "hover"], false], fillHighlightOpacity, fillOpacity, ], }, filter: ["==", ["geometry-type"], "Polygon"], }); map.addLayer({ id: `tileset_line_${vectorLayer}`, type: "line", source: "tileset", "source-layer": vectorLayer, paint: { "line-color": colorForIdx(i), "line-width": [ "case", ["boolean", ["feature-state", "hover"], false], 2, 0.5, ], }, filter: ["==", ["geometry-type"], "LineString"], }); map.addLayer({ id: `tileset_circle_${vectorLayer}`, type: "circle", source: "tileset", "source-layer": vectorLayer, paint: { "circle-color": colorForIdx(i), "circle-radius": ["interpolate", ["linear"], ["zoom"], 4, 2, 12, 4], "circle-opacity": 0.5, "circle-stroke-color": "white", "circle-stroke-width": [ "case", ["boolean", ["feature-state", "hover"], false], 3, 0, ], }, filter: ["==", ["geometry-type"], "Point"], }); } for (const [i, vectorLayer] of vectorLayers.entries()) { map.addLayer({ id: `tileset_line_label_${vectorLayer}`, type: "symbol", source: "tileset", "source-layer": vectorLayer, layout: { "text-field": ["get", "name"], "text-font": ["Noto Sans Regular"], "text-size": 10, "symbol-placement": "line", }, paint: { "text-color": colorForIdx(i), "text-halo-color": "black", "text-halo-width": 2, }, filter: ["==", ["geometry-type"], "LineString"], }); map.addLayer({ id: `tileset_point_label_${vectorLayer}`, type: "symbol", source: "tileset", "source-layer": vectorLayer, layout: { "text-field": ["get", "name"], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-offset": [0, -1], }, paint: { "text-color": colorForIdx(i), "text-halo-color": "black", "text-halo-width": 2, }, filter: ["==", ["geometry-type"], "Point"], }); map.addLayer({ id: `tileset_polygon_label_${vectorLayer}`, type: "symbol", source: "tileset", "source-layer": vectorLayer, layout: { "text-field": ["get", "name"], "text-font": ["Noto Sans Regular"], "text-max-angle": 85, "text-offset": [0, 1], "text-anchor": "bottom", "text-rotation-alignment": "map", "text-keep-upright": true, "text-size": 10, "symbol-placement": "line", "symbol-spacing": 250, }, paint: { "text-color": colorForIdx(i), "text-halo-color": "black", "text-halo-width": 2, }, filter: ["==", ["geometry-type"], "Polygon"], }); } } else { map.addSource("tileset", { type: "raster", url: tileset.getMaplibreSourceUrl(), }); map.addLayer({ source: "tileset", id: "tileset_raster", type: "raster", paint: { "raster-resampling": "nearest", }, }); } }; createEffect(() => { const tileset = props.tileset(); if (initialLoad) { initialLoad = false; return; } removeTileset(); addTileset(tileset); }); createEffect(() => { const visibility = basemap() ? "visible" : "none"; if (map) { for (const layer of map.getStyle().layers) { if ("source" in layer && layer.source === "basemap") { map.setLayoutProperty(layer.id, "visibility", visibility); } } } }); createEffect(() => { const show = props.showTileBoundaries(); if (map) { map.showTileBoundaries = show; } }); createEffect(() => { if (props.inspectFeatures()) { setFrozen(false); } else { for (const hoveredFeature of hoveredFeatures()) { if (hoveredFeature.id === undefined) continue; map.setFeatureState(hoveredFeature, { hover: false }); } popup.remove(); } }); createEffect(() => { const setVisibility = (layerName: string, visibility: string) => { if (map.getLayer(layerName)) { map.setLayoutProperty(layerName, "visibility", visibility); } }; for (const { id, visible } of layerVisibility()) { const visibility = visible ? "visible" : "none"; setVisibility(`tileset_fill_${id}`, visibility); setVisibility(`tileset_line_${id}`, visibility); setVisibility(`tileset_circle_${id}`, visibility); setVisibility(`tileset_line_label_${id}`, visibility); setVisibility(`tileset_point_label_${id}`, visibility); setVisibility(`tileset_polygon_label_${id}`, visibility); } }); onMount(async () => { if (!mapContainer) { console.error("Could not mount map element"); return; } if (getRTLTextPluginStatus() === "unavailable") { setRTLTextPlugin( "https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.min.js", true, ); } map = new MaplibreMap({ hash: "map", container: mapContainer, attributionControl: false, style: { version: 8, glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/black", sources: { basemap: { type: "vector", tiles: [ "https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=1003762824b9687f", ], maxzoom: 15, attribution: "Background © OpenStreetMap", }, }, layers: layers("basemap", namedFlavor("black"), { lang: "en" }).map( (l) => { if (!("layout" in l)) { l.layout = {}; } if (l.layout) l.layout.visibility = "none"; return l; }, ), }, }); map.addControl(new NavigationControl({}), "top-left"); map.addControl(new AttributionControl({ compact: false }), "bottom-right"); if (!props.mapHashPassed) { fitToBounds(); } if (props.showTileBoundaries()) { map.showTileBoundaries = true; } setZoom(map.getZoom()); map.on("zoom", (e) => { setZoom(e.target.getZoom()); }); map.on("mousemove", async (e) => { if (frozen()) return; if (!props.inspectFeatures()) { return; } for (const hoveredFeature of hoveredFeatures()) { if (hoveredFeature.id === undefined) continue; map.setFeatureState(hoveredFeature, { hover: false }); } const { x, y } = e.point; const r = 2; // radius around the point let features = map.queryRenderedFeatures([ [x - r, y - r], [x + r, y + r], ]); features = features.filter((feature) => feature.source === "tileset"); for (const feature of features) { if (feature.id === undefined) continue; map.setFeatureState(feature, { hover: true }); } setHoveredFeatures(features); const currentZoom = zoom(); const sp = new SphericalMercator(); const maxZoom = await props.tileset().getMaxZoom(); const z = Math.max(0, Math.min(maxZoom, Math.floor(currentZoom))); const result = sp.px([e.lngLat.lng, e.lngLat.lat], z); const tileX = Math.floor(result[0] / 256); const tileY = Math.floor(result[1] / 256); if (hiddenRef) { hiddenRef.innerHTML = ""; render( () => (
Tile {z}/{tileX}/{tileY}
{e.lngLat.lng.toFixed(4)},{e.lngLat.lat.toFixed(4)}
), hiddenRef, ); popup.setHTML(hiddenRef.innerHTML); popup.setLngLat(e.lngLat); popup.addTo(map); } }); map.on("click", () => { setFrozen(!frozen()); }); map.on("load", async () => { await addTileset(props.tileset()); map.resize(); }); }); return (
{ props.setInspectFeatures(!props.inspectFeatures()); }} /> { props.setShowTileBoundaries(!props.showTileBoundaries()); }} />
); } const JsonView = (props: { tileset: Accessor }) => { const [data] = createResource(async () => { return await props.tileset().getMetadata(); }); return ; }; function PageMap() { let hash = parseHash(location.hash); // the previous version of the PMTiles viewer // used query params ?url= instead of #url= // this makes it backward compatible so old-style links still work. const href = new URL(window.location.href); const queryParamUrl = href.searchParams.get("url"); if (queryParamUrl) { href.searchParams.delete("url"); history.pushState(null, "", href.toString()); location.hash = createHash(location.hash, { url: queryParamUrl, map: hash.map, }); hash = parseHash(location.hash); } const iframe = hash.iframe === "true"; const mapHashPassed = hash.map !== undefined; const [tileset, setTileset] = createSignal( hash.url ? tilesetFromString(decodeURIComponent(hash.url)) : undefined, ); const [showMetadata, setShowMetadata] = createSignal( hash.showMetadata === "true" || false, ); const [showTileBoundaries, setShowTileBoundaries] = createSignal( hash.showTileBoundaries === "true", ); const [inspectFeatures, setInspectFeatures] = createSignal( hash.inspectFeatures === "true", ); createEffect(() => { const t = tileset(); const stateUrl = t?.getStateUrl(); location.hash = createHash(location.hash, { url: stateUrl ? encodeURIComponent(stateUrl) : undefined, showMetadata: showMetadata() ? "true" : undefined, showTileBoundaries: showTileBoundaries() ? "true" : undefined, inspectFeatures: inspectFeatures() ? "true" : undefined, }); }); return ( } > {(t) => ( )} ); } const root = document.getElementById("root"); if (root) { render(() => , root); }