mirror of
https://github.com/protomaps/PMTiles.git
synced 2026-02-04 02:41:09 +00:00
* hash value for iframe * app: modify UI for iframe mode * change position of zoom number to not be blocked by large attribution * improve appearance of filename in nav for local files * improve fill appearance in overlay mode
632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
/* @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<Tileset>;
|
|
showMetadata: Accessor<boolean>;
|
|
setShowMetadata: Setter<boolean>;
|
|
showTileBoundaries: Accessor<boolean>;
|
|
setShowTileBoundaries: Setter<boolean>;
|
|
inspectFeatures: Accessor<boolean>;
|
|
setInspectFeatures: Setter<boolean>;
|
|
mapHashPassed: boolean;
|
|
}) {
|
|
let mapContainer: HTMLDivElement | undefined;
|
|
let hiddenRef: HTMLDivElement | undefined;
|
|
const [zoom, setZoom] = createSignal<number>(0);
|
|
const [layerVisibility, setLayerVisibility] = createSignal<LayerVisibility[]>(
|
|
[],
|
|
);
|
|
const [hoveredFeatures, setHoveredFeatures] = createSignal<
|
|
MapGeoJSONFeature[]
|
|
>([]);
|
|
const [basemap, setBasemap] = createSignal<boolean>(false);
|
|
const [frozen, setFrozen] = createSignal<boolean>(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 © <a href='https://openstreetmap.org/copyright'>OpenStreetMap</a>",
|
|
},
|
|
},
|
|
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(
|
|
() => (
|
|
<div>
|
|
<FeatureTable features={inspectableFeatures()} />
|
|
<a
|
|
class="block text-xs btn-primary mt-2 text-center px-2"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
href={tileInspectUrl(props.tileset().getStateUrl(), [
|
|
z,
|
|
tileX,
|
|
tileY,
|
|
])}
|
|
>
|
|
Tile {z}/{tileX}/{tileY}
|
|
</a>
|
|
<div class="text-xs text-center mt-2 font-mono">
|
|
{e.lngLat.lng.toFixed(4)},{e.lngLat.lat.toFixed(4)}
|
|
</div>
|
|
</div>
|
|
),
|
|
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 (
|
|
<div class="flex flex-col md:flex-row w-full h-full">
|
|
<div class="flex-1 flex flex-col">
|
|
<div class="flex-none p-4 flex justify-between text-xs md:text-base space-x-2">
|
|
<button
|
|
class="px-4 btn-primary cursor-pointer"
|
|
type="button"
|
|
onClick={fitToBounds}
|
|
>
|
|
fit to bounds
|
|
</button>
|
|
<span class="app-border rounded px-2 flex items-center">
|
|
<input
|
|
class="mr-1 cursor-pointer"
|
|
id="inspectFeatures"
|
|
checked={props.inspectFeatures()}
|
|
type="checkbox"
|
|
onChange={() => {
|
|
props.setInspectFeatures(!props.inspectFeatures());
|
|
}}
|
|
/>
|
|
<label for="inspectFeatures" class="cursor-pointer">
|
|
Inspect features
|
|
</label>
|
|
</span>
|
|
<span class="app-border rounded px-2 flex items-center">
|
|
<input
|
|
class="mr-1 cursor-pointer"
|
|
id="showTileBoundaries"
|
|
checked={props.showTileBoundaries()}
|
|
type="checkbox"
|
|
onChange={() => {
|
|
props.setShowTileBoundaries(!props.showTileBoundaries());
|
|
}}
|
|
/>
|
|
<label class="cursor-pointer" for="showTileBoundaries">
|
|
Show tile bounds
|
|
</label>
|
|
</span>
|
|
<button
|
|
class="px-4 py-1 btn-secondary cursor-pointer"
|
|
onClick={() => {
|
|
props.setShowMetadata(!props.showMetadata());
|
|
}}
|
|
type="button"
|
|
>
|
|
view metadata
|
|
</button>
|
|
</div>
|
|
<div class="relative flex-1 h-full">
|
|
<div
|
|
ref={mapContainer}
|
|
classList={{
|
|
"h-full": true,
|
|
"flex-1": true,
|
|
"bg-gray-900": true,
|
|
inspectFeatures: props.inspectFeatures(),
|
|
frozen: frozen(),
|
|
}}
|
|
/>
|
|
<div class="hidden" ref={hiddenRef} />
|
|
<div class="absolute right-2 top-2 z-0">
|
|
<LayersPanel
|
|
layerVisibility={layerVisibility}
|
|
setLayerVisibility={setLayerVisibility}
|
|
basemapOption
|
|
basemap={basemap}
|
|
setBasemap={setBasemap}
|
|
/>
|
|
</div>
|
|
<div class="absolute left-3 top-28">
|
|
<button
|
|
type="button"
|
|
class="flex items-center rounded app-border cursor-pointer"
|
|
onClick={roundZoom}
|
|
>
|
|
<span class="app-well px-1 rounded-l">Z</span>
|
|
<span class="px-2 text-base rounded-r-md rounded-r">
|
|
{zoom().toFixed(2)}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Show when={props.showMetadata()}>
|
|
<div class="md:w-1/2 z-[999] app-bg">
|
|
<JsonView tileset={props.tileset} />
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const JsonView = (props: { tileset: Accessor<Tileset> }) => {
|
|
const [data] = createResource(async () => {
|
|
return await props.tileset().getMetadata();
|
|
});
|
|
|
|
return <json-viewer data={data()} />;
|
|
};
|
|
|
|
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<Tileset | undefined>(
|
|
hash.url ? tilesetFromString(decodeURIComponent(hash.url)) : undefined,
|
|
);
|
|
const [showMetadata, setShowMetadata] = createSignal<boolean>(
|
|
hash.showMetadata === "true" || false,
|
|
);
|
|
const [showTileBoundaries, setShowTileBoundaries] = createSignal<boolean>(
|
|
hash.showTileBoundaries === "true",
|
|
);
|
|
const [inspectFeatures, setInspectFeatures] = createSignal<boolean>(
|
|
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 (
|
|
<Frame tileset={tileset} setTileset={setTileset} page="map" iframe={iframe}>
|
|
<Show
|
|
when={tileset()}
|
|
fallback={<ExampleChooser setTileset={setTileset} />}
|
|
>
|
|
{(t) => (
|
|
<MapView
|
|
tileset={t}
|
|
showMetadata={showMetadata}
|
|
setShowMetadata={setShowMetadata}
|
|
showTileBoundaries={showTileBoundaries}
|
|
setShowTileBoundaries={setShowTileBoundaries}
|
|
inspectFeatures={inspectFeatures}
|
|
setInspectFeatures={setInspectFeatures}
|
|
mapHashPassed={mapHashPassed}
|
|
/>
|
|
)}
|
|
</Show>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
const root = document.getElementById("root");
|
|
|
|
if (root) {
|
|
render(() => <PageMap />, root);
|
|
}
|